@kontourai/flow-agents 3.1.0 → 3.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 (52) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +97 -12
  5. package/build/src/cli/workflow-sidecar.d.ts +14 -4
  6. package/build/src/cli/workflow-sidecar.js +100 -10
  7. package/context/contracts/assignment-provider-contract.md +1 -1
  8. package/context/contracts/execution-contract.md +78 -0
  9. package/context/scripts/hooks/config-protection.js +11 -4
  10. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  11. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  12. package/evals/ci/run-baseline.sh +2 -0
  13. package/evals/integration/test_checkpoint_signing.sh +4 -3
  14. package/evals/integration/test_gate_lockdown.sh +36 -0
  15. package/evals/integration/test_model_routing_escalation.sh +145 -0
  16. package/evals/integration/test_publish_delivery.sh +14 -6
  17. package/evals/integration/test_stop_hook_release.sh +552 -0
  18. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  19. package/evals/run.sh +6 -0
  20. package/evals/static/test_model_routing_hints.sh +107 -0
  21. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  22. package/kits/builder/skills/deliver/SKILL.md +52 -11
  23. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  24. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  25. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  26. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  27. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  28. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  29. package/kits/builder/skills/review-work/SKILL.md +11 -0
  30. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  31. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  32. package/kits/knowledge/adapters/default-store/index.js +56 -15
  33. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  34. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  35. package/kits/knowledge/adapters/shared/codec.js +124 -0
  36. package/kits/knowledge/docs/store-contract.md +405 -3
  37. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  38. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  39. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  40. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  41. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  42. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  43. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  44. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  45. package/package.json +1 -1
  46. package/schemas/workflow-handoff.schema.json +6 -0
  47. package/scripts/ci/mint-attestation.js +33 -6
  48. package/scripts/ci/trust-reconcile.js +144 -26
  49. package/scripts/hooks/config-protection.js +11 -4
  50. package/scripts/hooks/stop-goal-fit.js +259 -4
  51. package/src/cli/assignment-provider.ts +110 -12
  52. package/src/cli/workflow-sidecar.ts +99 -10
@@ -32,6 +32,7 @@
32
32
  * @module adapters/flow-runner
33
33
  */
34
34
 
35
+ import * as fs from "node:fs";
35
36
  import * as path from "node:path";
36
37
  import { fileURLToPath } from "node:url";
37
38
  import { KnowledgeTelemetry } from "./telemetry.js";
@@ -41,6 +42,8 @@ import {
41
42
  isExactMatch,
42
43
  isPossibleDuplicate,
43
44
  } from "./entity-extractor.js";
45
+ // Record-carried effective expiry (issue #341, store-contract Addendum J).
46
+ import { effectiveExpiryMs } from "../shared/codec.js";
44
47
 
45
48
  // ---------------------------------------------------------------------------
46
49
  // Error helpers
@@ -456,6 +459,281 @@ export function defaultContradictionDetector(recordA, recordB) {
456
459
  return null;
457
460
  }
458
461
 
