@kontourai/flow-agents 3.1.0 → 3.3.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 (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. package/src/tools/validate-source-tree.ts +3 -2
@@ -63,6 +63,10 @@ import {
63
63
  loadAliasIndex,
64
64
  saveAliasIndex,
65
65
  registerAliases,
66
+ // Record-carried freshness + derived staleness (issue #341, Addendum J).
67
+ freshnessPatch,
68
+ decodeFreshnessFields,
69
+ isRecordStale,
66
70
  } from "../shared/codec.js";
67
71
 
68
72
  // ---------------------------------------------------------------------------
@@ -302,7 +306,8 @@ export class ObsidianKnowledgeStore {
302
306
  // Reconstruct the record body from the rendered markdown section.
303
307
  // `meta` no longer contains `body` — the rendered text is the source of truth.
304
308
  const body = this._parseBodyFromRendered(meta.type, renderedText);
305
- return { ...meta, body };
309
+ // Coerce record-carried freshness fields to canonical types (#341).
310
+ return decodeFreshnessFields({ ...meta, body });
306
311
  }
307
312
 
308
313
  /**
@@ -544,6 +549,9 @@ export class ObsidianKnowledgeStore {
544
549
  registerAliases(aliasIndex, id, aliases);
545
550
  }
546
551
 
552
+ // Validate optional freshness fields (#341) before any write.
553
+ const fresh = freshnessPatch(input);
554
+
547
555
  const explicitLinks = input.links || [];
548
556
  const wikilinks = extractWikilinks(input.body || "");
549
557
  const links = mergeLinks(explicitLinks, wikilinks);
@@ -558,6 +566,7 @@ export class ObsidianKnowledgeStore {
558
566
  status: "active",
559
567
  created_at: now,
560
568
  updated_at: now,
569
+ ...fresh,
561
570
  provenance: {
562
571
  agent: input.provenance.agent,
563
572
  ...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
@@ -592,7 +601,7 @@ export class ObsidianKnowledgeStore {
592
601
  const record = this._readRecord(id, pathIndex);
593
602
  if (!record) throw notFoundError(id);
594
603
 
595
- const mutableKeys = ["title", "body", "category", "tags", "links", "aliases"];
604
+ const mutableKeys = ["title", "body", "category", "tags", "links", "aliases", "expires_at", "ttl_seconds"];
596
605
  const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
597
606
  if (supplied.length === 0)
598
607
  throw missingEvidenceError("update: at least one mutable field must be supplied");
@@ -600,6 +609,9 @@ export class ObsidianKnowledgeStore {
600
609
  if (fields.category !== undefined && !validateCategory(fields.category))
601
610
  throw missingEvidenceError(`update: invalid category: ${fields.category}`);
602
611
 
612
+ // Validate/normalize supplied freshness fields (#341); null/"" clears a field.
613
+ const fresh = freshnessPatch(fields);
614
+
603
615
  const now = this._now();
604
616
 
605
617
  // Slug aliases are append-only: union supplied aliases with existing ones so
@@ -630,6 +642,7 @@ export class ObsidianKnowledgeStore {
630
642
  ...(fields.category !== undefined ? { category: fields.category } : {}),
631
643
  ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
632
644
  ...(mergedAliases.length ? { aliases: mergedAliases } : {}),
645
+ ...fresh,
633
646
  links: newLinks,
634
647
  updated_at: now,
635
648
  mutation_log: [
@@ -1043,18 +1056,19 @@ export class ObsidianKnowledgeStore {
1043
1056
  async listByCategory(category, options = {}) {
1044
1057
  const records = this._allRecords();
1045
1058
  const includeRetired = options.includeRetired === true;
1059
+ // Derived staleness filter (#341): keep only records past their own effective
1060
+ // expiry at `now` (injectable for tests). Absent → no staleness filtering.
1061
+ const staleOnly = options.stale === true;
1062
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
1063
+ const keep = (r) =>
1064
+ (includeRetired || (r.status || "active") !== "retired") &&
1065
+ (!staleOnly || isRecordStale(r, nowMs));
1046
1066
  if (options.prefix) {
1047
1067
  return records.filter(
1048
- (r) =>
1049
- (r.category === category || r.category.startsWith(`${category}.`)) &&
1050
- (includeRetired || (r.status || "active") !== "retired")
1068
+ (r) => (r.category === category || r.category.startsWith(`${category}.`)) && keep(r)
1051
1069
  );
1052
1070
  }
1053
- return records.filter(
1054
- (r) =>
1055
- r.category === category &&
1056
- (includeRetired || (r.status || "active") !== "retired")
1057
- );
1071
+ return records.filter((r) => r.category === category && keep(r));
1058
1072
  }
1059
1073
 
1060
1074
  // -------------------------------------------------------------------------
@@ -1063,10 +1077,14 @@ export class ObsidianKnowledgeStore {
1063
1077
 
1064
1078
  async listByType(type, options = {}) {
1065
1079
  const includeRetired = options.includeRetired === true;
1080
+ // Derived staleness filter (#341) — see listByCategory.
1081
+ const staleOnly = options.stale === true;
1082
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
1066
1083
  return this._allRecords().filter(
1067
1084
  (r) =>
1068
1085
  r.type === type &&
1069
- (includeRetired || (r.status || "active") !== "retired")
1086
+ (includeRetired || (r.status || "active") !== "retired") &&
1087
+ (!staleOnly || isRecordStale(r, nowMs))
1070
1088
  );
1071
1089
  }
1072
1090
  }
@@ -464,3 +464,127 @@ export function registerAliases(index, id, slugs) {
464
464
  index.by_slug[slug] = id;
465
465
  }
466
466
  }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Freshness + derived status (issue #341, store-contract Addendum J)
470
+ //
471
+ // Optional, backward-compatible record-carried expiry aligned to Kontour's own
472
+ // Hachure trust vocabulary (expiresAt / ttlSeconds; stale / superseded). Every
473
+ // helper below is a no-op / null / false for a record that carries NO freshness
474
+ // fields, so records written before this slice behave exactly as they did.
475
+ //
476
+ // `stale` and `superseded` are DERIVED states — computed here, never written
477
+ // back onto the record's stored `status` (Addendum B). "No silent rot" means a
478
+ // record goes VISIBLY stale under query, not that the store rewrites it behind
479
+ // the operator's back.
480
+ // ---------------------------------------------------------------------------
481
+
482
+ // The optional freshness fields on the record envelope (§1.1 / Addendum J.1).
483
+ export const FRESHNESS_KEYS = ["expires_at", "ttl_seconds"];
484
+
485
+ /** Validate an `expires_at` value: a non-empty ISO-8601 string. */
486
+ export function validateExpiresAt(value) {
487
+ if (typeof value !== "string" || !value.trim())
488
+ throw missingEvidenceError(
489
+ `expires_at must be a non-empty ISO-8601 string; got: ${JSON.stringify(value)}`
490
+ );
491
+ if (Number.isNaN(Date.parse(value)))
492
+ throw missingEvidenceError(
493
+ `expires_at must be a valid ISO-8601 timestamp; got: ${JSON.stringify(value)}`
494
+ );
495
+ return value;
496
+ }
497
+
498
+ /** Validate a `ttl_seconds` value: a positive, finite number of seconds. */
499
+ export function validateTtlSeconds(value) {
500
+ const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
501
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0)
502
+ throw missingEvidenceError(
503
+ `ttl_seconds must be a positive number of seconds; got: ${JSON.stringify(value)}`
504
+ );
505
+ return n;
506
+ }
507
+
508
+ /**
509
+ * Extract + validate the freshness fields from a create/update input, returning
510
+ * a patch object containing only the supplied keys (normalized). A key set
511
+ * explicitly to `null` or `""` is returned as `undefined` — the signal to CLEAR
512
+ * it (the YAML serializer drops undefined). Absent keys are omitted, so an input
513
+ * with no freshness fields yields `{}` (full backward compatibility). Throws
514
+ * MISSING_EVIDENCE — the contract's rejection channel — on a malformed value,
515
+ * at the mutation boundary, so bad freshness never reaches disk.
516
+ */
517
+ export function freshnessPatch(input) {
518
+ const patch = {};
519
+ if (!input || typeof input !== "object") return patch;
520
+ if (Object.prototype.hasOwnProperty.call(input, "expires_at")) {
521
+ patch.expires_at =
522
+ input.expires_at === null || input.expires_at === ""
523
+ ? undefined
524
+ : validateExpiresAt(input.expires_at);
525
+ }
526
+ if (Object.prototype.hasOwnProperty.call(input, "ttl_seconds")) {
527
+ patch.ttl_seconds =
528
+ input.ttl_seconds === null || input.ttl_seconds === ""
529
+ ? undefined
530
+ : validateTtlSeconds(input.ttl_seconds);
531
+ }
532
+ return patch;
533
+ }
534
+
535
+ /**
536
+ * Coerce freshness fields to their canonical runtime types after a frontmatter
537
+ * read: the zero-dep YAML codec returns every scalar as a string, so a numeric
538
+ * `ttl_seconds` round-trips as text without this. Mutates and returns the
539
+ * record; a no-op when no freshness field is present.
540
+ */
541
+ export function decodeFreshnessFields(record) {
542
+ if (record && record.ttl_seconds !== undefined && record.ttl_seconds !== null) {
543
+ const n = Number(record.ttl_seconds);
544
+ if (Number.isFinite(n)) record.ttl_seconds = n;
545
+ }
546
+ return record;
547
+ }
548
+
549
+ /**
550
+ * The record's EFFECTIVE expiry as epoch-ms, or `null` when it carries no
551
+ * freshness fields. Precedence: an explicit `expires_at` wins over a derived
552
+ * `created_at + ttl_seconds`. Returns null (never throws) on an unparseable
553
+ * field, so malformed freshness can neither make a record spuriously
554
+ * "fresh forever" nor crash a read-only audit.
555
+ */
556
+ export function effectiveExpiryMs(record) {
557
+ if (!record || typeof record !== "object") return null;
558
+ if (record.expires_at) {
559
+ const ms = Date.parse(record.expires_at);
560
+ if (!Number.isNaN(ms)) return ms;
561
+ }
562
+ if (record.ttl_seconds !== undefined && record.ttl_seconds !== null && record.created_at) {
563
+ const base = Date.parse(record.created_at);
564
+ const ttl = Number(record.ttl_seconds);
565
+ if (!Number.isNaN(base) && Number.isFinite(ttl)) return base + ttl * 1000;
566
+ }
567
+ return null;
568
+ }
569
+
570
+ /**
571
+ * Is the record STALE at instant `nowMs`? Derived, never stored. A record with
572
+ * no freshness fields is never stale via this path (backward compat). Boundary:
573
+ * the expiry instant itself counts as expired (`nowMs >= expiry`) — a record
574
+ * "expires_at T" is stale from T onward, inclusive.
575
+ */
576
+ export function isRecordStale(record, nowMs) {
577
+ const exp = effectiveExpiryMs(record);
578
+ if (exp === null) return false;
579
+ return nowMs >= exp;
580
+ }
581
+
582
+ /**
583
+ * Is the record SUPERSEDED? Derived from the Addendum A.5 supersede
584
+ * relationship, surfaced as the incoming `supersedes` edge in the graph
585
+ * (`getLinks(id).reverse`) — equivalently, the record carries a `superseded-by`
586
+ * mutation-log entry. Never stored as a `status`.
587
+ */
588
+ export function isSupersededByLinks(reverseLinks) {
589
+ return Array.isArray(reverseLinks) && reverseLinks.some((l) => l && l.kind === "supersedes");
590
+ }
@@ -31,6 +31,10 @@ The store holds three record types. Every record, regardless of type, shares a c
31
31
  | `provenance` | Provenance | yes | Immutable creation provenance (see §4). |
