@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.
- package/.github/workflows/ci.yml +4 -0
- package/CHANGELOG.md +17 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +97 -12
- package/build/src/cli/workflow-sidecar.d.ts +14 -4
- package/build/src/cli/workflow-sidecar.js +100 -10
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/run.sh +6 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +52 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +10 -0
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/src/cli/assignment-provider.ts +110 -12
- package/src/cli/workflow-sidecar.ts +99 -10
|
@@ -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
|
-
|
|
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
|
|
851
|
-
|
|
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()`).
|