462
+ // ===========================================================================
463
+ // Inbound-reference integrity — doc→store citation extraction (#340)
464
+ //
465
+ // The hygiene audits (Addenda D–G) survey *records*; nothing resolves the
466
+ // references a human reads — the ids curated docs (NOW.md, strategy/*.md) cite
467
+ // *into* the store. When a store restructure severs those citations, every gate
468
+ // still reports PASS. This scan closes that: it extracts record citations from
469
+ // caller-configured doc globs and resolves each via the #339 identity path
470
+ // (exact id → slug alias → unambiguous ≥8-char prefix), failing closed on any
471
+ // unresolvable *definite* citation.
472
+ //
473
+ // Extraction precision — the commit-SHA problem
474
+ // ---------------------------------------------
475
+ // A bare 8-hex token is ambiguous: `12cc5573` is equally a record short-id and
476
+ // an abbreviated git commit SHA. Failing on every non-resolving 8-hex token
477
+ // would flag every SHA in prose (the gate cries wolf and gets disabled); never
478
+ // failing on them re-opens the exact rot this issue closes. We resolve the
479
+ // tension by recognizing two tiers (store-contract.md Addendum I):
480
+ //
481
+ // DEFINITE citation forms — a token is unmistakably a citation, so it is
482
+ // resolved regardless of whether it currently resolves, and a miss FAILS:
483
+ // • full UUID (8-4-4-4-12 hex) — the hyphenated shape never collides with a
484
+ // git SHA (40/abbrev hex, no hyphens) or prose, so a bare UUID qualifies;
485
+ // • a configured citation marker (default `rec:` / `record:`) wrapping a
486
+ // short-id or slug, e.g. `rec:12cc5573`, `record:decision.strategy/gtm`;
487
+ // • a wikilink `[[token]]` (the kit's own link syntax).
488
+ //
489
+ // CANDIDATE bare short-ids — a standalone ≥8-hex token with no citation form
490
+ // is included in the index ONLY when it already resolves to a record. A
491
+ // non-resolving bare hex is indistinguishable from a commit SHA, so it is
492
+ // IGNORED (never failed). This is the deliberate, documented miss: a *broken*
493
+ // bare-hex short-id (no marker/wikilink) is invisible — cite short-ids in a
494
+ // marker/wikilink form to get fail-closed protection. Full-UUID citations are
495
+ // protected with zero configuration.
496
+ //
497
+ // This is a precision-over-recall choice: zero false positives on commit
498
+ // hashes, at the cost of not catching bare-hex short-id rot until the doc
499
+ // adopts a citation form (an ops-repo house-style change — a non-goal here).
500
+ // ---------------------------------------------------------------------------
501
+
502
+ // Default marker prefixes that opt a following short-id/slug into DEFINITE
503
+ // citation status. Caller-overridable via `options.markers`.
504
+ export const DEFAULT_CITATION_MARKERS = ["rec:", "record:"];
505
+
506
+ // Canonical UUID (8-4-4-4-12 hex). Self-identifying — always a definite citation.
507
+ const UUID_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
508
+
509
+ // Wikilink target: [[token]] or [[token|label]] (label discarded).
510
+ const WIKILINK_CITE_RE = /\[\[([^\]|\n]+?)(?:\|[^\]\n]*)?\]\]/g;
511
+
512
+ // A standalone hex run 8..40 chars long — a bare short-id / SHA candidate.
513
+ const BARE_HEX_RE = /(?<![0-9a-z-])[0-9a-f]{8,40}(?![0-9a-z-])/gi;
514
+
515
+ // The token a marker may wrap: a slug/short-id character run (hex, slug chars).
516
+ // Kept permissive — #339 resolution semantics decide what actually resolves.
517
+ const MARKER_TOKEN_CHARS = "[A-Za-z0-9][A-Za-z0-9._/-]*";
518
+
519
+ /**
520
+ * Translate a caller glob (`NOW.md`, `strategy/*.md`, `docs/**\/*.md`) into an
521
+ * anchored RegExp over POSIX-style relative paths. `*` matches within a path
522
+ * segment; `**` spans segments; `?` matches one non-slash char. Zero-dep.
523
+ */
524
+ function globToRegExp(glob) {
525
+ let re = "";
526
+ for (let i = 0; i < glob.length; i += 1) {
527
+ const c = glob[i];
528
+ if (c === "*") {
529
+ if (glob[i + 1] === "*") {
530
+ // `**` — span zero or more path segments.
531
+ if (glob[i + 2] === "/") { re += "(?:.*/)?"; i += 2; }
532
+ else { re += ".*"; i += 1; }
533
+ } else {
534
+ re += "[^/]*";
535
+ }
536
+ } else if (c === "?") {
537
+ re += "[^/]";
538
+ } else if ("\\^$.|+()[]{}".includes(c)) {
539
+ re += `\\${c}`;
540
+ } else {
541
+ re += c;
542
+ }
543
+ }
544
+ return new RegExp(`^${re}$`);
545
+ }
546
+
547
+ /**
548
+ * Collect files under `rootDir` matching any of `globs`, returned as POSIX-style
549
+ * paths relative to `rootDir`, sorted for determinism. Walks the tree once,
550
+ * skipping VCS/dependency noise (`.git`, `node_modules`). A glob with no
551
+ * wildcard is honoured even if the walk would skip its directory.
552
+ */
553
+ function collectDocs(rootDir, globs) {
554
+ const matchers = globs.map(globToRegExp);
555
+ const out = new Set();
556
+
557
+ const SKIP = new Set([".git", "node_modules"]);
558
+ const walk = (absDir, relDir) => {
559
+ let entries;
560
+ try {
561
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
562
+ } catch {
563
+ return;
564
+ }
565
+ for (const entry of entries) {
566
+ const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
567
+ if (entry.isDirectory()) {
568
+ if (SKIP.has(entry.name)) continue;
569
+ walk(path.join(absDir, entry.name), rel);
570
+ } else if (entry.isFile()) {
571
+ if (matchers.some((m) => m.test(rel))) out.add(rel);
572
+ }
573
+ }
574
+ };
575
+ walk(rootDir, "");
576
+
577
+ // Also honour literal (wildcard-free) globs directly — covers a file the walk
578
+ // might not reach and keeps single-file configs O(1).
579
+ for (const glob of globs) {
580
+ if (!/[*?]/.test(glob)) {
581
+ const abs = path.join(rootDir, glob);
582
+ try {
583
+ if (fs.statSync(abs).isFile()) out.add(glob.split(path.sep).join("/"));
584
+ } catch { /* not present — reported as an empty scan for that glob */ }
585
+ }
586
+ }
587
+ return [...out].sort();
588
+ }
589
+
590
+ /**
591
+ * Extract DEFINITE citations (uuid | marker | wikilink) plus CANDIDATE bare
592
+ * short-ids from one line. Returns citation descriptors with 1-based line/column
593
+ * so failures can name the exact location. Overlapping spans are claimed by the
594
+ * higher-priority form first (marker/wikilink/uuid before bare) so a token is
595
+ * never double-counted.
596
+ *
597
+ * @param {string} line
598
+ * @param {number} lineNo 1-based line number
599
+ * @param {string[]} markers
600
+ * @returns {Array<{ line:number, column:number, token:string, form:string }>}
601
+ */
602
+ function extractLineCitations(line, lineNo, markers) {
603
+ const found = [];
604
+ const claimed = []; // [start,end) offsets already consumed by a higher-priority form
605
+
606
+ const overlaps = (start, end) =>
607
+ claimed.some(([s, e]) => start < e && end > s);
608
+ const claim = (start, end, token, form) => {
609
+ if (overlaps(start, end)) return;
610
+ claimed.push([start, end]);
611
+ found.push({ line: lineNo, column: start + 1, token: token.trim(), form });
612
+ };
613
+
614
+ // 1. Wikilinks (highest priority — explicit kit link syntax).
615
+ for (const m of line.matchAll(WIKILINK_CITE_RE)) {
616
+ const token = m[1];
617
+ if (token && token.trim()) claim(m.index, m.index + m[0].length, token, "wikilink");
618
+ }
619
+
620
+ // 2. Markers (rec: / record: …) wrapping a slug/short-id.
621
+ for (const marker of markers) {
622
+ const markerRe = new RegExp(
623
+ `${marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(${MARKER_TOKEN_CHARS})`,
624
+ "g"
625
+ );
626
+ for (const m of line.matchAll(markerRe)) {
627
+ const token = m[1];
628
+ const tokenStart = m.index + m[0].length - token.length;
629
+ claim(tokenStart, m.index + m[0].length, token, "marker");
630
+ }
631
+ }
632
+
633
+ // 3. Full UUIDs (self-identifying; also captures a bare UUID not in a form).
634
+ for (const m of line.matchAll(UUID_RE)) {
635
+ claim(m.index, m.index + m[0].length, m[0], "uuid");
636
+ }
637
+
638
+ // 4. Bare hex short-id CANDIDATES — resolution decides inclusion later.
639
+ for (const m of line.matchAll(BARE_HEX_RE)) {
640
+ claim(m.index, m.index + m[0].length, m[0], "bare");
641
+ }
642
+
643
+ return found;
644
+ }
645
+
646
+ /**
647
+ * Resolve a citation token against the store via the #339 identity path.
648
+ * `store.get(token)` accepts an exact id, slug alias, or unambiguous ≥8-char
649
+ * prefix; returns `null` when it resolves to nothing and throws `AMBIGUOUS_ID`
650
+ * when a prefix collides. Both non-resolution modes are reported (never thrown
651
+ * out of the scan) so one bad token cannot abort the whole check.
652
+ *
653
+ * @returns {Promise<{ resolved:boolean, recordId:string|null, reason?:string, matches?:string[] }>}
654
+ */
655
+ async function resolveCitation(store, token) {
656
+ let record;
657
+ try {
658
+ record = await store.get(token);
659
+ } catch (err) {
660
+ if (err && err.code === "AMBIGUOUS_ID") {
661
+ return { resolved: false, recordId: null, reason: "ambiguous", matches: err.matches };
662
+ }
663
+ throw err;
664
+ }
665
+ if (record && record.id) return { resolved: true, recordId: record.id };
666
+ return { resolved: false, recordId: null, reason: "not-found" };
667
+ }
668
+
669
+ // ---------------------------------------------------------------------------
670
+ // Incremental consolidation helpers (knowledge.consolidate append-mode — #343)
671
+ //
672
+ // Store-contract Addendum L. The whole-body consolidate contract makes the
673
+ // caller author the ENTIRE updated snapshot body just to add one decision —
674
+ // heavy enough that real sessions skip it and the living decision snapshot
675
+ // stops living (ops field report record 0e439c57). Append-mode lets the caller
676
+ // supply only the new compiled contribution (the "appended entry"); the runner
677
+ // regenerates the snapshot body from the snapshot's contributing records
678
+ // (Addendum A.3 — provenance.source_ids / kind:"source" links) plus the new
679
+ // entry. Because the body is regenerated from the CURRENT live snapshot's
680
+ // records every time, two sequential consolidations from different sessions
681
+ // never lose an entry (R4): each session re-reads the live snapshot's source
682
+ // set and appends to it, rather than overwriting a stale whole body.
683
+ //
684
+ // The whole-body path (options.proposedBody) is unchanged and remains valid.
685
+ // ---------------------------------------------------------------------------
686
+
687
+ /**
688
+ * Normalize the append-mode input from consolidate options. Returns
689
+ * `{ recordId }` when append-mode is requested, or `null` for whole-body mode.
690
+ * Accepts either `appendEntryRecordId: "<id>"` or `appendEntry: { recordId }`.
691
+ *
692
+ * @param {object} [options]
693
+ * @returns {{ recordId: string } | null}
694
+ */
695
+ export function normalizeConsolidateAppend(options = {}) {
696
+ if (typeof options.appendEntryRecordId === "string" && options.appendEntryRecordId.trim()) {
697
+ return { recordId: options.appendEntryRecordId.trim() };
698
+ }
699
+ const ae = options.appendEntry;
700
+ if (ae && typeof ae === "object" && typeof ae.recordId === "string" && ae.recordId.trim()) {
701
+ return { recordId: ae.recordId.trim() };
702
+ }
703
+ return null;
704
+ }
705
+
706
+ /**
707
+ * Default entry renderer for the decision-log snapshot shape: one section per
708
+ * contributing compiled record — a level-2 heading (the record title) followed
709
+ * by the record body. Pluggable via `options.entryRenderer`.
710
+ *
711
+ * @param {object} record a compiled record ({ id, title, body, ... })
712
+ * @returns {string}
713
+ */
714
+ export function defaultConsolidateEntryRenderer(record) {
715
+ const title = (record.title || record.id || "").trim();
716
+ const body = String(record.body || "").trim();
717
+ return `## ${title}\n\n${body}`;
718
+ }
719
+
720
+ /**
721
+ * Regenerate a snapshot body from its contributing records (in order), each
722
+ * rendered as one entry and joined by a horizontal rule. An optional `header`
723
+ * is prepended. This IS the append-mode body: prior entries (from the prior
724
+ * snapshot's source records) followed by the newly appended entry.
725
+ *
726
+ * @param {object[]} records
727
+ * @param {{ header?: string, entryRenderer?: (r: object) => string }} [options]
728
+ * @returns {string}
729
+ */
730
+ export function regenerateSnapshotBodyFromRecords(records, options = {}) {
731
+ const render = options.entryRenderer || defaultConsolidateEntryRenderer;
732
+ const joined = records.map((r) => render(r)).join("\n\n---\n\n");
733
+ const header = options.header && String(options.header).trim();
734
+ return header ? `${header}\n\n${joined}` : joined;
735
+ }
736
+
459
737
  // ---------------------------------------------------------------------------
