@kontourai/flow-agents 0.2.0 → 0.4.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.
- package/.github/workflows/release-please.yml +13 -1
- package/.github/workflows/runtime-compat.yml +1 -1
- package/AGENTS.md +8 -1
- package/CHANGELOG.md +41 -0
- package/README.md +38 -19
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +18 -1
- package/console.telemetry.json +115 -20
- package/docs/_layouts/default.html +2 -0
- package/docs/index.md +8 -0
- package/docs/integrations/index.md +4 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/spec/runtime-hook-surface.md +56 -3
- package/evals/acceptance/run.sh +10 -1
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_pi_harness.sh +15 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/evals/static/test_universal_bundles.sh +10 -0
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/steering.py +54 -1
- package/integrations/strands/tests/test_hooks.py +88 -0
- package/integrations/strands-ts/src/hooks.ts +104 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +902 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1469 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/adapters/similarity-vector/index.js +284 -0
- package/kits/knowledge/docs/README.md +328 -0
- package/kits/knowledge/docs/store-contract.md +650 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +675 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1173 -0
- package/kits/knowledge/evals/similarity-vector/suite.test.js +685 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +916 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/retire.flow.json +77 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +98 -0
- package/package.json +1 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/runtime-adapters.ts +35 -0
- package/src/tools/build-universal-bundles.ts +18 -1
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit — Retirement Eval Suite (S7)
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* AC1: retirement happens only via approved proposal carrying the retirement
|
|
6
|
+
* rationale/ref. Direct status mutations without the gate are not permitted.
|
|
7
|
+
* After propose, the record status is unchanged; only after apply does the
|
|
8
|
+
* status transition.
|
|
9
|
+
*
|
|
10
|
+
* AC2: retired record excluded from listByType/listByCategory/similarity
|
|
11
|
+
* defaults but returned with includeRetired flag; provenance (mutation_log)
|
|
12
|
+
* is intact; get() always returns the full record regardless of status.
|
|
13
|
+
*
|
|
14
|
+
* AC3: a snapshot consolidation after retirement reflects the pruned working set
|
|
15
|
+
* (retired compiled records do not appear in the consolidation cluster);
|
|
16
|
+
* the retired decision still reachable from snapshot provenance history
|
|
17
|
+
* via get(id).
|
|
18
|
+
*
|
|
19
|
+
* Rejection leaves status byte-identical (rejection path does not mutate status).
|
|
20
|
+
* Gate telemetry: identify, propose-retirement, evidence, apply gate events emitted.
|
|
21
|
+
* Status transition table enforcement (invalid transitions throw MISSING_EVIDENCE).
|
|
22
|
+
* implementedByRef required when targetStatus="implemented".
|
|
23
|
+
*
|
|
24
|
+
* Run:
|
|
25
|
+
* node --test kits/knowledge/evals/retirement/suite.test.js
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { test, describe, before, after } from "node:test";
|
|
29
|
+
import assert from "node:assert/strict";
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import * as os from "node:os";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
|
|
35
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const KIT_ROOT = path.resolve(__dirname, "../..");
|
|
37
|
+
|
|
38
|
+
const adapterPath = path.join(KIT_ROOT, "adapters/default-store/index.js");
|
|
39
|
+
const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
|
|
40
|
+
const vectorPath = path.join(KIT_ROOT, "adapters/similarity-vector/index.js");
|
|
41
|
+
|
|
42
|
+
const { DefaultKnowledgeStore } = await import(adapterPath);
|
|
43
|
+
const { KnowledgeFlowRunner, defaultSimilarityDetector } = await import(runnerPath);
|
|
44
|
+
const { createVectorSimilarityDetector } = await import(vectorPath);
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Helpers
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
function makeTempDir() {
|
|
51
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-retirement-"));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function makeStore(dir) {
|
|
55
|
+
return new DefaultKnowledgeStore({ storeRoot: dir });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeRunner(store, dir) {
|
|
59
|
+
return new KnowledgeFlowRunner({
|
|
60
|
+
store,
|
|
61
|
+
workspace: dir,
|
|
62
|
+
agent: "retirement-test-runner",
|
|
63
|
+
sessionId: "retirement-session-001",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readTelemetryEvents(dir) {
|
|
68
|
+
const sinkPath = path.join(dir, ".telemetry", "full.jsonl");
|
|
69
|
+
if (!fs.existsSync(sinkPath)) return [];
|
|
70
|
+
return fs.readFileSync(sinkPath, "utf8")
|
|
71
|
+
.trim()
|
|
72
|
+
.split("\n")
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.map((line) => JSON.parse(line));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build a baseline fixture:
|
|
79
|
+
* - 2 compiled records in category "ops.decisions"
|
|
80
|
+
* - 1 raw record
|
|
81
|
+
* - 1 concept record
|
|
82
|
+
* Returns { store, rawId, compiledId1, compiledId2, conceptId }
|
|
83
|
+
*/
|
|
84
|
+
async function buildFixture(dir) {
|
|
85
|
+
const store = makeStore(dir);
|
|
86
|
+
|
|
87
|
+
const rawId = await store.create({
|
|
88
|
+
type: "raw",
|
|
89
|
+
title: "Raw capture: API decision",
|
|
90
|
+
body: "We should use REST for the public-facing API.",
|
|
91
|
+
category: "ops.decisions",
|
|
92
|
+
provenance: { agent: "fixture" },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const compiledId1 = await store.create({
|
|
96
|
+
type: "compiled",
|
|
97
|
+
title: "Decision: Use REST for public API",
|
|
98
|
+
body: "## Decision\n\nWe will use REST for the public API.",
|
|
99
|
+
category: "ops.decisions",
|
|
100
|
+
provenance: { agent: "fixture", source_ids: [rawId] },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const compiledId2 = await store.create({
|
|
104
|
+
type: "compiled",
|
|
105
|
+
title: "Decision: Versioning via URL path",
|
|
106
|
+
body: "## Decision\n\nVersioning will be done via URL path (/v1/, /v2/).",
|
|
107
|
+
category: "ops.decisions",
|
|
108
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const conceptId = await store.create({
|
|
112
|
+
type: "concept",
|
|
113
|
+
title: "REST API Design",
|
|
114
|
+
body: "REST is the standard for our public API.",
|
|
115
|
+
category: "ops.decisions",
|
|
116
|
+
provenance: { agent: "fixture" },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { store, rawId, compiledId1, compiledId2, conceptId };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// AC1 — retirement only via approved proposal with rationale/ref
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
describe("AC1 — retirement only via approved proposal with rationale/ref", () => {
|
|
127
|
+
test("retire with decision=apply transitions record status to 'retired'", async () => {
|
|
128
|
+
const dir = makeTempDir();
|
|
129
|
+
try {
|
|
130
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
131
|
+
const runner = makeRunner(store, dir);
|
|
132
|
+
|
|
133
|
+
const before = await store.get(compiledId1);
|
|
134
|
+
assert.equal(before.status || "active", "active", "record starts as active");
|
|
135
|
+
|
|
136
|
+
await runner.retire(compiledId1, {
|
|
137
|
+
targetStatus: "retired",
|
|
138
|
+
rationale: "This decision has been superseded by the versioning policy.",
|
|
139
|
+
decision: "apply",
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const after = await store.get(compiledId1);
|
|
143
|
+
assert.equal(after.status, "retired", "AC1: record status is 'retired' after apply");
|
|
144
|
+
} finally {
|
|
145
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("retire with targetStatus='implemented' requires implementedByRef", async () => {
|
|
150
|
+
const dir = makeTempDir();
|
|
151
|
+
try {
|
|
152
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
153
|
+
const runner = makeRunner(store, dir);
|
|
154
|
+
|
|
155
|
+
await assert.rejects(
|
|
156
|
+
() => runner.retire(compiledId1, {
|
|
157
|
+
targetStatus: "implemented",
|
|
158
|
+
rationale: "This decision was implemented.",
|
|
159
|
+
// implementedByRef intentionally omitted
|
|
160
|
+
decision: "apply",
|
|
161
|
+
}),
|
|
162
|
+
(err) => {
|
|
163
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
164
|
+
assert.match(err.message, /implementedByRef/);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
);
|
|
168
|
+
} finally {
|
|
169
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("retire with targetStatus='implemented' and implementedByRef transitions to 'implemented'", async () => {
|
|
174
|
+
const dir = makeTempDir();
|
|
175
|
+
try {
|
|
176
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
177
|
+
const runner = makeRunner(store, dir);
|
|
178
|
+
|
|
179
|
+
await runner.retire(compiledId1, {
|
|
180
|
+
targetStatus: "implemented",
|
|
181
|
+
rationale: "Shipped in PR #42.",
|
|
182
|
+
implementedByRef: "https://github.com/org/repo/pull/42",
|
|
183
|
+
decision: "apply",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const after = await store.get(compiledId1);
|
|
187
|
+
assert.equal(after.status, "implemented", "AC1: status transitions to 'implemented'");
|
|
188
|
+
|
|
189
|
+
// mutation_log carries the evidence
|
|
190
|
+
const logEntry = (after.mutation_log || []).find((e) => e.op === "retire");
|
|
191
|
+
assert.ok(logEntry, "AC1: mutation log has retire entry");
|
|
192
|
+
assert.equal(logEntry.evidence.implementedByRef, "https://github.com/org/repo/pull/42");
|
|
193
|
+
assert.equal(logEntry.evidence.rationale, "Shipped in PR #42.");
|
|
194
|
+
} finally {
|
|
195
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("AC1: proposal uses store propose op, not direct mutation — proposer has proposes link", async () => {
|
|
200
|
+
const dir = makeTempDir();
|
|
201
|
+
try {
|
|
202
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
203
|
+
const runner = makeRunner(store, dir);
|
|
204
|
+
|
|
205
|
+
const result = await runner.retire(compiledId1, {
|
|
206
|
+
targetStatus: "retired",
|
|
207
|
+
rationale: "Obsolete — superseded by new spec.",
|
|
208
|
+
decision: "apply",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// The proposer record must have a "proposes" link to the target record
|
|
212
|
+
const { forward } = await store.getLinks(result.proposerId);
|
|
213
|
+
assert.ok(
|
|
214
|
+
forward.some((l) => l.target_id === compiledId1 && l.kind === "proposes"),
|
|
215
|
+
"AC1: proposer has proposes link to target record"
|
|
216
|
+
);
|
|
217
|
+
} finally {
|
|
218
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("AC1: mutation log on target record has propose entry before retire entry", async () => {
|
|
223
|
+
const dir = makeTempDir();
|
|
224
|
+
try {
|
|
225
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
226
|
+
const runner = makeRunner(store, dir);
|
|
227
|
+
|
|
228
|
+
await runner.retire(compiledId1, {
|
|
229
|
+
targetStatus: "retired",
|
|
230
|
+
rationale: "Superseded.",
|
|
231
|
+
decision: "apply",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const after = await store.get(compiledId1);
|
|
235
|
+
const logOps = (after.mutation_log || []).map((e) => e.op);
|
|
236
|
+
assert.ok(logOps.includes("propose"), "AC1: mutation log has propose entry");
|
|
237
|
+
assert.ok(logOps.includes("retire"), "AC1: mutation log has retire entry");
|
|
238
|
+
// propose must appear before retire
|
|
239
|
+
const proposeIdx = logOps.indexOf("propose");
|
|
240
|
+
const retireIdx = logOps.indexOf("retire");
|
|
241
|
+
assert.ok(proposeIdx < retireIdx, "AC1: propose log entry precedes retire entry");
|
|
242
|
+
} finally {
|
|
243
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("AC1: retire requires non-empty rationale", async () => {
|
|
248
|
+
const dir = makeTempDir();
|
|
249
|
+
try {
|
|
250
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
251
|
+
const runner = makeRunner(store, dir);
|
|
252
|
+
|
|
253
|
+
await assert.rejects(
|
|
254
|
+
() => runner.retire(compiledId1, {
|
|
255
|
+
targetStatus: "retired",
|
|
256
|
+
rationale: "",
|
|
257
|
+
decision: "apply",
|
|
258
|
+
}),
|
|
259
|
+
(err) => {
|
|
260
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
261
|
+
assert.match(err.message, /rationale/);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
);
|
|
265
|
+
} finally {
|
|
266
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("AC1: retire rejects invalid targetStatus", async () => {
|
|
271
|
+
const dir = makeTempDir();
|
|
272
|
+
try {
|
|
273
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
274
|
+
const runner = makeRunner(store, dir);
|
|
275
|
+
|
|
276
|
+
await assert.rejects(
|
|
277
|
+
() => runner.retire(compiledId1, {
|
|
278
|
+
targetStatus: "deleted",
|
|
279
|
+
rationale: "Trying an invalid status.",
|
|
280
|
+
decision: "apply",
|
|
281
|
+
}),
|
|
282
|
+
(err) => {
|
|
283
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
284
|
+
assert.match(err.message, /targetStatus/);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
);
|
|
288
|
+
} finally {
|
|
289
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("AC1: retire rejects nonexistent record id", async () => {
|
|
294
|
+
const dir = makeTempDir();
|
|
295
|
+
try {
|
|
296
|
+
const store = makeStore(dir);
|
|
297
|
+
const runner = makeRunner(store, dir);
|
|
298
|
+
|
|
299
|
+
await assert.rejects(
|
|
300
|
+
() => runner.retire("nonexistent-id", {
|
|
301
|
+
targetStatus: "retired",
|
|
302
|
+
rationale: "This record does not exist.",
|
|
303
|
+
decision: "apply",
|
|
304
|
+
}),
|
|
305
|
+
(err) => {
|
|
306
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
307
|
+
assert.match(err.message, /record not found/);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
);
|
|
311
|
+
} finally {
|
|
312
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// AC1 — status transition table enforcement
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
describe("AC1 — status transition table enforcement", () => {
|
|
322
|
+
test("active → retired is valid", async () => {
|
|
323
|
+
const dir = makeTempDir();
|
|
324
|
+
try {
|
|
325
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
326
|
+
const runner = makeRunner(store, dir);
|
|
327
|
+
|
|
328
|
+
await runner.retire(compiledId1, {
|
|
329
|
+
targetStatus: "retired",
|
|
330
|
+
rationale: "Obsolete.",
|
|
331
|
+
decision: "apply",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const rec = await store.get(compiledId1);
|
|
335
|
+
assert.equal(rec.status, "retired");
|
|
336
|
+
} finally {
|
|
337
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("active → implemented → retired is valid (two-step)", async () => {
|
|
342
|
+
const dir = makeTempDir();
|
|
343
|
+
try {
|
|
344
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
345
|
+
const runner = makeRunner(store, dir);
|
|
346
|
+
|
|
347
|
+
// Step 1: active → implemented
|
|
348
|
+
await runner.retire(compiledId1, {
|
|
349
|
+
targetStatus: "implemented",
|
|
350
|
+
rationale: "Shipped in PR #1.",
|
|
351
|
+
implementedByRef: "PR #1",
|
|
352
|
+
decision: "apply",
|
|
353
|
+
});
|
|
354
|
+
let rec = await store.get(compiledId1);
|
|
355
|
+
assert.equal(rec.status, "implemented");
|
|
356
|
+
|
|
357
|
+
// Step 2: implemented → retired
|
|
358
|
+
await runner.retire(compiledId1, {
|
|
359
|
+
targetStatus: "retired",
|
|
360
|
+
rationale: "Fully superseded, archiving.",
|
|
361
|
+
decision: "apply",
|
|
362
|
+
});
|
|
363
|
+
rec = await store.get(compiledId1);
|
|
364
|
+
assert.equal(rec.status, "retired");
|
|
365
|
+
} finally {
|
|
366
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("retired → * is invalid (terminal state)", async () => {
|
|
371
|
+
const dir = makeTempDir();
|
|
372
|
+
try {
|
|
373
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
374
|
+
const runner = makeRunner(store, dir);
|
|
375
|
+
|
|
376
|
+
// First retire
|
|
377
|
+
await runner.retire(compiledId1, {
|
|
378
|
+
targetStatus: "retired",
|
|
379
|
+
rationale: "First retirement.",
|
|
380
|
+
decision: "apply",
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Then try again
|
|
384
|
+
await assert.rejects(
|
|
385
|
+
() => runner.retire(compiledId1, {
|
|
386
|
+
targetStatus: "implemented",
|
|
387
|
+
rationale: "Trying to re-open.",
|
|
388
|
+
implementedByRef: "PR #99",
|
|
389
|
+
decision: "apply",
|
|
390
|
+
}),
|
|
391
|
+
(err) => {
|
|
392
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
393
|
+
assert.match(err.message, /invalid transition/);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
);
|
|
397
|
+
} finally {
|
|
398
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("store.retire enforces transition table directly", async () => {
|
|
403
|
+
const dir = makeTempDir();
|
|
404
|
+
try {
|
|
405
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
406
|
+
|
|
407
|
+
// Retire directly via store
|
|
408
|
+
await store.retire(compiledId1, "retired", {
|
|
409
|
+
agent: "tester",
|
|
410
|
+
rationale: "Direct store retire.",
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// Attempting invalid transition via store throws MISSING_EVIDENCE
|
|
414
|
+
await assert.rejects(
|
|
415
|
+
() => store.retire(compiledId1, "implemented", {
|
|
416
|
+
agent: "tester",
|
|
417
|
+
rationale: "Cannot go back.",
|
|
418
|
+
implementedByRef: "PR #X",
|
|
419
|
+
}),
|
|
420
|
+
(err) => {
|
|
421
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
422
|
+
assert.match(err.message, /invalid transition/);
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
);
|
|
426
|
+
} finally {
|
|
427
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
// AC1 — rejection leaves status byte-identical
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
|
|
436
|
+
describe("AC1 / rejection — rejection leaves record status byte-identical", () => {
|
|
437
|
+
test("retire with decision=reject leaves record status unchanged", async () => {
|
|
438
|
+
const dir = makeTempDir();
|
|
439
|
+
try {
|
|
440
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
441
|
+
const runner = makeRunner(store, dir);
|
|
442
|
+
|
|
443
|
+
const before = await store.get(compiledId1);
|
|
444
|
+
const originalBody = before.body;
|
|
445
|
+
const originalStatus = before.status || "active";
|
|
446
|
+
|
|
447
|
+
await runner.retire(compiledId1, {
|
|
448
|
+
targetStatus: "retired",
|
|
449
|
+
rationale: "Proposing retirement.",
|
|
450
|
+
decision: "reject",
|
|
451
|
+
rejectReason: "AC1: verifying that rejection does not mutate status.",
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const after = await store.get(compiledId1);
|
|
455
|
+
assert.equal(
|
|
456
|
+
after.status || "active",
|
|
457
|
+
originalStatus,
|
|
458
|
+
"AC1: status unchanged after rejection"
|
|
459
|
+
);
|
|
460
|
+
assert.equal(after.body, originalBody, "AC1: body unchanged after rejection");
|
|
461
|
+
} finally {
|
|
462
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("retire rejection requires rejectReason", async () => {
|
|
467
|
+
const dir = makeTempDir();
|
|
468
|
+
try {
|
|
469
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
470
|
+
const runner = makeRunner(store, dir);
|
|
471
|
+
|
|
472
|
+
await assert.rejects(
|
|
473
|
+
() => runner.retire(compiledId1, {
|
|
474
|
+
targetStatus: "retired",
|
|
475
|
+
rationale: "Proposing.",
|
|
476
|
+
decision: "reject",
|
|
477
|
+
// rejectReason intentionally omitted
|
|
478
|
+
}),
|
|
479
|
+
(err) => {
|
|
480
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
481
|
+
assert.match(err.message, /rejectReason/);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
);
|
|
485
|
+
} finally {
|
|
486
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("reject path: mutation log has reject entry but no retire entry", async () => {
|
|
491
|
+
const dir = makeTempDir();
|
|
492
|
+
try {
|
|
493
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
494
|
+
const runner = makeRunner(store, dir);
|
|
495
|
+
|
|
496
|
+
await runner.retire(compiledId1, {
|
|
497
|
+
targetStatus: "retired",
|
|
498
|
+
rationale: "Proposing for rejection test.",
|
|
499
|
+
decision: "reject",
|
|
500
|
+
rejectReason: "Not ready yet.",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const after = await store.get(compiledId1);
|
|
504
|
+
const logOps = (after.mutation_log || []).map((e) => e.op);
|
|
505
|
+
assert.ok(logOps.includes("propose"), "rejection path: proposal was logged");
|
|
506
|
+
assert.ok(logOps.includes("reject"), "rejection path: reject was logged");
|
|
507
|
+
assert.ok(!logOps.includes("retire"), "rejection path: no retire entry (status not changed)");
|
|
508
|
+
} finally {
|
|
509
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// AC2 — retired excluded from defaults, included with flag, provenance intact
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
describe("AC2 — retired excluded from defaults, included with flag, provenance intact", () => {
|
|
519
|
+
test("retired record excluded from listByType defaults", async () => {
|
|
520
|
+
const dir = makeTempDir();
|
|
521
|
+
try {
|
|
522
|
+
const { store, compiledId1, compiledId2 } = await buildFixture(dir);
|
|
523
|
+
const runner = makeRunner(store, dir);
|
|
524
|
+
|
|
525
|
+
// Retire compiledId1
|
|
526
|
+
await runner.retire(compiledId1, {
|
|
527
|
+
targetStatus: "retired",
|
|
528
|
+
rationale: "AC2: testing exclusion from listByType.",
|
|
529
|
+
decision: "apply",
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const compiled = await store.listByType("compiled");
|
|
533
|
+
assert.ok(!compiled.some((r) => r.id === compiledId1), "AC2: retired record excluded from listByType");
|
|
534
|
+
assert.ok(compiled.some((r) => r.id === compiledId2), "AC2: active record included in listByType");
|
|
535
|
+
} finally {
|
|
536
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("retired record included in listByType with includeRetired:true", async () => {
|
|
541
|
+
const dir = makeTempDir();
|
|
542
|
+
try {
|
|
543
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
544
|
+
const runner = makeRunner(store, dir);
|
|
545
|
+
|
|
546
|
+
await runner.retire(compiledId1, {
|
|
547
|
+
targetStatus: "retired",
|
|
548
|
+
rationale: "AC2: testing includeRetired flag.",
|
|
549
|
+
decision: "apply",
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const all = await store.listByType("compiled", { includeRetired: true });
|
|
553
|
+
assert.ok(all.some((r) => r.id === compiledId1), "AC2: retired record in listByType with includeRetired:true");
|
|
554
|
+
} finally {
|
|
555
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test("retired record excluded from listByCategory defaults", async () => {
|
|
560
|
+
const dir = makeTempDir();
|
|
561
|
+
try {
|
|
562
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
563
|
+
const runner = makeRunner(store, dir);
|
|
564
|
+
|
|
565
|
+
await runner.retire(compiledId1, {
|
|
566
|
+
targetStatus: "retired",
|
|
567
|
+
rationale: "AC2: testing listByCategory exclusion.",
|
|
568
|
+
decision: "apply",
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const byCategory = await store.listByCategory("ops.decisions");
|
|
572
|
+
assert.ok(!byCategory.some((r) => r.id === compiledId1), "AC2: retired excluded from listByCategory");
|
|
573
|
+
} finally {
|
|
574
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
test("retired record included in listByCategory with includeRetired:true", async () => {
|
|
579
|
+
const dir = makeTempDir();
|
|
580
|
+
try {
|
|
581
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
582
|
+
const runner = makeRunner(store, dir);
|
|
583
|
+
|
|
584
|
+
await runner.retire(compiledId1, {
|
|
585
|
+
targetStatus: "retired",
|
|
586
|
+
rationale: "AC2: includeRetired for category.",
|
|
587
|
+
decision: "apply",
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const all = await store.listByCategory("ops.decisions", { includeRetired: true });
|
|
591
|
+
assert.ok(all.some((r) => r.id === compiledId1), "AC2: retired in listByCategory with includeRetired:true");
|
|
592
|
+
} finally {
|
|
593
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("get() always returns retired record with full provenance", async () => {
|
|
598
|
+
const dir = makeTempDir();
|
|
599
|
+
try {
|
|
600
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
601
|
+
const runner = makeRunner(store, dir);
|
|
602
|
+
|
|
603
|
+
const originalBody = (await store.get(compiledId1)).body;
|
|
604
|
+
|
|
605
|
+
await runner.retire(compiledId1, {
|
|
606
|
+
targetStatus: "retired",
|
|
607
|
+
rationale: "AC2: get still works after retirement.",
|
|
608
|
+
decision: "apply",
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const retired = await store.get(compiledId1);
|
|
612
|
+
assert.ok(retired, "AC2: get() returns retired record");
|
|
613
|
+
assert.equal(retired.status, "retired", "AC2: status is retired");
|
|
614
|
+
assert.equal(retired.body, originalBody, "AC2: body is intact (not deleted)");
|
|
615
|
+
assert.ok(retired.provenance, "AC2: provenance is intact");
|
|
616
|
+
assert.ok(Array.isArray(retired.mutation_log), "AC2: mutation_log is present");
|
|
617
|
+
const retireEntry = retired.mutation_log.find((e) => e.op === "retire");
|
|
618
|
+
assert.ok(retireEntry, "AC2: retirement evidence in mutation_log");
|
|
619
|
+
assert.ok(retireEntry.evidence.rationale, "AC2: rationale in retirement evidence");
|
|
620
|
+
} finally {
|
|
621
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("AC2: defaultSimilarityDetector excludes retired candidates", async () => {
|
|
626
|
+
const dir = makeTempDir();
|
|
627
|
+
try {
|
|
628
|
+
const { store, compiledId1, conceptId } = await buildFixture(dir);
|
|
629
|
+
const runner = makeRunner(store, dir);
|
|
630
|
+
|
|
631
|
+
// Verify compiledId1 IS included before retirement
|
|
632
|
+
const allCompiled = await store.listByType("compiled");
|
|
633
|
+
const concept = await store.get(conceptId);
|
|
634
|
+
const beforeCluster = await defaultSimilarityDetector(concept, allCompiled, store);
|
|
635
|
+
assert.ok(beforeCluster.includes(compiledId1), "AC2: compiledId1 in cluster before retirement");
|
|
636
|
+
|
|
637
|
+
// Retire compiledId1
|
|
638
|
+
await runner.retire(compiledId1, {
|
|
639
|
+
targetStatus: "retired",
|
|
640
|
+
rationale: "AC2: testing detector exclusion.",
|
|
641
|
+
decision: "apply",
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// After retirement, listByType excludes it and so does the detector
|
|
645
|
+
const activeCompiled = await store.listByType("compiled");
|
|
646
|
+
assert.ok(!activeCompiled.some((r) => r.id === compiledId1), "AC2: retired not in listByType");
|
|
647
|
+
const afterCluster = await defaultSimilarityDetector(concept, activeCompiled, store);
|
|
648
|
+
assert.ok(!afterCluster.includes(compiledId1), "AC2: retired excluded from default detector");
|
|
649
|
+
} finally {
|
|
650
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
test("AC2: vector similarity detector excludes retired records", async () => {
|
|
655
|
+
const dir = makeTempDir();
|
|
656
|
+
try {
|
|
657
|
+
const { store, compiledId1, compiledId2, conceptId } = await buildFixture(dir);
|
|
658
|
+
const runner = makeRunner(store, dir);
|
|
659
|
+
|
|
660
|
+
// Injectable embed: all records are "similar" to each other
|
|
661
|
+
const injectEmbed = async (texts) => texts.map(() => [0.9, 0.1, 0.0]);
|
|
662
|
+
const detector = createVectorSimilarityDetector({ embed: injectEmbed, threshold: 0.5 });
|
|
663
|
+
|
|
664
|
+
// Before retirement: both compiled records should be in the cluster
|
|
665
|
+
const concept = await store.get(conceptId);
|
|
666
|
+
const allCompiled = await store.listByType("compiled");
|
|
667
|
+
const beforeCluster = await detector(concept, allCompiled, store);
|
|
668
|
+
assert.ok(beforeCluster.includes(compiledId1), "AC2: compiledId1 in vector cluster before retirement");
|
|
669
|
+
assert.ok(beforeCluster.includes(compiledId2), "AC2: compiledId2 in vector cluster before retirement");
|
|
670
|
+
|
|
671
|
+
// Retire compiledId1
|
|
672
|
+
await runner.retire(compiledId1, {
|
|
673
|
+
targetStatus: "retired",
|
|
674
|
+
rationale: "AC2: vector detector test.",
|
|
675
|
+
decision: "apply",
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// After retirement: detector should exclude compiledId1
|
|
679
|
+
const activeCompiled = await store.listByType("compiled");
|
|
680
|
+
const afterCluster = await detector(concept, activeCompiled, store);
|
|
681
|
+
assert.ok(!afterCluster.includes(compiledId1), "AC2: retired excluded from vector detector");
|
|
682
|
+
assert.ok(afterCluster.includes(compiledId2), "AC2: active record still in vector cluster");
|
|
683
|
+
} finally {
|
|
684
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("AC2: retired record reachable with includeRetired on all query surfaces", async () => {
|
|
689
|
+
const dir = makeTempDir();
|
|
690
|
+
try {
|
|
691
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
692
|
+
const runner = makeRunner(store, dir);
|
|
693
|
+
|
|
694
|
+
await runner.retire(compiledId1, {
|
|
695
|
+
targetStatus: "retired",
|
|
696
|
+
rationale: "AC2: full reachability test.",
|
|
697
|
+
decision: "apply",
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
// get() — always works
|
|
701
|
+
const viaGet = await store.get(compiledId1);
|
|
702
|
+
assert.ok(viaGet, "AC2: reachable via get()");
|
|
703
|
+
|
|
704
|
+
// listByType with flag
|
|
705
|
+
const viaType = await store.listByType("compiled", { includeRetired: true });
|
|
706
|
+
assert.ok(viaType.some((r) => r.id === compiledId1), "AC2: reachable via listByType with flag");
|
|
707
|
+
|
|
708
|
+
// listByCategory with flag
|
|
709
|
+
const viaCat = await store.listByCategory("ops.decisions", { includeRetired: true });
|
|
710
|
+
assert.ok(viaCat.some((r) => r.id === compiledId1), "AC2: reachable via listByCategory with flag");
|
|
711
|
+
|
|
712
|
+
// prefix listByCategory with flag
|
|
713
|
+
const viaPrefix = await store.listByCategory("ops", { prefix: true, includeRetired: true });
|
|
714
|
+
assert.ok(viaPrefix.some((r) => r.id === compiledId1), "AC2: reachable via prefix listByCategory with flag");
|
|
715
|
+
} finally {
|
|
716
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
717
|
+
}
|
|
718
|
+
});
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// AC3 — consolidation after retirement reflects pruned working set;
|
|
723
|
+
// retired decision still reachable from snapshot provenance history
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
|
|
726
|
+
describe("AC3 — consolidation after retirement prunes working set; retired record still reachable", () => {
|
|
727
|
+
test("AC3: consolidation cluster excludes retired compiled records", async () => {
|
|
728
|
+
const dir = makeTempDir();
|
|
729
|
+
try {
|
|
730
|
+
const store = makeStore(dir);
|
|
731
|
+
const runner = makeRunner(store, dir);
|
|
732
|
+
|
|
733
|
+
// Create 3 compiled records for the same topic
|
|
734
|
+
const c1 = await store.create({
|
|
735
|
+
type: "compiled",
|
|
736
|
+
title: "API Decision v1",
|
|
737
|
+
body: "Use REST for the API.",
|
|
738
|
+
category: "ops.api",
|
|
739
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
740
|
+
});
|
|
741
|
+
const c2 = await store.create({
|
|
742
|
+
type: "compiled",
|
|
743
|
+
title: "API Decision v2",
|
|
744
|
+
body: "Use REST with OpenAPI spec.",
|
|
745
|
+
category: "ops.api",
|
|
746
|
+
provenance: { agent: "fixture", source_ids: [c1] },
|
|
747
|
+
});
|
|
748
|
+
const c3 = await store.create({
|
|
749
|
+
type: "compiled",
|
|
750
|
+
title: "API Decision v3 (active)",
|
|
751
|
+
body: "Use REST with OpenAPI 3.1 and versioned endpoints.",
|
|
752
|
+
category: "ops.api",
|
|
753
|
+
provenance: { agent: "fixture", source_ids: [c1, c2] },
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Retire c1 (the oldest decision)
|
|
757
|
+
await runner.retire(c1, {
|
|
758
|
+
targetStatus: "retired",
|
|
759
|
+
rationale: "AC3: c1 superseded by v2 and v3.",
|
|
760
|
+
decision: "apply",
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// Run consolidation — should only see c2 and c3 in the cluster
|
|
764
|
+
const result = await runner.consolidate(
|
|
765
|
+
{ topic: "ops.api", category: "ops.api" },
|
|
766
|
+
{
|
|
767
|
+
proposedBody: "## API Design Decision\n\nUse REST with OpenAPI 3.1 and versioned endpoints.",
|
|
768
|
+
rationale: "AC3: consolidation after c1 retirement.",
|
|
769
|
+
decision: "apply",
|
|
770
|
+
}
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
assert.ok(result.newSnapshotId, "AC3: new snapshot created");
|
|
774
|
+
assert.ok(!result.cluster.includes(c1), "AC3: retired c1 excluded from consolidation cluster");
|
|
775
|
+
assert.ok(result.cluster.includes(c2), "AC3: active c2 in cluster");
|
|
776
|
+
assert.ok(result.cluster.includes(c3), "AC3: active c3 in cluster");
|
|
777
|
+
|
|
778
|
+
// The retired record is still reachable via get()
|
|
779
|
+
const retiredRecord = await store.get(c1);
|
|
780
|
+
assert.ok(retiredRecord, "AC3: retired record still reachable via get()");
|
|
781
|
+
assert.equal(retiredRecord.status, "retired", "AC3: retired status preserved");
|
|
782
|
+
} finally {
|
|
783
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test("AC3: snapshot provenance source_ids (from prior consolidation) still link to retired record", async () => {
|
|
788
|
+
const dir = makeTempDir();
|
|
789
|
+
try {
|
|
790
|
+
const store = makeStore(dir);
|
|
791
|
+
const runner = makeRunner(store, dir);
|
|
792
|
+
|
|
793
|
+
// Create 2 compiled records
|
|
794
|
+
const c1 = await store.create({
|
|
795
|
+
type: "compiled",
|
|
796
|
+
title: "Auth Decision v1",
|
|
797
|
+
body: "Use API keys.",
|
|
798
|
+
category: "ops.auth",
|
|
799
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
800
|
+
});
|
|
801
|
+
const c2 = await store.create({
|
|
802
|
+
type: "compiled",
|
|
803
|
+
title: "Auth Decision v2",
|
|
804
|
+
body: "Use OAuth2.",
|
|
805
|
+
category: "ops.auth",
|
|
806
|
+
provenance: { agent: "fixture", source_ids: [c1] },
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Run first consolidation while both records are active
|
|
810
|
+
const firstResult = await runner.consolidate(
|
|
811
|
+
{ topic: "ops.auth", category: "ops.auth" },
|
|
812
|
+
{
|
|
813
|
+
proposedBody: "Auth: API keys (v1).",
|
|
814
|
+
rationale: "AC3: first snapshot.",
|
|
815
|
+
decision: "apply",
|
|
816
|
+
}
|
|
817
|
+
);
|
|
818
|
+
|
|
819
|
+
const firstSnapshot = await store.get(firstResult.newSnapshotId);
|
|
820
|
+
// First snapshot's cluster included c1
|
|
821
|
+
assert.ok(firstResult.cluster.includes(c1), "AC3: c1 in first consolidation cluster");
|
|
822
|
+
|
|
823
|
+
// Now retire c1
|
|
824
|
+
await runner.retire(c1, {
|
|
825
|
+
targetStatus: "retired",
|
|
826
|
+
rationale: "AC3: c1 is now retired.",
|
|
827
|
+
decision: "apply",
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// Run second consolidation — c1 should NOT be in the new cluster
|
|
831
|
+
const secondResult = await runner.consolidate(
|
|
832
|
+
firstResult.newSnapshotId,
|
|
833
|
+
{
|
|
834
|
+
proposedBody: "Auth: OAuth2 (v2).",
|
|
835
|
+
rationale: "AC3: second snapshot after c1 retirement.",
|
|
836
|
+
decision: "apply",
|
|
837
|
+
}
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
assert.ok(!secondResult.cluster.includes(c1), "AC3: retired c1 excluded from second cluster");
|
|
841
|
+
assert.ok(secondResult.cluster.includes(c2), "AC3: active c2 in second cluster");
|
|
842
|
+
|
|
843
|
+
// First snapshot still has provenance linking to c1 (history preserved)
|
|
844
|
+
// The first snapshot's provenance.source_ids referenced c1 at creation time
|
|
845
|
+
const firstSnapshotAfter = await store.get(firstResult.newSnapshotId);
|
|
846
|
+
// c1 is still queryable
|
|
847
|
+
const c1Record = await store.get(c1);
|
|
848
|
+
assert.ok(c1Record, "AC3: retired c1 still reachable via get()");
|
|
849
|
+
assert.equal(c1Record.status, "retired", "AC3: c1 status is retired");
|
|
850
|
+
|
|
851
|
+
// The new (second) snapshot does not reference c1 in provenance
|
|
852
|
+
const secondSnapshot = await store.get(secondResult.newSnapshotId);
|
|
853
|
+
const secondSourceIds = secondSnapshot.provenance.source_ids || [];
|
|
854
|
+
assert.ok(!secondSourceIds.includes(c1), "AC3: second snapshot provenance does not include retired c1");
|
|
855
|
+
} finally {
|
|
856
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
test("AC3: retired record reachable from snapshot provenance history", async () => {
|
|
861
|
+
const dir = makeTempDir();
|
|
862
|
+
try {
|
|
863
|
+
const store = makeStore(dir);
|
|
864
|
+
const runner = makeRunner(store, dir);
|
|
865
|
+
|
|
866
|
+
// Single compiled record
|
|
867
|
+
const c1 = await store.create({
|
|
868
|
+
type: "compiled",
|
|
869
|
+
title: "Decision: single region",
|
|
870
|
+
body: "Deploy to us-east-1 only.",
|
|
871
|
+
category: "ops.deploy",
|
|
872
|
+
provenance: { agent: "fixture", source_ids: [] },
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
// Create a snapshot that references c1 directly
|
|
876
|
+
const snapshotId = await store.create({
|
|
877
|
+
type: "snapshot",
|
|
878
|
+
title: "Snapshot: ops.deploy",
|
|
879
|
+
body: "Current: single region (us-east-1).",
|
|
880
|
+
category: "ops.deploy",
|
|
881
|
+
tags: ["topic:ops.deploy"],
|
|
882
|
+
links: [{ target_id: c1, kind: "source" }],
|
|
883
|
+
provenance: { agent: "fixture", source_ids: [c1] },
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
// Retire c1
|
|
887
|
+
await runner.retire(c1, {
|
|
888
|
+
targetStatus: "retired",
|
|
889
|
+
rationale: "AC3: decision was implemented; now archiving.",
|
|
890
|
+
implementedByRef: "commit:abc123",
|
|
891
|
+
decision: "apply",
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// Snapshot still references c1 in provenance — no automatic mutation
|
|
895
|
+
const snapshotAfter = await store.get(snapshotId);
|
|
896
|
+
assert.ok(snapshotAfter, "AC3: snapshot still queryable");
|
|
897
|
+
assert.ok(
|
|
898
|
+
(snapshotAfter.provenance.source_ids || []).includes(c1),
|
|
899
|
+
"AC3: snapshot provenance still references retired c1"
|
|
900
|
+
);
|
|
901
|
+
|
|
902
|
+
// Retired c1 is reachable from snapshot provenance reference
|
|
903
|
+
const c1ViaProvenance = await store.get(c1);
|
|
904
|
+
assert.ok(c1ViaProvenance, "AC3: retired c1 reachable via provenance ref");
|
|
905
|
+
assert.equal(c1ViaProvenance.status, "retired", "AC3: c1 status is retired");
|
|
906
|
+
const retireEntry = (c1ViaProvenance.mutation_log || []).find((e) => e.op === "retire");
|
|
907
|
+
assert.ok(retireEntry, "AC3: retirement evidence preserved in mutation_log");
|
|
908
|
+
assert.equal(retireEntry.evidence.implementedByRef, "commit:abc123", "AC3: implementedByRef preserved");
|
|
909
|
+
} finally {
|
|
910
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
911
|
+
}
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
// Gate telemetry — retire emits events at each gate
|
|
917
|
+
// ---------------------------------------------------------------------------
|
|
918
|
+
|
|
919
|
+
describe("Gate telemetry — retire emits events at each gate", () => {
|
|
920
|
+
test("retire emits identify, propose-retirement, evidence, apply gate events", async () => {
|
|
921
|
+
const dir = makeTempDir();
|
|
922
|
+
try {
|
|
923
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
924
|
+
const runner = makeRunner(store, dir);
|
|
925
|
+
|
|
926
|
+
await runner.retire(compiledId1, {
|
|
927
|
+
targetStatus: "retired",
|
|
928
|
+
rationale: "Telemetry gate test.",
|
|
929
|
+
decision: "apply",
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
const events = readTelemetryEvents(dir);
|
|
933
|
+
assert.ok(events.length > 0, "telemetry events were emitted");
|
|
934
|
+
|
|
935
|
+
const gateNames = events
|
|
936
|
+
.filter((e) => e.tool?.name)
|
|
937
|
+
.map((e) => e.tool.name);
|
|
938
|
+
|
|
939
|
+
assert.ok(gateNames.some((n) => n.includes("identify-gate")), "identify-gate events emitted");
|
|
940
|
+
assert.ok(gateNames.some((n) => n.includes("propose-retirement-gate")), "propose-retirement-gate events emitted");
|
|
941
|
+
assert.ok(gateNames.some((n) => n.includes("evidence-gate")), "evidence-gate events emitted");
|
|
942
|
+
assert.ok(gateNames.some((n) => n.includes("apply-gate")), "apply-gate events emitted");
|
|
943
|
+
} finally {
|
|
944
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
945
|
+
}
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
test("gate events have entry (tool.invoke) and exit (tool.result) pairs", async () => {
|
|
949
|
+
const dir = makeTempDir();
|
|
950
|
+
try {
|
|
951
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
952
|
+
const runner = makeRunner(store, dir);
|
|
953
|
+
|
|
954
|
+
await runner.retire(compiledId1, {
|
|
955
|
+
targetStatus: "retired",
|
|
956
|
+
rationale: "Entry/exit event pair test.",
|
|
957
|
+
decision: "apply",
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
const events = readTelemetryEvents(dir);
|
|
961
|
+
const retireEvents = events.filter((e) => e.tool?.name?.includes("knowledge.retire"));
|
|
962
|
+
|
|
963
|
+
const gateIds = [
|
|
964
|
+
"identify-gate",
|
|
965
|
+
"propose-retirement-gate",
|
|
966
|
+
"evidence-gate",
|
|
967
|
+
"apply-gate",
|
|
968
|
+
];
|
|
969
|
+
for (const gateId of gateIds) {
|
|
970
|
+
const gateEvents = retireEvents.filter((e) => e.tool?.name?.includes(gateId));
|
|
971
|
+
const invokeEvent = gateEvents.find((e) => e.event_type === "tool.invoke");
|
|
972
|
+
const resultEvent = gateEvents.find((e) => e.event_type === "tool.result");
|
|
973
|
+
assert.ok(invokeEvent, `${gateId}: tool.invoke event present`);
|
|
974
|
+
assert.ok(resultEvent, `${gateId}: tool.result event present`);
|
|
975
|
+
}
|
|
976
|
+
} finally {
|
|
977
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test("rejection path emits apply-gate events with decision=reject", async () => {
|
|
982
|
+
const dir = makeTempDir();
|
|
983
|
+
try {
|
|
984
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
985
|
+
const runner = makeRunner(store, dir);
|
|
986
|
+
|
|
987
|
+
await runner.retire(compiledId1, {
|
|
988
|
+
targetStatus: "retired",
|
|
989
|
+
rationale: "Proposing for rejection telemetry test.",
|
|
990
|
+
decision: "reject",
|
|
991
|
+
rejectReason: "Testing rejection telemetry.",
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
const events = readTelemetryEvents(dir);
|
|
995
|
+
const applyGateEvents = events.filter((e) => e.tool?.name?.includes("apply-gate"));
|
|
996
|
+
assert.ok(applyGateEvents.length >= 2, "apply-gate has entry and exit events on reject path");
|
|
997
|
+
|
|
998
|
+
const resultEvent = applyGateEvents.find(
|
|
999
|
+
(e) => e.event_type === "tool.result" && e.tool?.output?.decision === "reject"
|
|
1000
|
+
);
|
|
1001
|
+
assert.ok(resultEvent, "apply-gate result event has decision=reject");
|
|
1002
|
+
} finally {
|
|
1003
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test("retire telemetry events have correct schema_version and agent block", async () => {
|
|
1008
|
+
const dir = makeTempDir();
|
|
1009
|
+
try {
|
|
1010
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
1011
|
+
const runner = new KnowledgeFlowRunner({
|
|
1012
|
+
store,
|
|
1013
|
+
workspace: dir,
|
|
1014
|
+
agent: "tel-retirement-agent",
|
|
1015
|
+
sessionId: "tel-retirement-session",
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
await runner.retire(compiledId1, {
|
|
1019
|
+
targetStatus: "retired",
|
|
1020
|
+
rationale: "Schema version test.",
|
|
1021
|
+
decision: "apply",
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const events = readTelemetryEvents(dir);
|
|
1025
|
+
const retireEvents = events.filter((e) => e.tool?.name?.includes("knowledge.retire"));
|
|
1026
|
+
|
|
1027
|
+
assert.ok(retireEvents.length > 0, "retire events were emitted");
|
|
1028
|
+
for (const ev of retireEvents) {
|
|
1029
|
+
assert.equal(ev.schema_version, "0.3.0", "event has schema_version 0.3.0");
|
|
1030
|
+
assert.equal(ev.agent.name, "tel-retirement-agent", "agent.name matches");
|
|
1031
|
+
assert.equal(ev.agent.runtime, "knowledge-kit", "agent.runtime is knowledge-kit");
|
|
1032
|
+
assert.equal(ev.session_id, "tel-retirement-session", "session_id matches");
|
|
1033
|
+
}
|
|
1034
|
+
} finally {
|
|
1035
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
// Store retire op direct tests
|
|
1042
|
+
// ---------------------------------------------------------------------------
|
|
1043
|
+
|
|
1044
|
+
describe("store.retire — direct op tests", () => {
|
|
1045
|
+
test("retire requires agent", async () => {
|
|
1046
|
+
const dir = makeTempDir();
|
|
1047
|
+
try {
|
|
1048
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
1049
|
+
await assert.rejects(
|
|
1050
|
+
() => store.retire(compiledId1, "retired", { rationale: "r" }),
|
|
1051
|
+
(err) => {
|
|
1052
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1053
|
+
assert.match(err.message, /agent/);
|
|
1054
|
+
return true;
|
|
1055
|
+
}
|
|
1056
|
+
);
|
|
1057
|
+
} finally {
|
|
1058
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1059
|
+
}
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
test("retire requires rationale", async () => {
|
|
1063
|
+
const dir = makeTempDir();
|
|
1064
|
+
try {
|
|
1065
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
1066
|
+
await assert.rejects(
|
|
1067
|
+
() => store.retire(compiledId1, "retired", { agent: "tester" }),
|
|
1068
|
+
(err) => {
|
|
1069
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1070
|
+
assert.match(err.message, /rationale/);
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
);
|
|
1074
|
+
} finally {
|
|
1075
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
test("retire requires implementedByRef when targetStatus=implemented", async () => {
|
|
1080
|
+
const dir = makeTempDir();
|
|
1081
|
+
try {
|
|
1082
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
1083
|
+
await assert.rejects(
|
|
1084
|
+
() => store.retire(compiledId1, "implemented", { agent: "tester", rationale: "r" }),
|
|
1085
|
+
(err) => {
|
|
1086
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1087
|
+
assert.match(err.message, /implementedByRef/);
|
|
1088
|
+
return true;
|
|
1089
|
+
}
|
|
1090
|
+
);
|
|
1091
|
+
} finally {
|
|
1092
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test("retire rejects nonexistent record", async () => {
|
|
1097
|
+
const dir = makeTempDir();
|
|
1098
|
+
try {
|
|
1099
|
+
const store = makeStore(dir);
|
|
1100
|
+
await assert.rejects(
|
|
1101
|
+
() => store.retire("nonexistent", "retired", { agent: "tester", rationale: "r" }),
|
|
1102
|
+
{ code: "NOT_FOUND" }
|
|
1103
|
+
);
|
|
1104
|
+
} finally {
|
|
1105
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
test("retire rejects invalid targetStatus", async () => {
|
|
1110
|
+
const dir = makeTempDir();
|
|
1111
|
+
try {
|
|
1112
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
1113
|
+
await assert.rejects(
|
|
1114
|
+
() => store.retire(compiledId1, "deleted", { agent: "tester", rationale: "r" }),
|
|
1115
|
+
(err) => {
|
|
1116
|
+
assert.equal(err.code, "MISSING_EVIDENCE");
|
|
1117
|
+
assert.match(err.message, /targetStatus/);
|
|
1118
|
+
return true;
|
|
1119
|
+
}
|
|
1120
|
+
);
|
|
1121
|
+
} finally {
|
|
1122
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
test("retire does not delete the record — body intact", async () => {
|
|
1127
|
+
const dir = makeTempDir();
|
|
1128
|
+
try {
|
|
1129
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
1130
|
+
const before = await store.get(compiledId1);
|
|
1131
|
+
|
|
1132
|
+
await store.retire(compiledId1, "retired", {
|
|
1133
|
+
agent: "tester",
|
|
1134
|
+
rationale: "Non-destructive retirement test.",
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const after = await store.get(compiledId1);
|
|
1138
|
+
assert.ok(after, "record still exists after retire");
|
|
1139
|
+
assert.equal(after.body, before.body, "body is intact after retire");
|
|
1140
|
+
assert.equal(after.category, before.category, "category is intact after retire");
|
|
1141
|
+
assert.deepEqual(after.provenance, before.provenance, "provenance is intact after retire");
|
|
1142
|
+
} finally {
|
|
1143
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1144
|
+
}
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
test("update op ignores status field — cannot circumvent retirement gate via update", async () => {
|
|
1148
|
+
const dir = makeTempDir();
|
|
1149
|
+
try {
|
|
1150
|
+
const { store, compiledId1 } = await buildFixture(dir);
|
|
1151
|
+
|
|
1152
|
+
// Retire the record first
|
|
1153
|
+
await store.retire(compiledId1, "retired", {
|
|
1154
|
+
agent: "tester",
|
|
1155
|
+
rationale: "Test: cannot reset via update.",
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// Try to use update to reset status — should either ignore or throw
|
|
1159
|
+
// The contract says update MUST ignore status; so status stays retired
|
|
1160
|
+
try {
|
|
1161
|
+
await store.update(compiledId1, { status: "active", title: "Updated title" }, { agent: "tester" });
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
// If update throws because status is not a valid mutable field, that's also acceptable
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Either way, status must still be retired
|
|
1167
|
+
const rec = await store.get(compiledId1);
|
|
1168
|
+
assert.equal(rec.status, "retired", "update cannot reset status — status stays retired");
|
|
1169
|
+
} finally {
|
|
1170
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
});
|