32
32
  | `created_at` | ISO-8601 string | yes | Creation timestamp in UTC. |
33
33
  | `updated_at` | ISO-8601 string | yes | Last mutation timestamp in UTC. |
34
+ | `expires_at` | ISO-8601 string | no | Optional record-carried expiry (Addendum J). When now is at or past it, the record derives to `stale`. Never silently mutates the record. |
35
+ | `ttl_seconds` | number | no | Optional relative expiry in seconds from `created_at` (Addendum J). `expires_at`, when present, takes precedence. |
36
+
37
+ Records **without** `expires_at` / `ttl_seconds` behave exactly as before this addendum — they are never derived `stale` via the expiry path.
34
38
 
35
39
  ### 1.2 `raw` Record
36
40
 
@@ -847,9 +851,17 @@ interface FreshnessFlag {
847
851
  ```
848
852
 
849
853
  `auditFreshness` returns `{ audited, skipped, flags, telemetryEvents }` where `audited` counts
850
- records that had a resolvable threshold, `skipped` counts opt-out categories, and `flags` lists the
851
- stale records. Gate telemetry is emitted at `collect-gate` and `flag-gate`
852
- (`knowledge.audit-freshness`).
854
+ records that had a resolvable threshold **or a record-carried expiry**, `skipped` counts opt-out
855
+ categories (no threshold *and* no expiry), and `flags` lists the stale records. Gate telemetry is
856
+ emitted at `collect-gate` and `flag-gate` (`knowledge.audit-freshness`).
857
+
858
+ **Extension (#341, Addendum J).** A record that carries its own `expires_at` / `ttl_seconds` is
859
+ flagged when now is at or past that expiry — **without** any caller-supplied threshold. Such an
860
+ expiry flag cites the expiry it fired on: it carries `reason: "expiry"`, `expiresAt` (the effective
861
+ expiry, ISO-8601), `matchedThresholdKey: "expires_at"`, and `thresholdDays: null` (an expiry cites a
862
+ timestamp, not a day-count). Threshold-driven flags (Addendum D above) carry `reason: "threshold"`
863
+ and are otherwise unchanged. The expiry path takes precedence over the threshold path for a record
864
+ that is past both.
853
865
 
854
866
  ## Addendum E — Category Canonicalization (Hygiene #4, #106)
855
867
 
@@ -1142,3 +1154,393 @@ The default adapter persists it at `<store_root>/alias-index.json`
1142
1154
 
1143
1155
  An independent adapter conforms to this addendum when the `identity: *` sections of the contract
1144
1156
  suite (`evals/contract-suite/suite.test.js`, §15–§17) pass against it.
1157
+
1158
+ ---
1159
+
1160
+ ## Addendum I — Inbound-Reference Integrity (doc→store citations, #340)
1161
+
1162
+ ### I.1 Motivation
1163
+
1164
+ The store's graph (§2, §5) and the hygiene audits (Addenda D–G) cover record→record links and
1165
+ survey **records**. Nothing resolves the references a human actually reads: the record ids that
1166
+ curated docs — `NOW.md`, `strategy/*.md`, a roadmap — cite **into** the store. A store restructure
1167
+ that re-files or retires a record silently severs every inbound doc citation, and every existing
1168
+ gate still reports PASS. Field evidence (design partner `kontourai/ops`): `verify-ops` stayed green
1169
+ for weeks while `NOW.md` and `strategy/vision.md` cited unresolvable short-ids (`12cc5573`,
1170
+ `f5df7b4c`). This addendum makes that rot **fail closed**.
1171
+
1172
+ The check is a **read-only** flow (`KnowledgeFlowRunner.checkInboundReferences`) in the style of the
1173
+ Addenda D–G audits: it mutates no record, appends no mutation-log entry, and forks no mutation path.
1174
+ It resolves citations through the unchanged Addendum H identity path — it adds no new query surface.
1175
+
1176
+ ### I.2 Options
1177
+
1178
+ | Option | Type | Default | Meaning |
1179
+ |---|---|---|---|
1180
+ | `docGlobs` | string[] | `[]` | Globs (relative to `docsRoot`) whose matching docs are scanned. **Empty = opt-in no-op pass** (Addenda D–G convention). Supports `*` (within a segment), `**` (across segments), `?`. |
1181
+ | `docsRoot` | string | `workspace`, then `cwd` | Root the globs resolve against (the docs live outside the store). |
1182
+ | `markers` | string[] | `["rec:", "record:"]` | Citation-marker prefixes that opt a following short-id/slug into **definite** citation status (I.3). |
1183
+
1184
+ ### I.3 Citation Forms — the commit-SHA precision problem
1185
+
1186
+ A bare 8-hex token is inherently ambiguous: `12cc5573` is equally a record short-id and an
1187
+ abbreviated git commit SHA. Failing on every non-resolving 8-hex token would flag every SHA in prose
1188
+ (the gate cries wolf and gets muted); never failing on them re-opens the exact rot this closes. The
1189
+ check resolves the tension with **two tiers**:
1190
+
1191
+ **Definite citations** — unmistakably a record reference, so resolved regardless of whether they
1192
+ currently resolve, and an unresolved one **FAILS** (fail closed):
1193
+
1194
+ | Form | Example | Why it is SHA-safe |
1195
+ |---|---|---|
1196
+ | Full UUID (8-4-4-4-12 hex) | `aaaaaaaa-1111-4111-8111-111111111111` | The hyphenated shape never collides with a git SHA (40/abbrev hex, no hyphens) or prose. |
1197
+ | Marker + token | `rec:12cc5573`, `record:decision.strategy/gtm` | The marker is explicit intent; the token may be a short-id or slug. |
1198
+ | Wikilink | `[[decision.strategy/gtm]]` | The kit's own link syntax. |
1199
+
1200
+ **Candidate bare short-ids** — a standalone ≥8-hex token with **no** citation form is included in
1201
+ the index **only when it already resolves** to a record. A non-resolving bare hex is indistinguishable
1202
+ from a commit SHA, so it is **ignored** — never indexed, never failed.
1203
+
1204
+ > **Deliberate, documented miss.** A *broken* bare-hex short-id (no marker/wikilink) is invisible to
1205
+ > the check — by construction, since it does not resolve. This is a precision-over-recall choice:
1206
+ > **zero false positives on commit hashes**, at the cost of not catching bare-hex short-id rot until
1207
+ > the doc adopts a citation form. To get fail-closed protection for a short-id or slug, cite it in a
1208
+ > marker or wikilink form; full-UUID citations are protected with zero configuration. Migrating a
1209
+ > consuming repo's house style to a marker is an ops-repo change (a non-goal here).
1210
+
1211
+ Every extracted token is resolved via `get(token)` (Addendum H: exact id → slug alias → unambiguous
1212
+ ≥8-char prefix). `null` → unresolved; an `AMBIGUOUS_ID` throw is caught and reported as an unresolved
1213
+ citation with `reason: "ambiguous"` (fail closed — an ambiguous citation does not uniquely resolve).
1214
+
1215
+ ### I.4 Fail-Closed Semantics
1216
+
1217
+ The result's `ok` is `true` **iff** `unresolved` is empty — i.e. every definite citation resolved. A
1218
+ caller (e.g. ops `verify-ops`) treats `ok === false` as a gate failure. Each `unresolved` entry names
1219
+ `doc`, `line`, `column`, `token`, `form`, and `reason` so the break is actionable. An empty scan with
1220
+ configured globs that cite nothing is a pass; an empty scan from **no** globs is the explicit opt-in
1221
+ no-op — neither is an "empty success" that masks an unresolved definite citation.
1222
+
1223
+ ### I.5 Citation Index (result shape)
1224
+
1225
+ The result exposes the full citation index so downstream flows (supersede/retire propagation) can
1226
+ enumerate citers:
1227
+
1228
+ ```json
1229
+ {
1230
+ "ok": true,
1231
+ "scanned": ["NOW.md", "strategy/vision.md"],
1232
+ "citations": [
1233
+ { "doc": "NOW.md", "line": 3, "column": 17, "token": "aaaaaaaa-1111-4111-8111-111111111111",
1234
+ "form": "uuid", "resolved": true, "recordId": "aaaaaaaa-1111-4111-8111-111111111111" }
1235
+ ],
1236
+ "unresolved": [
1237
+ { "doc": "NOW.md", "line": 5, "column": 9, "token": "deadbeef-0000-4000-8000-000000000000",
1238
+ "form": "uuid", "reason": "not-found" }
1239
+ ],
1240
+ "byDoc": { "NOW.md": [ { "token": "…", "form": "uuid", "resolved": true, "recordId": "…", "line": 3, "column": 17 } ] },
1241
+ "byRecord": { "aaaaaaaa-1111-4111-8111-111111111111": [ { "doc": "NOW.md", "line": 3, "column": 17, "token": "…", "form": "uuid" } ] }
1242
+ }
1243
+ ```
1244
+
1245
+ - `citations` — every recorded citation (definite ones, plus bare candidates that resolved).
1246
+ - `byDoc` — doc → its cited record ids (every scanned doc appears, even if it cites nothing).
1247
+ - `byRecord` — record id → its citers (the doc→record edge; a natural typed edge under #317).
1248
+
1249
+ ### I.6 Conformance
1250
+
1251
+ An adapter conforms to this addendum when the inbound-reference suite
1252
+ (`evals/inbound-references/suite.test.js`) passes against it — AC1 (three forms extracted +
1253
+ resolved), AC2 (fail closed on a nonexistent id; no-op pass with no globs), AC3 (read-only), and the
1254
+ commit-SHA-safety case. The suite is parameterized by `KNOWLEDGE_ADAPTER`, so conformance is required
1255
+ of every adapter (AC4). Because the check reuses `get`, any Addendum-H-conforming adapter satisfies it
1256
+ without new storage.
1257
+
1258
+ ---
1259
+
1260
+ ## Addendum J — Record Freshness & Status Semantics (Hachure alignment, #341)
1261
+
1262
+ ### J.1 Motivation — no silent rot for our own knowledge
1263
+
1264
+ The store is strong at *filing* and, since Addendum D, at *auditing* staleness — but staleness lived
1265
+ entirely in the auditor's call: thresholds are supplied per invocation, so freshness intent lived in
1266
+ whoever remembered to pass the right options, not on the record. A record could not say "I go stale
1267
+ on 2026-09-01"; the only freshness mechanism for a long-lived store was a human remembering to run an
1268
+ audit with the right numbers (field evidence: the ops design partner's radar section carried a
1269
+ `prune monthly` *comment* as its sole freshness mechanism). Knowledge rots silently between audits.
1270
+
1271
+ Meanwhile Kontour already owns a trust vocabulary for exactly this — **Hachure**: `expiresAt` /
1272
+ `ttlSeconds`, statuses `unknown / proposed / verified / stale / superseded`, and the "no silent rot"
1273
+ principle. This addendum makes the knowledge store **eat that vocabulary**: records carry their own
1274
+ expiry and go *visibly* stale under the same semantics our trust bundles use. It **aligns** with
1275
+ Hachure's vocabulary and semantics — it does **not** import Hachure's schema or take a format
1276
+ dependency (a non-goal). Ephemeris integration and automatic re-verification / auto-retirement are
1277
+ also non-goals: this addendum **flags**; the operator routes a flag through `knowledge.retire` or a
1278
+ fresh capture/compile, exactly as Addendum D established.
1279
+
1280
+ ### J.2 Record-carried freshness fields
1281
+
1282
+ Two **optional** envelope fields (§1.1), round-tripped by every adapter through the shared codec:
1283
+
1284
+ | Field | Type | Meaning |
1285
+ |---|---|---|
1286
+ | `expires_at` | ISO-8601 string | Absolute instant the record expires. |
1287
+ | `ttl_seconds` | number (> 0) | Relative expiry: `created_at + ttl_seconds`. |
1288
+
1289
+ - Both are settable at `create` and via `update` (they are mutable fields; `update` records them in
1290
+ its mutation-log `fields` evidence like any other). Supplying either as `null` / `""` on `update`
1291
+ **clears** it.
1292
+ - A malformed value (non-ISO `expires_at`, non-positive `ttl_seconds`) is rejected at the mutation
1293
+ boundary with `error.code === "MISSING_EVIDENCE"` — bad freshness never reaches disk.
1294
+ - **Effective expiry** = `expires_at` when present, else `created_at + ttl_seconds`. An explicit
1295
+ `expires_at` therefore **takes precedence** over `ttl_seconds`.
1296
+ - **Backward compatibility (R5):** a record carrying neither field has `null` effective expiry and is
1297
+ never derived `stale` via the expiry path — it behaves exactly as before this addendum.
1298
+
1299
+ ### J.3 Derived states — `stale` and `superseded`
1300
+
1301
+ `stale` and `superseded` are **derived**, never stored. They are computed on read/query and **never**
1302
+ mutate the record's `status` (Addendum B) or any other field. "No silent rot" means a record becomes
1303
+ *visibly* stale under query — not that the store silently rewrites it.
1304
+
1305
+ - **`stale`** — the record's effective expiry exists and now is **at or past** it. The boundary is
1306
+ **inclusive**: a record `expires_at: T` is stale from `T` onward (`now >= T`). A record may also be
1307
+ surfaced as stale by `auditFreshness` (Addendum D + J.5). Derivation never changes the record.
1308
+ - **`superseded`** — the record is the target of an Addendum A.5 `supersede` relationship: it carries
1309
+ a `superseded-by` mutation-log entry, equivalently an incoming `supersedes` edge in the graph
1310
+ (`getLinks(id).reverse`). This is the cross-adapter query surface for supersession — it works
1311
+ identically whether the adapter keeps superseded records in place (default) or archives them
1312
+ (obsidian), because both maintain the graph. Supersession is a **derived state**, not a `status`
1313
+ value; `supersede` remains the only op that establishes it.
1314
+
1315
+ ### J.4 Hachure vocabulary alignment
1316
+
1317
+ The kit keeps **two orthogonal axes**, and aligns each to Hachure's vocabulary without forking a
1318
+ second lifecycle:
1319
+
1320
+ 1. **Lifecycle status** (stored; mutated only by `retire`, Addendum B): `active` / `implemented` /
1321
+ `retired`.
1322
+ 2. **Freshness/derivation state** (derived; never stored): `fresh` / `stale` / `superseded`.
1323
+
1324
+ | Hachure term | Kit representation | Axis | Derived from |
1325
+ |---|---|---|---|
1326
+ | `verified` | `active` and not past expiry ("fresh") | lifecycle + freshness | `status === "active"` **and** not stale |
1327
+ | `stale` | `stale` | freshness (derived) | now ≥ effective expiry (`expires_at`, or `created_at + ttl_seconds`), or an `auditFreshness` flag |
1328
+ | `superseded` | `superseded` | freshness (derived) | incoming `supersedes` edge / `superseded-by` log entry (Addendum A.5) |
1329
+ | `proposed` | open proposal | flow state | a pending `proposes` link (Addenda B.7 / D) — not a record status |
1330
+ | `unknown` | *(not modelled)* | — | the kit has no "unknown" state; every record is at least `active` |
1331
+
1332
+ `retired` (kit) has no Hachure freshness equivalent — it is a **terminal lifecycle decision**,
1333
+ orthogonal to freshness. Retired records are excluded from the default working set (Addendum B.3) and
1334
+ so from stale-filtered listings, but remain fully queryable via `includeRetired` and `get`.
1335
+
1336
+ ### J.5 Query surface
1337
+
1338
+ - **`listByType(type, { stale: true, now? })`** and **`listByCategory(category, { stale: true, now?, prefix?, includeRetired? })`** return only records whose effective expiry is at or past `now` (default `Date.now()`; `now` is injectable for deterministic tests). Absent `stale` → unchanged
1339
+ behaviour. The staleness filter composes with the existing status filter (retired still excluded by
1340
+ default).
1341
+ - **`auditFreshness`** (Addendum D.2/D.3) additionally flags a record past its **own** `expires_at`
1342
+ with **no** caller-supplied thresholds, each such flag citing the expiry as the threshold that
1343
+ fired (`reason: "expiry"`, `expiresAt`, `matchedThresholdKey: "expires_at"`, `thresholdDays: null`).
1344
+ Record-carried expiry thus **complements** Addendum D's caller-supplied thresholds rather than
1345
+ replacing them.
1346
+ - **`superseded`** is queried via the existing `getLinks(id).reverse` surface (J.3) — no new storage
1347
+ or list method; it works uniformly across adapters.
1348
+
1349
+ ### J.6 Conformance
1350
+
1351
+ An adapter conforms to this addendum when the freshness suite
1352
+ (`evals/freshness/suite.test.js`) passes against it: `expires_at` / `ttl_seconds` round-trip through
1353
+ `get`; expiry transitions (past → stale, future → not, inclusive boundary, no-freshness-fields →
1354
+ never stale) hold; `{ stale: true }` listing returns exactly the expired records; and a superseded
1355
+ record is queryable as superseded. The suite is parameterized by `KNOWLEDGE_ADAPTER`, so conformance
1356
+ is required of every bundled adapter. Backward compatibility (R5) is covered by the unmodified
1357
+ `contract-suite`, `audit-freshness`, and `retirement` suites continuing to pass.
1358
+
1359
+ ---
1360
+
1361
+ ## Addendum K — Supersede/Retire Citer Propagation (flag inbound citers, #342)
1362
+
1363
+ ### K.1 Motivation
1364
+
1365
+ The `supersede` op (Addendum A.5) and the `retire` op with `supersededByRef` (Addendum B.4) preserve
1366
+ the affected record (supersede-not-delete / retire-not-delete) and make **store-internal** citers
1367
+ discoverable via the reverse-link index (§5, `getLinks(id).reverse`). But nothing enumerated or
1368
+ flagged the **docs** citing a superseded record — so a superseded decision could leave every citer
1369
+ silently pointing at dead or outdated authority.
1370
+
1371
+ Field evidence (design partner `kontourai/ops`, knowledge record `0e439c57`): the 6/17 "sell the
1372
+ combination" decision became unresolvable after a store restructure and **four docs** (`NOW.md` ×2,
1373
+ `strategy/vision.md`, `strategy/2026-06-18-market-fork-decision.md`) cited it invisibly for weeks —
1374
+ the citers kept presenting a dead record as live authority. This addendum closes that loop: on a
1375
+ superseded/retired record it enumerates first-degree inbound citers from **both** indexes and emits a
1376
+ supersession-aware flag per citer.
1377
+
1378
+ This is a **read-only** enumeration in the hygiene-flow style (Addenda D–G): the existing gated
1379
+ `supersede` and `retire` ops are unchanged, and no new mutation path is forked. It does **not**
1380
+ auto-edit citing docs, does **not** cascade status changes, and does **not** follow citers-of-citers
1381
+ (first-degree only). Fixing a citation is the operator's or a downstream flow's gated action.
1382
+
1383
+ ### K.2 `flagSupersededCiters` Flow-Runner Operation
1384
+
1385
+ `KnowledgeFlowRunner.flagSupersededCiters(recordId, options)` (also the module-level
1386
+ `flagSupersededCiters(recordId, { store, ... })`):
1387
+
1388
+ **Options:**
1389
+
1390
+ | Option | Type | Default | Description |
1391
+ |---|---|---|---|
1392
+ | `docGlobs` | `string[]` | `[]` | Globs to scan for doc citers via the #340 citation index. Opt-in: `[]` = store citers only, no doc scan. |
1393
+ | `docsRoot` | `string` | workspace, then cwd | Root the globs resolve against. |
1394
+ | `markers` | `string[]` | `DEFAULT_CITATION_MARKERS` | Citation marker prefixes forwarded to the inbound-reference scan (Addendum I). |
1395
+ | `agent` | `string` | runner agent | Agent recorded on the flow telemetry. |
1396
+
1397
+ `recordId` accepts a full id or any Addendum-H handle (short-id prefix / slug alias); the returned
1398
+ `recordId` is the resolved full id. A record that does not exist throws (`MISSING_EVIDENCE`) — never a
1399
+ silent empty pass.
1400
+
1401
+ **Supersession evidence** (at least one is required for any flag to be emitted):
1402
+ - **supersede op** (A.5): a reverse link of kind `"supersedes"` from the superseding record, mirrored
1403
+ by a `"superseded-by"` mutation-log entry carrying `new_id`. Both sources are read; their union is
1404
+ `supersededByIds`.
1405
+ - **retire-with-supersededByRef** (B.4): a `"retire"` mutation-log entry whose
1406
+ `evidence.supersededByRef` names the superseding artifact; surfaced as `supersededByRef` (and
1407
+ `retiredStatus: "retired"`).
1408
+
1409
+ **Citer enumeration** (first-degree only):
1410
+ - **store records** — `getLinks(id).reverse`, EXCLUDING the `"supersedes"` link itself and any link
1411
+ originating from a superseding record (the replacement authority is not a silent dead citer). Every
1412
+ other inbound link is a citer, tagged with its `linkKind`.
1413
+ - **docs** — `checkInboundReferences({ docGlobs, ... }).byRecord[id]` (Addendum I). The superseded
1414
+ record is preserved, so its citations still resolve. Opt-in via `docGlobs`.
1415
+
1416
+ ### K.3 Flag Evidence Guarantee
1417
+
1418
+ Every flag carries the superseding context that produced it — the flag-gate refuses to emit a flag
1419
+ without it. Consequently a record with **no** supersession evidence yields `superseded: false` and an
1420
+ **empty** flag list (no false flags, fail closed); a superseded record with no citers likewise yields
1421
+ an empty flag list. The enumerated citer counts (`storeCiters` / `docCiters`) are reported regardless
1422
+ of gating, so a caller can see the inbound edges independent of supersession state.
1423
+
1424
+ ```ts
1425
+ interface SupersededCiterFlag {
1426
+ citerRef: string; // record id, or "doc:line:column"
1427
+ citerKind: "record" | "doc";
1428
+ citedId: string; // full id of the superseded / retired record
1429
+ supersededByIds: string[]; // superseding record id(s) from the supersede op
1430
+ supersededByRef: string | null; // the retire supersededByRef, when present
1431
+ // record citer:
1432
+ linkKind?: string; // the reverse link kind that made it a citer
1433
+ // doc citer (from the #340 citation index):
1434
+ doc?: string; line?: number; column?: number; token?: string; form?: string;
1435
+ }
1436
+ ```
1437
+
1438
+ `flagSupersededCiters` returns:
1439
+
1440
+ ```ts
1441
+ {
1442
+ recordId: string; // resolved full id
1443
+ superseded: boolean; // whether supersession evidence exists
1444
+ supersededByIds: string[];
1445
+ supersededByRef: string | null;
1446
+ retiredStatus: "retired" | null;
1447
+ storeCiters: number; // count from the reverse-link index
1448
+ docCiters: number; // count from the citation index (0 when no globs)
1449
+ flags: SupersededCiterFlag[]; // gated by the evidence guarantee
1450
+ telemetryEvents: object[];
1451
+ }
1452
+ ```
1453
+
1454
+ Gate telemetry is emitted at `collect-gate` and `flag-gate` (`knowledge.flag-superseded-citers`);
1455
+ when doc globs are configured, the folded `knowledge.check-inbound-references` gate events (Addendum
1456
+ I) also appear in the trail.
1457
+
1458
+ ### K.4 Read-Only Invariant
1459
+
1460
+ `flagSupersededCiters` reads the store's query surface (`get`, `getLinks`) and — when doc globs are
1461
+ configured — the #340 inbound-reference scan (doc files + `get`). It mutates no record and appends no
1462
+ mutation-log entry. The store tree is byte-identical before and after the call.
1463
+
1464
+ ### K.5 Conformance
1465
+
1466
+ An adapter conforms to this addendum when the supersede-propagation suite
1467
+ (`evals/supersede-propagation/suite.test.js`) passes against it — AC1 (store-record + doc citer
1468
+ enumerated from the two indexes), AC2 (every flag carries citer ref + cited id + superseding context;
1469
+ superseded-with-no-citers and non-superseded both yield zero flags), AC3 (read-only), and the
1470
+ retire-with-supersededByRef path. The suite is parameterized by `KNOWLEDGE_ADAPTER`, so conformance is
1471
+ required of every adapter (AC4). Because it reuses `get`/`getLinks` and the Addendum-I check, any
1472
+ adapter conforming to Addenda A, B, H, and I satisfies it without new storage.
1473
+
1474
+ ---
1475
+
1476
+ ## Addendum L — Incremental Consolidation (append mode, #343)
1477
+
1478
+ ### L.1 Problem: whole-body consolidate is heavy enough that sessions skip it
1479
+
1480
+ The `knowledge.consolidate` flow (Addendum A) originally accepted only a **whole-body** input:
1481
+ the caller authored the *entire* updated snapshot body (`options.proposedBody`) and the runner
1482
+ replaced the snapshot with it. To add one decision, the caller had to re-read and re-emit every
1483
+ prior entry. Field evidence from the ops design partner (record `0e439c57`) showed this cost is
1484
+ high enough that real sessions **skip consolidation entirely** — the living decision snapshot
1485
+ silently stops living, defeating the flow.
1486
+
1487
+ The snapshot already links its contributing compiled records with kind `"source"` and carries
1488
+ `provenance.source_ids` (Addendum A.3), so the body is *derivable from records*. Append mode uses
1489
+ that: the caller supplies only the new contribution and the runner regenerates the body.
1490
+
1491
+ ### L.2 Append-mode input
1492
+
1493
+ `KnowledgeFlowRunner.consolidate(snapshotIdOrTopic, options)` accepts, as an alternative to
1494
+ `options.proposedBody`, an **appended entry** identifying the new compiled record:
1495
+
1496
+ | Option | Type | Description |
1497
+ |---|---|---|
1498
+ | `appendEntryRecordId` | `string` | Id of the new **compiled** record to append (the appended entry). `appendEntry: { recordId }` is an accepted equivalent shape. |
1499
+ | `header` | `string` (opt) | Body header prepended before the rendered entries. |
1500
+ | `entryRenderer` | `(record) => string` (opt) | Pluggable per-entry renderer. Default renders a decision-log section: `## <title>\n\n<body>`. |
1501
+
1502
+ `proposedBody` (whole-body mode) and the append options are **mutually exclusive**; supplying
1503
+ both throws `MISSING_EVIDENCE`. The appended record MUST exist and be type `"compiled"`
1504
+ (otherwise `MISSING_EVIDENCE`).
1505
+
1506
+ ### L.3 Body regeneration
1507
+
1508
+ On an append-mode call the runner:
1509
+
1510
+ 1. Resolves the **current** (non-superseded) snapshot for the topic (or the given snapshot id).
1511
+ 2. Reads that snapshot's contributing records: `provenance.source_ids` first, falling back to its
1512
+ kind `"source"` links (Addendum A.3).
1513
+ 3. Forms the new source set = *prior sources* ++ *appended entry*, de-duplicated with order
1514
+ preserved (re-appending the same record is idempotent — no duplicate source id).
1515
+ 4. Regenerates the body by rendering **every** source record in order (`entryRenderer`, joined by a
1516
+ `---` rule, optional `header`). The caller never supplies prior content.
1517
+
1518
+ The regenerated body then flows through the *unchanged* consolidate machinery
1519
+ (propose → evidence-gate → apply-or-reject, Addendum A.5): a new snapshot is created with the
1520
+ regenerated body, `provenance.source_ids` and kind `"source"` links covering every contributing
1521
+ record **including the new one** (R3), and the prior snapshot(s) are superseded, not deleted.
1522
+
1523
+ ### L.4 Back-compat
1524
+
1525
+ Whole-body mode is unchanged: existing callers passing `options.proposedBody` work exactly as
1526
+ before, and `evals/consolidation/suite.test.js` passes unmodified. Append mode is purely additive
1527
+ — it changes how the effective body and source cluster are *computed*, not the gate shape, the
1528
+ supersede-not-delete invariant (A.5), or the telemetry gate points.
1529
+
1530
+ ### L.5 Sequential-session safety (no lost update)
1531
+
1532
+ Because the body is regenerated from the **current live snapshot's** records on every call, two
1533
+ sequential consolidations from different sessions/agents, each appending one distinct entry, yield
1534
+ a snapshot containing **both** entries — the second session resolves the first session's snapshot,
1535
+ reads its (now-larger) source set, and appends to it rather than overwriting a stale whole body.
1536
+
1537
+ ### L.6 Conformance
1538
+
1539
+ An adapter conforms when the incremental-consolidation suite
1540
+ (`evals/consolidate-incremental/suite.test.js`) passes against it. The suite is parameterized by
1541
+ `KNOWLEDGE_ADAPTER`; the no-lost-update case (R4) and the provenance/source-link completeness case
1542
+ (R3, asserted via the portable `get()` + reverse-`supersedes`-link guarantees of A.7) are required
1543
+ of every adapter. The default-store's additional guarantee that `listByType("snapshot")` still
1544
+ returns superseded snapshots is covered for that adapter by `evals/consolidation/suite.test.js`
1545
+ (the Obsidian adapter archives superseded snapshots out of the working set — both keep them
1546
+ reachable via `get()`).