460
738
  // KnowledgeFlowRunner
461
739
  // ---------------------------------------------------------------------------
@@ -1046,7 +1324,21 @@ export class KnowledgeFlowRunner {
1046
1324
  * - object topicSelector: { topic } — snapshot located by topic tag.
1047
1325
  * If no snapshot exists for the topic yet, a new one will be created on apply.
1048
1326
  * @param {object} [options]
1049
- * - proposedBody: string the proposed snapshot body (required)
1327
+ * Body inputsupply EXACTLY ONE of these two (mutually exclusive):
1328
+ * - proposedBody: string — whole-body mode (unchanged): the full
1329
+ * replacement snapshot body.
1330
+ * - appendEntryRecordId: string — append mode (#343 — Addendum L): the id of
1331
+ * the new compiled record to append. The body
1332
+ * is regenerated from the current snapshot's
1333
+ * contributing records (provenance.source_ids
1334
+ * / kind:"source" links) plus this new entry,
1335
+ * so the caller does NOT author the full body.
1336
+ * `appendEntry: { recordId }` is also accepted.
1337
+ * - header: string — (append mode) optional body header prepended
1338
+ * before the rendered entries.
1339
+ * - entryRenderer: fn — (append mode) pluggable `(record) => string`
1340
+ * to render one entry; defaults to a decision-
1341
+ * log section (`## <title>\n\n<body>`).
1050
1342
  * - rationale: string — reason for the consolidation (required for apply)
1051
1343
  * - decision: "apply"|"reject" — gate decision (default "apply")
1052
1344
  * - rejectReason: string — reason for rejection (required when decision="reject")
@@ -1128,6 +1420,88 @@ export class KnowledgeFlowRunner {
1128
1420
  );
1129
1421
  }
1130
1422
 
1423
+ // ── Mode: whole-body vs. incremental append (#343 — Addendum L) ─────────
1424
+ // Whole-body mode (unchanged): caller supplies options.proposedBody, the
1425
+ // full replacement snapshot body. Append mode: caller supplies the new
1426
+ // compiled contribution (appendEntryRecordId / appendEntry.recordId) and the
1427
+ // runner regenerates the body from the CURRENT snapshot's contributing
1428
+ // records plus the new entry. The two are mutually exclusive.
1429
+ const append = normalizeConsolidateAppend(options);
1430
+ const isIncremental = append !== null;
1431
+
1432
+ if (isIncremental && typeof options.proposedBody === "string" && options.proposedBody.trim()) {
1433
+ throw missingEvidenceError(
1434
+ "consolidate: options.proposedBody and append-mode (appendEntryRecordId/appendEntry) " +
1435
+ "are mutually exclusive — supply one or the other, not both"
1436
+ );
1437
+ }
1438
+
1439
+ // Computed in append mode; drive cluster + body below.
1440
+ let incrementalCluster = null;
1441
+ let incrementalBody = null;
1442
+
1443
+ if (isIncremental) {
1444
+ // The appended entry MUST be an existing compiled record — it is the new
1445
+ // contribution whose provenance the regenerated snapshot links (R3).
1446
+ const newRecord = await this._store.get(append.recordId);
1447
+ if (!newRecord) {
1448
+ throw missingEvidenceError(
1449
+ `consolidate: appended entry record not found: ${append.recordId}`
1450
+ );
1451
+ }
1452
+ if (newRecord.type !== "compiled") {
1453
+ throw missingEvidenceError(
1454
+ `consolidate: appended entry record ${append.recordId} is type="${newRecord.type}", ` +
1455
+ `expected "compiled"`
1456
+ );
1457
+ }
1458
+
1459
+ // Prior contributing records come from the CURRENT snapshot's provenance
1460
+ // (Addendum A.3): source_ids first, falling back to kind:"source" links.
1461
+ // Resolving the live snapshot per-call is what makes sequential
1462
+ // cross-session appends lossless (R4).
1463
+ let priorSourceIds = [];
1464
+ if (existingSnapshot) {
1465
+ const provIds = Array.isArray(existingSnapshot.provenance?.source_ids)
1466
+ ? existingSnapshot.provenance.source_ids
1467
+ : [];
1468
+ if (provIds.length > 0) {
1469
+ priorSourceIds = provIds;
1470
+ } else {
1471
+ const { forward } = await this._store.getLinks(existingSnapshot.id);
1472
+ priorSourceIds = (forward || [])
1473
+ .filter((l) => l.kind === "source")
1474
+ .map((l) => l.target_id);
1475
+ }
1476
+ }
1477
+
1478
+ // New source set = prior sources ++ new entry, de-duplicated with order
1479
+ // preserved (idempotent if the same entry is appended twice).
1480
+ const orderedIds = [];
1481
+ const seen = new Set();
1482
+ for (const sid of [...priorSourceIds, append.recordId]) {
1483
+ if (!seen.has(sid)) {
1484
+ seen.add(sid);
1485
+ orderedIds.push(sid);
1486
+ }
1487
+ }
1488
+
1489
+ // Regenerate the body from the resolved records, skipping any prior
1490
+ // source id that no longer resolves (deleted upstream is impossible under
1491
+ // supersede-not-delete, but be defensive). The new entry always resolves.
1492
+ const entryRecords = [];
1493
+ for (const sid of orderedIds) {
1494
+ const rec = await this._store.get(sid);
1495
+ if (rec) entryRecords.push(rec);
1496
+ }
1497
+
1498
+ incrementalCluster = entryRecords.map((r) => r.id);
1499
+ incrementalBody = regenerateSnapshotBodyFromRecords(entryRecords, {
1500
+ header: options.header,
1501
+ entryRenderer: options.entryRenderer,
1502
+ });
1503
+ }
1504
+
1131
1505
  // ── Gate: related-event-gate ───────────────────────────────────────────
1132
1506
  // Run similarity detection to find compiled records related to the topic.
1133
1507
  // We use a concept-like proxy to run the similarity detector: a synthetic
@@ -1154,9 +1528,19 @@ export class KnowledgeFlowRunner {
1154
1528
  );
1155
1529
  events.push(relatedGateIn);
1156
1530
 
1157
- // Run the detector: pass all compiled records as candidates
1158
- const allCompiled = await this._store.listByType("compiled");
1159
- const cluster = await detector(snapshotProxy, allCompiled, this._store);
1531
+ // Determine the cluster of contributing compiled records. In append mode
1532
+ // (#343) the cluster is the regenerated source set (prior sources ++ the new
1533
+ // entry) the similarity detector is bypassed because the source set is
1534
+ // authoritative and grows by exactly the appended entry, which is precisely
1535
+ // what makes sequential appends lossless. In whole-body mode the detector
1536
+ // discovers the related compiled records as before.
1537
+ let cluster;
1538
+ if (isIncremental) {
1539
+ cluster = incrementalCluster;
1540
+ } else {
1541
+ const allCompiled = await this._store.listByType("compiled");
1542
+ cluster = await detector(snapshotProxy, allCompiled, this._store);
1543
+ }
1160
1544
 
1161
1545
  if (!Array.isArray(cluster) || cluster.length === 0) {
1162
1546
  throw missingEvidenceError(
@@ -1184,9 +1568,12 @@ export class KnowledgeFlowRunner {
1184
1568
  // When the snapshot does not exist yet (first consolidation for the topic),
1185
1569
  // we create a placeholder snapshot record to attach the proposal to.
1186
1570
 
1187
- if (!options.proposedBody || !options.proposedBody.trim()) {
1571
+ // The effective body applied to the snapshot: the regenerated body in
1572
+ // append mode, or the caller-supplied full body in whole-body mode.
1573
+ if (!isIncremental && (!options.proposedBody || !options.proposedBody.trim())) {
1188
1574
  throw missingEvidenceError("consolidate: options.proposedBody is required");
1189
1575
  }
1576
+ const effectiveBody = isIncremental ? incrementalBody : options.proposedBody;
1190
1577
 
1191
1578
  // Ensure a snapshot record exists to propose against
1192
1579
  if (!snapshotId) {
@@ -1229,7 +1616,7 @@ export class KnowledgeFlowRunner {
1229
1616
  // the store's propose method with the snapshot's id.
1230
1617
  await this._store.propose(snapshotId, proposerId, {
1231
1618
  agent,
1232
- proposal: options.proposedBody,
1619
+ proposal: effectiveBody,
1233
1620
  ...(options.note ? { note: options.note } : {}),
1234
1621
  });
1235
1622
 
@@ -1355,7 +1742,7 @@ export class KnowledgeFlowRunner {
1355
1742
  newSnapshotId = await this._store.create({
1356
1743
  type: "snapshot",
1357
1744
  title: `Snapshot: ${topic}`,
1358
- body: options.proposedBody,
1745
+ body: effectiveBody,
1359
1746
  category: existingSnapshot?.category || category || "general",
1360
1747
  tags: [topicTag],
1361
1748
  links: sourceLinks,
@@ -1756,6 +2143,449 @@ export class KnowledgeFlowRunner {
1756
2143
  }
1757
2144
 
1758
2145
 
2146
+ // -------------------------------------------------------------------------
2147
+ // knowledge.check-inbound-references flow (#340)
2148
+ // Steps: scan-gate → resolve-gate → done
2149
+ // Gate: resolve-gate — every DEFINITE doc→store citation resolves via the
2150
+ // #339 identity path. Any unresolvable definite citation flips ok to
2151
+ // false (fail closed) and is reported with doc path + line/column +
2152
+ // token. Bare hex candidates are included only when they resolve
2153
+ // (commit-SHA safety), never contributing a failure.
2154
+ //
2155
+ // READ-ONLY: reads doc files + the store's query surface only; mutates no
2156
+ // record and appends no mutation-log entry (same invariant as the Addenda D–G
2157
+ // audits). OPT-IN: zero configured globs is a no-op pass.
2158
+ // -------------------------------------------------------------------------
2159
+
2160
+ /**
2161
+ * Scan caller-configured doc globs for record citations and resolve each
2162
+ * against the store, failing closed on any unresolvable definite citation.
2163
+ *
2164
+ * Citation forms (see the module header + store-contract.md Addendum I):
2165
+ * - full UUID (bare or wrapped) → definite; miss FAILS
2166
+ * - `<marker>token` (default `rec:`/`record:`) → definite; miss FAILS
2167
+ * - `[[token]]` wikilink → definite; miss FAILS
2168
+ * - bare ≥8-hex short-id → candidate; included only if it
2169
+ * currently resolves (a non-resolving bare hex is treated as prose/commit
2170
+ * SHA and ignored — the documented miss).
2171
+ *
2172
+ * @param {object} [options]
2173
+ * - docGlobs: string[] — globs (relative to docsRoot) to scan; [] = no-op pass
2174
+ * - docsRoot: string — root the globs resolve against (default: workspace, then cwd)
2175
+ * - markers: string[] — citation marker prefixes (default DEFAULT_CITATION_MARKERS)
2176
+ * - agent: string — override agent name
2177
+ * @returns {Promise<{
2178
+ * ok: boolean,
2179
+ * scanned: string[],
2180
+ * citations: Array<{ doc:string, line:number, column:number, token:string, form:string, resolved:boolean, recordId:string|null, reason?:string }>,
2181
+ * unresolved: Array<{ doc:string, line:number, column:number, token:string, form:string, reason:string }>,
2182
+ * byDoc: Record<string, Array<{ token:string, form:string, resolved:boolean, recordId:string|null, line:number, column:number }>>,
2183
+ * byRecord: Record<string, Array<{ doc:string, line:number, column:number, token:string, form:string }>>,
2184
+ * telemetryEvents: object[]
2185
+ * }>}
2186
+ */
2187
+ async checkInboundReferences(options = {}) {
2188
+ const events = [];
2189
+ const docGlobs = Array.isArray(options.docGlobs) ? options.docGlobs : [];
2190
+ const docsRoot = path.resolve(options.docsRoot || options.workspace || process.cwd());
2191
+ const markers = Array.isArray(options.markers) && options.markers.length
2192
+ ? options.markers
2193
+ : DEFAULT_CITATION_MARKERS;
2194
+
2195
+ // ── Step: scan-gate ────────────────────────────────────────────────────
2196
+ const scanGateIn = this._telemetry.emitGate(
2197
+ "knowledge.check-inbound-references",
2198
+ "scan-gate",
2199
+ {
2200
+ flow: "knowledge.check-inbound-references",
2201
+ gate: "scan-gate",
2202
+ docs_root: docsRoot,
2203
+ doc_globs: docGlobs,
2204
+ markers,
2205
+ }
2206
+ );
2207
+ events.push(scanGateIn);
2208
+
2209
+ // OPT-IN no-op: no globs configured → nothing to check (consistent with the
2210
+ // Addenda D–G opt-in convention). Returns an empty, PASSING index — never an
2211
+ // empty success that masks an unconfigured gate for a definite citation.
2212
+ if (docGlobs.length === 0) {
2213
+ const scanGateOutEmpty = this._telemetry.emitGateResult(
2214
+ "knowledge.check-inbound-references",
2215
+ "scan-gate",
2216
+ { scanned: 0, opt_out: true }
2217
+ );
2218
+ events.push(scanGateOutEmpty);
2219
+ return {
2220
+ ok: true,
2221
+ scanned: [],
2222
+ citations: [],
2223
+ unresolved: [],
2224
+ byDoc: {},
2225
+ byRecord: {},
2226
+ telemetryEvents: events,
2227
+ };
2228
+ }
2229
+
2230
+ const scanned = collectDocs(docsRoot, docGlobs);
2231
+
2232
+ // Extract every candidate citation from every scanned doc.
2233
+ const rawCitations = [];
2234
+ for (const doc of scanned) {
2235
+ const text = fs.readFileSync(path.join(docsRoot, doc), "utf8");
2236
+ const lines = text.split("\n");
2237
+ for (let i = 0; i < lines.length; i += 1) {
2238
+ for (const c of extractLineCitations(lines[i], i + 1, markers)) {
2239
+ rawCitations.push({ doc, ...c });
2240
+ }
2241
+ }
2242
+ }
2243
+
2244
+ const scanGateOut = this._telemetry.emitGateResult(
2245
+ "knowledge.check-inbound-references",
2246
+ "scan-gate",
2247
+ { scanned: scanned.length, candidates: rawCitations.length }
2248
+ );
2249
+ events.push(scanGateOut);
2250
+
2251
+ // ── Step: resolve-gate ─────────────────────────────────────────────────
2252
+ const resolveGateIn = this._telemetry.emitGate(
2253
+ "knowledge.check-inbound-references",
2254
+ "resolve-gate",
2255
+ {
2256
+ flow: "knowledge.check-inbound-references",
2257
+ gate: "resolve-gate",
2258
+ candidates: rawCitations.length,
2259
+ }
2260
+ );
2261
+ events.push(resolveGateIn);
2262
+
2263
+ const citations = [];
2264
+ const unresolved = [];
2265
+ const byDoc = {};
2266
+ const byRecord = {};
2267
+
2268
+ for (const c of rawCitations) {
2269
+ const { resolved, recordId, reason, matches } = await resolveCitation(this._store, c.token);
2270
+
2271
+ // A bare candidate that does NOT resolve is prose / a commit SHA — drop it
2272
+ // entirely (never indexed, never failed). Every other case is recorded.
2273
+ if (c.form === "bare" && !resolved) continue;
2274
+
2275
+ const entry = {
2276
+ doc: c.doc,
2277
+ line: c.line,
2278
+ column: c.column,
2279
+ token: c.token,
2280
+ form: c.form,
2281
+ resolved,
2282
+ recordId,
2283
+ ...(reason ? { reason } : {}),
2284
+ ...(matches ? { matches } : {}),
2285
+ };
2286
+ citations.push(entry);
2287
+
2288
+ (byDoc[c.doc] ||= []).push({
2289
+ token: c.token,
2290
+ form: c.form,
2291
+ resolved,
2292
+ recordId,
2293
+ line: c.line,
2294
+ column: c.column,
2295
+ });
2296
+
2297
+ if (resolved) {
2298
+ (byRecord[recordId] ||= []).push({
2299
+ doc: c.doc,
2300
+ line: c.line,
2301
+ column: c.column,
2302
+ token: c.token,
2303
+ form: c.form,
2304
+ });
2305
+ } else {
2306
+ // Definite citation (uuid/marker/wikilink) that does not resolve → FAIL.
2307
+ unresolved.push({
2308
+ doc: c.doc,
2309
+ line: c.line,
2310
+ column: c.column,
2311
+ token: c.token,
2312
+ form: c.form,
2313
+ reason: reason || "not-found",
2314
+ ...(matches ? { matches } : {}),
2315
+ });
2316
+ }
2317
+ }
2318
+
2319
+ // Ensure every scanned doc appears in byDoc even when it cites nothing, so a
2320
+ // downstream consumer can distinguish "scanned, no citations" from "not scanned".
2321
+ for (const doc of scanned) byDoc[doc] ||= [];
2322
+
2323
+ const ok = unresolved.length === 0;
2324
+
2325
+ const resolveGateOut = this._telemetry.emitGateResult(
2326
+ "knowledge.check-inbound-references",
2327
+ "resolve-gate",
2328
+ {
2329
+ ok,
2330
+ citations: citations.length,
2331
+ resolved: citations.length - unresolved.length,
2332
+ unresolved: unresolved.length,
2333
+ }
2334
+ );
2335
+ events.push(resolveGateOut);
2336
+
2337
+ return { ok, scanned, citations, unresolved, byDoc, byRecord, telemetryEvents: events };
2338
+ }
2339
+
2340
+
2341
+ // -------------------------------------------------------------------------
2342
+ // knowledge.flag-superseded-citers flow (supersede/retire propagation — #342)
2343
+ // Steps: collect-gate → flag-gate → done
2344
+ // Gate: flag-gate — every flag carries the superseding context that produced
2345
+ // it (the superseding record id(s) from the supersede op, and/or the
2346
+ // retire supersededByRef). A flag can NEVER be emitted without this
2347
+ // evidence: when a record shows no supersession evidence, zero flags
2348
+ // are emitted (fail closed — the "no false flags" guarantee).
2349
+ //
2350
+ // READ-ONLY: reads the store's query surface (get / getLinks) and — when doc
2351
+ // globs are configured — the #340 inbound-reference citation index. Mutates no
2352
+ // record and appends no mutation-log entry (same invariant as the Addenda D–G
2353
+ // audits and the #340 check). The existing gated `supersede` (Addendum A.5) and
2354
+ // `retire` (Addendum B.4) ops are untouched: this forks no mutation path.
2355
+ //
2356
+ // Motivation (field report, ops record 0e439c57): a decision record became
2357
+ // unresolvable after a store restructure and NOTHING flagged the four docs
2358
+ // citing it — they kept presenting dead authority for weeks. Supersession
2359
+ // preserves the record (supersede-not-delete) and makes store-internal citers
2360
+ // discoverable via reverse links, but doc citers stayed invisible. This
2361
+ // operation closes that loop: on a superseded/retired record it enumerates
2362
+ // first-degree inbound citers — store records (reverse-link index, §5) AND docs
2363
+ // (the #340 citation index) — and emits one supersession-aware flag per citer.
2364
+ // -------------------------------------------------------------------------
2365
+
2366
+ /**
2367
+ * Enumerate first-degree inbound citers of a superseded / retired record and
2368
+ * emit one flag per citer, each carrying the superseding context. Read-only.
2369
+ *
2370
+ * Supersession evidence (the flag-gate requires at least one):
2371
+ * - `supersede` op (Addendum A.5): a reverse link of kind `"supersedes"` from
2372
+ * the superseding record, mirrored by a `"superseded-by"` mutation-log entry
2373
+ * (`new_id`). Both are consulted; the union is `supersededByIds`.
2374
+ * - `retire` op with `supersededByRef` (Addendum B.4): a `"retire"` mutation-log
2375
+ * entry whose `evidence.supersededByRef` names the superseding artifact.
2376
+ *
2377
+ * Citer enumeration (first-degree only — no citers-of-citers):
2378
+ * - store records: `getLinks(id).reverse`, EXCLUDING the `"supersedes"` link and
2379
+ * any link from a superseding record itself (the new authority is not a stale
2380
+ * citer). Every other inbound link is a citer, tagged with its `linkKind`.
2381
+ * - docs: `checkInboundReferences({ docGlobs, ... }).byRecord[id]` when doc globs
2382
+ * are configured (opt-in; zero globs = store citers only). The superseded
2383
+ * record is preserved (supersede-not-delete), so its citations still resolve.
2384
+ *
2385
+ * Flag-gating: flags are emitted ONLY when supersession evidence exists. A
2386
+ * record with no such evidence yields `superseded:false` and an empty flag list
2387
+ * (no false flags); a superseded record with no citers likewise yields an empty
2388
+ * flag list. The enumerated citer counts (`storeCiters` / `docCiters`) are
2389
+ * always reported so a caller can see the inbound edges independent of gating.
2390
+ *
2391
+ * @param {string} recordId Id (or #339 prefix/slug handle) of the record.
2392
+ * @param {object} [options]
2393
+ * - docGlobs: string[] — globs to scan for doc citers (opt-in; [] = store citers only)
2394
+ * - docsRoot: string — root the globs resolve against (default: workspace, then cwd)
2395
+ * - markers: string[] — citation marker prefixes (default DEFAULT_CITATION_MARKERS)
2396
+ * - agent: string — override agent name
2397
+ * @returns {Promise<{
2398
+ * recordId: string,
2399
+ * superseded: boolean,
2400
+ * supersededByIds: string[],
2401
+ * supersededByRef: string|null,
2402
+ * retiredStatus: string|null,
2403
+ * storeCiters: number,
2404
+ * docCiters: number,
2405
+ * flags: Array<{
2406
+ * citerRef: string, citerKind: "record"|"doc", citedId: string,
2407
+ * supersededByIds: string[], supersededByRef: string|null,
2408
+ * linkKind?: string,
2409
+ * doc?: string, line?: number, column?: number, token?: string, form?: string
2410
+ * }>,
2411
+ * telemetryEvents: object[]
2412
+ * }>}
2413
+ */
2414
+ async flagSupersededCiters(recordId, options = {}) {
2415
+ const events = [];
2416
+ const agent = options.agent || this._agent;
2417
+
2418
+ if (!recordId || typeof recordId !== "string") {
2419
+ throw missingEvidenceError(
2420
+ "flag-superseded-citers: recordId must be a non-empty string"
2421
+ );
2422
+ }
2423
+
2424
+ const record = await this._store.get(recordId);
2425
+ if (!record) {
2426
+ throw missingEvidenceError(`flag-superseded-citers: record not found: ${recordId}`);
2427
+ }
2428
+ const citedId = record.id;
2429
+ const docGlobs = Array.isArray(options.docGlobs) ? options.docGlobs : [];
2430
+
2431
+ // ── Step: collect-gate ─────────────────────────────────────────────────
2432
+ const collectGateIn = this._telemetry.emitGate(
2433
+ "knowledge.flag-superseded-citers",
2434
+ "collect-gate",
2435
+ {
2436
+ flow: "knowledge.flag-superseded-citers",
2437
+ gate: "collect-gate",
2438
+ record_id: citedId,
2439
+ doc_globs: docGlobs,
2440
+ }
2441
+ );
2442
+ events.push(collectGateIn);
2443
+
2444
+ // Superseding context — the evidence every flag must carry.
2445
+ const links = await this._store.getLinks(citedId);
2446
+ const reverse = Array.isArray(links.reverse) ? links.reverse : [];
2447
+
2448
+ const supersededByIds = [
2449
+ ...new Set(
2450
+ reverse
2451
+ .filter((l) => l.kind === "supersedes")
2452
+ .map((l) => l.source_id)
2453
+ .filter(Boolean)
2454
+ ),
2455
+ ];
2456
+ // The mutation log independently records the superseding record(s); fold in
2457
+ // any not already surfaced by the reverse index (belt-and-suspenders).
2458
+ for (const e of record.mutation_log || []) {
2459
+ if (e.op === "superseded-by" && e.new_id && !supersededByIds.includes(e.new_id)) {
2460
+ supersededByIds.push(e.new_id);
2461
+ }
2462
+ }
2463
+
2464
+ // retire(…, { supersededByRef }) — Addendum B.4.
2465
+ let supersededByRef = null;
2466
+ for (const e of record.mutation_log || []) {
2467
+ if (e.op === "retire" && e.evidence && e.evidence.supersededByRef) {
2468
+ supersededByRef = e.evidence.supersededByRef;
2469
+ }
2470
+ }
2471
+ const retiredStatus = (record.status || "active") === "retired" ? "retired" : null;
2472
+
2473
+ const superseded = supersededByIds.length > 0 || Boolean(supersededByRef);
2474
+
2475
+ // Store-record citers: inbound links minus the supersession relationship
2476
+ // itself and any link originating from a superseding record (the replacement
2477
+ // authority is not a silent dead citer).
2478
+ const storeCiterLinks = reverse.filter(
2479
+ (l) => l.kind !== "supersedes" && !supersededByIds.includes(l.source_id)
2480
+ );
2481
+
2482
+ // Doc citers: the #340 citation index, keyed by the record's full id. Opt-in.
2483
+ let docCiterEntries = [];
2484
+ if (docGlobs.length > 0) {
2485
+ const inbound = await this.checkInboundReferences({
2486
+ docGlobs,
2487
+ docsRoot: options.docsRoot,
2488
+ markers: options.markers,
2489
+ agent,
2490
+ });
2491
+ // Fold the inbound-check gate telemetry into this flow's event trail.
2492
+ if (Array.isArray(inbound.telemetryEvents)) {
2493
+ for (const ev of inbound.telemetryEvents) events.push(ev);
2494
+ }
2495
+ docCiterEntries = inbound.byRecord[citedId] || [];
2496
+ }
2497
+
2498
+ const collectGateOut = this._telemetry.emitGateResult(
2499
+ "knowledge.flag-superseded-citers",
2500
+ "collect-gate",
2501
+ {
2502
+ record_id: citedId,
2503
+ superseded,
2504
+ superseded_by_count: supersededByIds.length,
2505
+ superseded_by_ref: Boolean(supersededByRef),
2506
+ store_citers: storeCiterLinks.length,
2507
+ doc_citers: docCiterEntries.length,
2508
+ }
2509
+ );
2510
+ events.push(collectGateOut);
2511
+
2512
+ // ── Step: flag-gate ────────────────────────────────────────────────────
2513
+ const flagGateIn = this._telemetry.emitGate(
2514
+ "knowledge.flag-superseded-citers",
2515
+ "flag-gate",
2516
+ {
2517
+ flow: "knowledge.flag-superseded-citers",
2518
+ gate: "flag-gate",
2519
+ record_id: citedId,
2520
+ superseded,
2521
+ candidate_citers: storeCiterLinks.length + docCiterEntries.length,
2522
+ }
2523
+ );
2524
+ events.push(flagGateIn);
2525
+
2526
+ const flags = [];
2527
+ if (superseded) {
2528
+ const context = { supersededByIds, supersededByRef };
2529
+ for (const l of storeCiterLinks) {
2530
+ flags.push({
2531
+ citerRef: l.source_id,
2532
+ citerKind: "record",
2533
+ linkKind: l.kind,
2534
+ citedId,
2535
+ ...context,
2536
+ });
2537
+ }
2538
+ for (const d of docCiterEntries) {
2539
+ flags.push({
2540
+ citerRef: `${d.doc}:${d.line}:${d.column}`,
2541
+ citerKind: "doc",
2542
+ doc: d.doc,
2543
+ line: d.line,
2544
+ column: d.column,
2545
+ token: d.token,
2546
+ form: d.form,
2547
+ citedId,
2548
+ ...context,
2549
+ });
2550
+ }
2551
+ // Evidence guarantee (defensive): the flag-gate refuses any flag that does
2552
+ // not carry superseding context. `superseded` already implies context, so
2553
+ // this can only fire on a future regression — fail closed if it ever does.
2554
+ for (const f of flags) {
2555
+ if (f.supersededByIds.length === 0 && !f.supersededByRef) {
2556
+ throw missingEvidenceError(
2557
+ "flag-superseded-citers: refusing to emit a flag without superseding context"
2558
+ );
2559
+ }
2560
+ }
2561
+ }
2562
+
2563
+ const flagGateOut = this._telemetry.emitGateResult(
2564
+ "knowledge.flag-superseded-citers",
2565
+ "flag-gate",
2566
+ {
2567
+ record_id: citedId,
2568
+ superseded,
2569
+ flagged: flags.length,
2570
+ agent,
2571
+ }
2572
+ );
2573
+ events.push(flagGateOut);
2574
+
2575
+ return {
2576
+ recordId: citedId,
2577
+ superseded,
2578
+ supersededByIds,
2579
+ supersededByRef,
2580
+ retiredStatus,
2581
+ storeCiters: storeCiterLinks.length,
2582
+ docCiters: docCiterEntries.length,
2583
+ flags,
2584
+ telemetryEvents: events,
2585
+ };
2586
+ }
2587
+
2588
+
1759
2589
  // -------------------------------------------------------------------------
1760
2590
  // knowledge.audit-freshness flow (hygiene #1 — #106)
1761
2591
  // Steps: collect → measure → flag-gate → done
@@ -1800,7 +2630,9 @@ export class KnowledgeFlowRunner {
1800
2630
  * flags: Array<{
1801
2631
  * recordId: string, title: string, type: string, category: string,
1802
2632
  * status: string, lastMutationAt: string, ageDays: number,
1803
- * thresholdDays: number, matchedThresholdKey: string,
2633
+ * thresholdDays: number|null, matchedThresholdKey: string,
2634
+ * reason: "threshold"|"expiry", // which path fired (#341)
2635
+ * expiresAt?: string, // present on expiry flags: the cited expiry
1804
2636
  * proposedAction: "archive"|"refresh"
1805
2637
  * }>,
1806
2638
  * telemetryEvents: object[]
@@ -1872,9 +2704,18 @@ export class KnowledgeFlowRunner {
1872
2704
  let audited = 0;
1873
2705
 
1874
2706
  for (const record of records) {
2707
+ // Record-carried expiry (#341): a record's own `expires_at` / `ttl_seconds`
2708
+ // is the strongest freshness signal and fires WITHOUT any caller-supplied
2709
+ // threshold (Addendum J). Effective expiry: explicit expires_at, else
2710
+ // created_at + ttl_seconds; null when the record carries no freshness field.
2711
+ const expiryMs = effectiveExpiryMs(record);
2712
+ const hasExpiry = expiryMs !== null;
1875
2713
  const resolved = resolveThreshold(record.category, thresholds, defaultThresholdDays);
1876
- if (resolved === null) {
1877
- // No threshold configured for this category (and no default) opt out.
2714
+
2715
+ // A record is opt-out only when it has NEITHER a record-carried expiry NOR
2716
+ // a resolvable per-category threshold. This preserves the pre-#341 skip
2717
+ // semantics for records without freshness fields.
2718
+ if (!hasExpiry && resolved === null) {
1878
2719
  skipped += 1;
1879
2720
  continue;
1880
2721
  }
@@ -1886,13 +2727,35 @@ export class KnowledgeFlowRunner {
1886
2727
  const ageDays = Number.isNaN(lastMs)
1887
2728
  ? 0
1888
2729
  : Math.floor((nowMs - lastMs) / 86_400_000);
2730
+ const proposedAction = resolveAction(record.category, actions, defaultAction);
2731
+
2732
+ // Expiry path takes precedence: when a record is past its OWN expiry, flag
2733
+ // it citing that expiry (`expiresAt`) as the threshold that fired. Boundary
2734
+ // is inclusive (nowMs >= expiry), matching isRecordStale (Addendum J).
2735
+ if (hasExpiry && nowMs >= expiryMs) {
2736
+ flags.push({
2737
+ recordId: record.id,
2738
+ title: record.title,
2739
+ type: record.type,
2740
+ category: record.category,
2741
+ status: record.status || "active",
2742
+ lastMutationAt,
2743
+ ageDays,
2744
+ // Expiry flags cite `expiresAt` rather than a day-count threshold, so
2745
+ // `thresholdDays` is null and `matchedThresholdKey` names the field.
2746
+ thresholdDays: null,
2747
+ matchedThresholdKey: "expires_at",
2748
+ expiresAt: new Date(expiryMs).toISOString(),
2749
+ reason: "expiry",
2750
+ proposedAction,
2751
+ });
2752
+ continue;
2753
+ }
1889
2754
 
1890
- // Flag only when STRICTLY past the threshold. Evidence (last-mutation +
1891
- // the threshold key/value that fired) is carried on every flag — the
1892
- // flag-gate requires both, so a flag can never be emitted without them.
1893
- if (ageDays > resolved.thresholdDays) {
1894
- const proposedAction =
1895
- resolveAction(record.category, actions, defaultAction);
2755
+ // Threshold path (unchanged, #106): flag only when STRICTLY past the
2756
+ // resolved per-category threshold. Evidence (last-mutation + the threshold
2757
+ // key/value that fired) is carried on every flag.
2758
+ if (resolved !== null && ageDays > resolved.thresholdDays) {
1896
2759
  flags.push({
1897
2760
  recordId: record.id,
1898
2761
  title: record.title,
@@ -1903,6 +2766,7 @@ export class KnowledgeFlowRunner {
1903
2766
  ageDays,
1904
2767
  thresholdDays: resolved.thresholdDays,
1905
2768
  matchedThresholdKey: resolved.matchedKey,
2769
+ reason: "threshold",
1906
2770
  proposedAction,
1907
2771
  });
1908
2772
  }
@@ -3350,6 +4214,38 @@ export async function auditFreshness(
3350
4214
  return runner.auditFreshness(auditOpts);
3351
4215
  }
3352
4216
 
4217
+ /**
4218
+ * Module-level inbound-reference integrity check: creates an ephemeral runner
4219
+ * using the provided store. Read-only — mutates nothing. Fails closed on any
4220
+ * unresolvable definite doc→store citation. (#340)
4221
+ *
4222
+ * @param {object} options (merged into checkInboundReferences options + runner options)
4223
+ */
4224
+ export async function checkInboundReferences(
4225
+ { store, workspace, agent, sessionId, ...checkOpts } = {}
4226
+ ) {
4227
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
4228
+ return runner.checkInboundReferences(checkOpts);
4229
+ }
4230
+
4231
+ /**
4232
+ * Module-level supersede/retire citer-propagation flag: creates an ephemeral
4233
+ * runner using the provided store. Read-only — mutates nothing. Enumerates
4234
+ * first-degree inbound citers (store records + docs) of a superseded/retired
4235
+ * record and emits one flag per citer, each carrying the superseding context.
4236
+ * Fails closed: no flag without that evidence. (#342)
4237
+ *
4238
+ * @param {string} recordId
4239
+ * @param {object} options (merged into flagSupersededCiters options + runner options)
4240
+ */
4241
+ export async function flagSupersededCiters(
4242
+ recordId,
4243
+ { store, workspace, agent, sessionId, ...flagOpts } = {}
4244
+ ) {
4245
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
4246
+ return runner.flagSupersededCiters(recordId, flagOpts);
4247
+ }
4248
+
3353
4249
  /**
3354
4250
  * Module-level category-canonicalization audit: creates an ephemeral runner
3355
4251
  * using the provided store. Read-only — mutates nothing. (#106 hygiene #4)