knosky 0.5.0 → 0.6.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.
@@ -0,0 +1,189 @@
1
+ // KnoSky naive-vs-guided agent comparison protocol (SAT-458).
2
+ // Defines the artifact schema and validators for a single comparison run:
3
+ // tokens (in + out), tool-calls, time-to-relevant-file, correctness
4
+ // for both a naive agent and an identical task run with KnoSky guidance.
5
+ // Pure Node stdlib, ESM — no new deps.
6
+
7
+ import { PROTOCOL_VERSION } from './schema.mjs';
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Artifact type constant
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /** @type {string} */
14
+ export const COMPARISON_ARTIFACT_TYPE = 'comparison-run';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Internal helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Return true when `p` is a relative-safe file path:
22
+ * — non-empty string
23
+ * — does not start with `/` (Unix absolute) or Windows drive letter
24
+ * — contains no `..` path segment
25
+ * @param {string} p
26
+ * @returns {boolean}
27
+ */
28
+ function isRelativeSafePath(p) {
29
+ if (!p || typeof p !== 'string') return false;
30
+ if (p.startsWith('/')) return false;
31
+ if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
32
+ if (p.split(/[/\\]/).some(s => s === '..')) return false;
33
+ return true;
34
+ }
35
+
36
+ /**
37
+ * Validate one metric side object (either `naive` or `guided`).
38
+ * Returns an array of violation strings; empty means valid.
39
+ *
40
+ * @param {unknown} side
41
+ * @param {string} label — "naive" or "guided"
42
+ * @returns {string[]}
43
+ */
44
+ function validateSide(side, label) {
45
+ const errors = [];
46
+
47
+ if (!side || typeof side !== 'object') {
48
+ errors.push(`${label} must be an object`);
49
+ return errors; // can't go further
50
+ }
51
+
52
+ // tokens_in: non-negative integer
53
+ if (!Number.isInteger(side.tokens_in) || side.tokens_in < 0) {
54
+ errors.push(`${label}.tokens_in must be a non-negative integer, got: ${JSON.stringify(side.tokens_in)}`);
55
+ }
56
+
57
+ // tokens_out: non-negative integer
58
+ if (!Number.isInteger(side.tokens_out) || side.tokens_out < 0) {
59
+ errors.push(`${label}.tokens_out must be a non-negative integer, got: ${JSON.stringify(side.tokens_out)}`);
60
+ }
61
+
62
+ // tool_calls: non-negative integer
63
+ if (!Number.isInteger(side.tool_calls) || side.tool_calls < 0) {
64
+ errors.push(`${label}.tool_calls must be a non-negative integer, got: ${JSON.stringify(side.tool_calls)}`);
65
+ }
66
+
67
+ // time_to_relevant_file_ms: null (never found) or non-negative number
68
+ const ttrf = side.time_to_relevant_file_ms;
69
+ if (ttrf !== null && !(typeof ttrf === 'number' && ttrf >= 0)) {
70
+ errors.push(
71
+ `${label}.time_to_relevant_file_ms must be null or a non-negative number, got: ${JSON.stringify(ttrf)}`,
72
+ );
73
+ }
74
+
75
+ // correct: boolean
76
+ if (typeof side.correct !== 'boolean') {
77
+ errors.push(`${label}.correct must be a boolean, got: ${JSON.stringify(side.correct)}`);
78
+ }
79
+
80
+ return errors;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // makeComparisonRun
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Construct a KnoSky `comparison-run` artifact envelope.
89
+ *
90
+ * @param {object} opts
91
+ * @param {string} opts.task_id Short identifier for this task.
92
+ * @param {string} opts.task_description Human-readable description of the task.
93
+ * @param {string[]} opts.target_files Relative paths to the "relevant" files for this task.
94
+ * @param {object} opts.naive Metrics for the naive agent (no KnoSky guidance).
95
+ * @param {number} opts.naive.tokens_in Input tokens consumed.
96
+ * @param {number} opts.naive.tokens_out Output tokens produced.
97
+ * @param {number} opts.naive.tool_calls Total tool/function calls made.
98
+ * @param {number|null} opts.naive.time_to_relevant_file_ms ms until first relevant-file hit, or null.
99
+ * @param {boolean} opts.naive.correct Whether the agent arrived at the correct answer.
100
+ * @param {object} opts.guided Same metric shape for the KnoSky-guided agent.
101
+ * @returns {object}
102
+ */
103
+ export function makeComparisonRun({
104
+ task_id,
105
+ task_description,
106
+ target_files = [],
107
+ naive,
108
+ guided,
109
+ } = {}) {
110
+ return {
111
+ knosky_protocol: PROTOCOL_VERSION,
112
+ artifact_type: COMPARISON_ARTIFACT_TYPE,
113
+ advisory: true,
114
+ generated_at: new Date().toISOString(),
115
+ task_id,
116
+ task_description,
117
+ target_files,
118
+ naive,
119
+ guided,
120
+ };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // validateComparisonRun
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Validate a KnoSky `comparison-run` document.
129
+ * Collects every violation; `ok` is true only when `errors` is empty.
130
+ *
131
+ * @param {object} doc
132
+ * @returns {{ ok: boolean, errors: string[] }}
133
+ */
134
+ export function validateComparisonRun(doc) {
135
+ const errors = [];
136
+
137
+ if (!doc || typeof doc !== 'object') {
138
+ return { ok: false, errors: ['doc must be an object'] };
139
+ }
140
+
141
+ if (doc.knosky_protocol !== PROTOCOL_VERSION) {
142
+ errors.push(
143
+ `knosky_protocol must be "${PROTOCOL_VERSION}", got: ${JSON.stringify(doc.knosky_protocol)}`,
144
+ );
145
+ }
146
+
147
+ if (doc.artifact_type !== COMPARISON_ARTIFACT_TYPE) {
148
+ errors.push(
149
+ `artifact_type must be "${COMPARISON_ARTIFACT_TYPE}", got: ${JSON.stringify(doc.artifact_type)}`,
150
+ );
151
+ }
152
+
153
+ if (doc.advisory !== true) {
154
+ errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
155
+ }
156
+
157
+ // task_id: non-empty string
158
+ if (typeof doc.task_id !== 'string' || doc.task_id.length === 0) {
159
+ errors.push(`task_id must be a non-empty string, got: ${JSON.stringify(doc.task_id)}`);
160
+ }
161
+
162
+ // task_description: non-empty string
163
+ if (typeof doc.task_description !== 'string' || doc.task_description.length === 0) {
164
+ errors.push(
165
+ `task_description must be a non-empty string, got: ${JSON.stringify(doc.task_description)}`,
166
+ );
167
+ }
168
+
169
+ // target_files: non-empty array of relative-safe path strings
170
+ if (!Array.isArray(doc.target_files) || doc.target_files.length === 0) {
171
+ errors.push('target_files must be a non-empty array');
172
+ } else {
173
+ for (let i = 0; i < doc.target_files.length; i++) {
174
+ const p = doc.target_files[i];
175
+ if (typeof p !== 'string' || p.length === 0) {
176
+ errors.push(`target_files[${i}] must be a non-empty string`);
177
+ } else if (!isRelativeSafePath(p)) {
178
+ errors.push(`target_files[${i}] must be a relative path with no ".." segments: ${JSON.stringify(p)}`);
179
+ }
180
+ }
181
+ }
182
+
183
+ // naive and guided sides
184
+ for (const side of ['naive', 'guided']) {
185
+ errors.push(...validateSide(doc[side], side));
186
+ }
187
+
188
+ return { ok: errors.length === 0, errors };
189
+ }
@@ -0,0 +1,13 @@
1
+ // Shared, dependency-free constants used by more than one core module.
2
+ // Kept in its own file (no imports, no side effects) so any core/*.mjs can
3
+ // import from it without creating a circular dependency between modules.
4
+
5
+ // Upper bound for a plausible ledger sequence (git commit count). 1 billion
6
+ // is far beyond any real repo's commit count and far below
7
+ // Number.MAX_SAFE_INTEGER (~9e15), so it rejects implausible/attacker-
8
+ // supplied values (e.g. 1e100) with no precision-loss risk of its own.
9
+ // Enforced independently at two layers — core/freshness.mjs's
10
+ // extractLedgerSeq (structural validation) and core/ledger.mjs's
11
+ // checkAndAdvance (the persisted guard itself) — both import this single
12
+ // source of truth so the two layers cannot silently diverge.
13
+ export const MAX_PLAUSIBLE_LEDGER_SEQ = 1_000_000_000;
@@ -0,0 +1,111 @@
1
+ // Cross-repo graph utilities (SAT-448): edge detection, boundary extraction, and rendering metadata.
2
+ // Pure logic — no filesystem access, no new deps. Works on the in-memory city/byId structures
3
+ // produced by retrieve.load() or any equivalent.
4
+ //
5
+ // A node's *repo* identity is determined by provenance.repo when present, falling back to
6
+ // provenance.store (e.g. "fs", "gh:owner/repo") so existing city data stays fully compatible —
7
+ // single-repo graphs have no cross-repo edges because every node shares the same store value.
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // repoOf
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Return the repository identity of a node.
15
+ * Preference order:
16
+ * 1. node.provenance.repo — explicit multi-repo label (new field, optional)
17
+ * 2. node.provenance.store — always present; used as the identity when repo is absent
18
+ * (works correctly for single-repo graphs: all nodes share one store → no cross edges)
19
+ *
20
+ * Returns null if provenance is absent or malformed.
21
+ *
22
+ * @param {object} node
23
+ * @returns {string|null}
24
+ */
25
+ export function repoOf(node) {
26
+ if (!node || typeof node !== 'object') return null;
27
+ const prov = node.provenance;
28
+ if (!prov || typeof prov !== 'object') return null;
29
+ if (typeof prov.repo === 'string' && prov.repo.length > 0) return prov.repo;
30
+ if (typeof prov.store === 'string' && prov.store.length > 0) return prov.store;
31
+ return null;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // isCrossRepoEdge
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Return true when the edge (fromId → toId) crosses a repo boundary.
40
+ * An edge is cross-repo when repoOf(fromNode) !== repoOf(toNode) AND both are non-null.
41
+ * Edges where either endpoint is missing or has no repo identity are treated as same-repo
42
+ * (conservative: no false positives).
43
+ *
44
+ * @param {Map<string, object>} byId — node map from retrieve.load()
45
+ * @param {string} fromId
46
+ * @param {string} toId
47
+ * @returns {boolean}
48
+ */
49
+ export function isCrossRepoEdge(byId, fromId, toId) {
50
+ const src = byId.get(fromId);
51
+ const dst = byId.get(toId);
52
+ if (!src || !dst) return false;
53
+ const r1 = repoOf(src);
54
+ const r2 = repoOf(dst);
55
+ if (r1 === null || r2 === null) return false;
56
+ return r1 !== r2;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // getCrossRepoEdges
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /**
64
+ * Walk every node's `links` array and return all edges that cross a repo boundary.
65
+ *
66
+ * @param {object} ctx — { city: { nodes: [] }, byId: Map }
67
+ * @returns {Array<{ from: string, to: string, fromRepo: string, toRepo: string }>}
68
+ */
69
+ export function getCrossRepoEdges(ctx) {
70
+ const edges = [];
71
+ for (const node of (ctx.city.nodes || [])) {
72
+ if (!Array.isArray(node.links)) continue;
73
+ const fromRepo = repoOf(node);
74
+ if (fromRepo === null) continue;
75
+ for (const toId of node.links) {
76
+ const dst = ctx.byId.get(toId);
77
+ if (!dst) continue;
78
+ const toRepo = repoOf(dst);
79
+ if (toRepo === null) continue;
80
+ if (fromRepo !== toRepo) {
81
+ edges.push({ from: node.id, to: toId, fromRepo, toRepo });
82
+ }
83
+ }
84
+ }
85
+ return edges;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // getRepoBoundaries
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Return a summary of every distinct repo present in the graph and its node membership.
94
+ *
95
+ * @param {object} ctx — { city: { nodes: [] }, byId: Map }
96
+ * @returns {Array<{ repo: string, nodeIds: string[], count: number }>}
97
+ * Sorted by count desc, then repo asc.
98
+ */
99
+ export function getRepoBoundaries(ctx) {
100
+ /** @type {Map<string, string[]>} */
101
+ const byRepo = new Map();
102
+ for (const node of (ctx.city.nodes || [])) {
103
+ const r = repoOf(node);
104
+ if (r === null) continue;
105
+ if (!byRepo.has(r)) byRepo.set(r, []);
106
+ byRepo.get(r).push(node.id);
107
+ }
108
+ return [...byRepo.entries()]
109
+ .map(([repo, nodeIds]) => ({ repo, nodeIds, count: nodeIds.length }))
110
+ .sort((a, b) => b.count - a.count || String(a.repo).localeCompare(String(b.repo)));
111
+ }
@@ -0,0 +1,68 @@
1
+ // KnoSky HITL escalation gate (D-166 / SAT-467) — any P0/critical or ambiguous
2
+ // review result must surface to Paul, never proceed silently.
3
+ // D-166 (SAT-466): zero-P0/critical + all reviewers succeeded → auto-proceed-to-publish.
4
+ // Pure function: no I/O, no external dependencies, safe to import anywhere.
5
+
6
+ /**
7
+ * Decide whether a review result must be escalated to Paul, or may auto-publish.
8
+ *
9
+ * Rules (D-166 hitl gate, SAT-467 / SAT-466):
10
+ * - One or more CRITICAL findings → P0, escalate immediately.
11
+ * - All reviewers failed → ambiguous (no result at all), escalate.
12
+ * - Any reviewer failed (degraded) → ambiguous (result is incomplete), escalate.
13
+ * - Zero criticals + zero failures + at least one reviewer ran
14
+ * → canAutoPublish (D-166 pre-authorized path).
15
+ *
16
+ * @param {object} opts
17
+ * @param {object[]} [opts.criticals] CRITICAL findings ({ sev, file, hint, role }).
18
+ * @param {object[]} [opts.failed] Failed reviewer records ({ role, err, ok: false }).
19
+ * @param {number} [opts.total] Total reviewer count dispatched this run.
20
+ * @returns {{ escalate: boolean, reasons: string[], paulMessage: string, canAutoPublish: boolean }}
21
+ * escalate — true iff the result must block and surface to Paul.
22
+ * reasons — human-readable list explaining why escalation was triggered
23
+ * (empty array when escalate === false).
24
+ * paulMessage — the block line to embed in the PR review body;
25
+ * non-empty iff escalate === true.
26
+ * canAutoPublish — DATA ONLY, not an authorization by itself (D-173): true iff a clean
27
+ * review (zero criticals, zero reviewer failures, ≥1 reviewer ran)
28
+ * would satisfy D-166's pre-authorized-publish condition. Callers must
29
+ * NOT treat this as license to take an autonomous action (e.g. approving
30
+ * a PR, publishing a release) on its own -- it exists so a separately
31
+ * gated, narrowly-scoped release workflow can consume it for the actual
32
+ * SAT-437/D-166 publish decision. The general per-PR review script
33
+ * deliberately does NOT act on it -- see that script's own comments.
34
+ */
35
+ export function shouldEscalateToPaul({ criticals = [], failed = [], total = 0 } = {}) {
36
+ const reasons = [];
37
+
38
+ // P0 / critical findings — must never pass silently
39
+ if (criticals.length > 0) {
40
+ reasons.push(`${criticals.length} CRITICAL finding(s)`);
41
+ }
42
+
43
+ const allFailed = total > 0 && failed.length === total;
44
+
45
+ if (allFailed) {
46
+ // No reviewer returned a result — outcome is unknowable (ambiguous)
47
+ reasons.push('all reviewers failed (no result — ambiguous)');
48
+ } else if (failed.length > 0) {
49
+ // At least one reviewer failed — remaining results are incomplete (ambiguous)
50
+ reasons.push(`${failed.length} of ${total} reviewer(s) failed (degraded — ambiguous)`);
51
+ }
52
+
53
+ const escalate = reasons.length > 0;
54
+
55
+ // D-166 (SAT-466): pre-authorized auto-publish path — zero criticals, all reviewers
56
+ // succeeded, and at least one reviewer actually ran. Do NOT auto-publish when total
57
+ // is 0 (no-reviewer synthetic run) — that would be a silent pass with no evidence.
58
+ const canAutoPublish = !escalate && total > 0 && failed.length === 0;
59
+
60
+ return {
61
+ escalate,
62
+ reasons,
63
+ paulMessage: escalate
64
+ ? 'Merge blocked until CRITICAL items are resolved or dismissed by Paul.'
65
+ : '',
66
+ canAutoPublish,
67
+ };
68
+ }
@@ -0,0 +1,194 @@
1
+ // KnoSky ledger-anchored freshness attestation (SAT-444, SAT-474).
2
+ //
3
+ // Closes the clock-skew / key-resurrection gap by basing freshness on the
4
+ // repository's commit count rather than wall-clock time. A `ledger_seq`
5
+ // is a monotone integer — the number of commits reachable from HEAD —
6
+ // which cannot be fabricated by adjusting the system clock and can only
7
+ // decrease when history is rewritten (the ledger-truncation attack caught
8
+ // by the high-water-mark guard in core/ledger.mjs).
9
+ //
10
+ // SAT-474: validateFreshnessWithHwm() routes through the *persisted*
11
+ // checkAndAdvance() guard (core/ledger.mjs) so that the rollback defence
12
+ // survives process restarts. validateFreshness() is retained for callers
13
+ // that manage their own in-memory lastSeq (e.g. protocol-spec validation).
14
+ // Pure Node stdlib, ESM — no third-party dependencies.
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { checkAndAdvance } from './ledger.mjs';
18
+ import { MAX_PLAUSIBLE_LEDGER_SEQ } from './constants.mjs';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // extractLedgerSeq — read the ledger_seq stored in a city envelope
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Extract the `ledger_seq` from a city envelope or any artifact that carries
26
+ * one. Returns null when absent, not a non-negative integer, or implausibly
27
+ * large (see MAX_PLAUSIBLE_LEDGER_SEQ).
28
+ *
29
+ * @param {object} obj City envelope or comparable artifact object.
30
+ * @returns {number|null}
31
+ */
32
+ export function extractLedgerSeq(obj) {
33
+ if (!obj || typeof obj !== 'object') return null;
34
+ const seq = obj.ledger_seq;
35
+ if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0) return null;
36
+ if (seq > MAX_PLAUSIBLE_LEDGER_SEQ) return null;
37
+ return seq;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // computeLedgerSeq — derive a ledger_seq from a git repo on disk
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * Compute the ledger_seq for `root` by counting all commits reachable from
46
+ * HEAD (`git rev-list --count HEAD`). Returns 0 when git is unavailable or
47
+ * the directory has no commit history (e.g. a brand-new repo with no commits).
48
+ *
49
+ * @param {string} root Absolute path to the git working tree.
50
+ * @returns {number} Non-negative integer commit count.
51
+ */
52
+ export function computeLedgerSeq(root) {
53
+ try {
54
+ const out = execFileSync(
55
+ 'git',
56
+ ['rev-list', '--count', 'HEAD'],
57
+ { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
58
+ ).trim();
59
+ const n = parseInt(out, 10);
60
+ return Number.isFinite(n) && n >= 0 ? n : 0;
61
+ } catch {
62
+ // No git / no commits / non-repo directory → 0 (safe: sequence starts fresh)
63
+ return 0;
64
+ }
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // checkHighWaterMark — V13 guard against ledger-truncation attack
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * High-water-mark guard (V13).
73
+ *
74
+ * Returns `{ ok: true }` when `newSeq` is strictly greater than `lastSeq`,
75
+ * meaning the city has advanced — its ledger has grown (or this is the first
76
+ * load, indicated by `lastSeq === null`).
77
+ *
78
+ * Returns `{ ok: false, reason }` when `newSeq` is ≤ `lastSeq`, which means
79
+ * the ledger has been truncated or replayed — a potential key-resurrection or
80
+ * rollback attack. Callers MUST treat stale/equal as suspicious.
81
+ *
82
+ * @param {number|null} lastSeq Previously accepted ledger_seq, or null on
83
+ * first load (unconditionally accepted).
84
+ * @param {number|null} newSeq Ledger_seq from the incoming artifact, or
85
+ * null when absent (treated as 0 — conservative).
86
+ * @returns {{ ok: boolean, reason?: string }}
87
+ */
88
+ export function checkHighWaterMark(lastSeq, newSeq) {
89
+ // Treat missing seq as 0 — conservative: an artifact with no ledger anchoring
90
+ // is as trustworthy as a brand-new repo.
91
+ const effective = (typeof newSeq === 'number' && Number.isInteger(newSeq) && newSeq >= 0)
92
+ ? newSeq
93
+ : 0;
94
+
95
+ // First load: no last seq to compare against — unconditionally accept.
96
+ if (lastSeq === null || lastSeq === undefined) {
97
+ return { ok: true };
98
+ }
99
+
100
+ if (typeof lastSeq !== 'number' || !Number.isInteger(lastSeq) || lastSeq < 0) {
101
+ // Corrupt lastSeq — treat it as 0 (reset the watermark)
102
+ return { ok: true };
103
+ }
104
+
105
+ if (effective > lastSeq) {
106
+ return { ok: true };
107
+ }
108
+
109
+ // equal: rolled back to same point (replay) or no new commits
110
+ // less: ledger truncation — history was rewritten
111
+ const direction = effective < lastSeq ? 'ledger truncated' : 'ledger not advanced';
112
+ return {
113
+ ok: false,
114
+ reason:
115
+ direction +
116
+ ': incoming ledger_seq ' + effective +
117
+ ' is not greater than last accepted ' + lastSeq +
118
+ ' — possible rollback or key-resurrection attack',
119
+ };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // validateFreshness — full attestation check for an incoming artifact
124
+ // ---------------------------------------------------------------------------
125
+
126
+ /**
127
+ * Validate the freshness of an incoming artifact against an optional stored
128
+ * high-water mark.
129
+ *
130
+ * Combines:
131
+ * (1) structural check: `ledger_seq` is present and a non-negative integer
132
+ * (2) V13 high-water-mark guard: `ledger_seq` must exceed the last accepted
133
+ * value (pass `null` on first load to skip this check)
134
+ *
135
+ * @param {object} artifact City envelope or protocol artifact.
136
+ * @param {number|null} lastSeq Last accepted ledger_seq (null = first load).
137
+ * @returns {{ ok: boolean, ledger_seq: number|null, errors: string[] }}
138
+ */
139
+ export function validateFreshness(artifact, lastSeq = null) {
140
+ const errors = [];
141
+ const seq = extractLedgerSeq(artifact);
142
+
143
+ if (seq === null) {
144
+ errors.push('ledger_seq is missing or not a non-negative integer');
145
+ }
146
+
147
+ const hwm = checkHighWaterMark(lastSeq, seq);
148
+ if (!hwm.ok) {
149
+ errors.push(hwm.reason);
150
+ }
151
+
152
+ return { ok: errors.length === 0, ledger_seq: seq, errors };
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // validateFreshnessWithHwm — persisted-HWM variant (SAT-474)
157
+ // ---------------------------------------------------------------------------
158
+
159
+ /**
160
+ * Validate freshness through the **persisted** high-water-mark guard
161
+ * (`core/ledger.mjs` `checkAndAdvance`).
162
+ *
163
+ * Unlike `validateFreshness`, this function reads and updates the HWM from
164
+ * `hwmPath` on disk, so the rollback defence is durable across process
165
+ * restarts. Callers that previously maintained an in-memory `lastSeq` and
166
+ * passed it to `validateFreshness` should migrate to this function and a
167
+ * stable `hwmPath` in their data directory.
168
+ *
169
+ * Semantics of the persisted guard (from `checkAndAdvance`):
170
+ * - seq STRICTLY LESS than HWM → rejected (anti-truncation guard)
171
+ * - seq EQUAL to HWM → accepted (idempotent replay)
172
+ * - seq GREATER than HWM → accepted and HWM advanced
173
+ *
174
+ * @param {object} artifact City envelope or protocol artifact.
175
+ * @param {string} hwmPath Path to the independently-persisted HWM file.
176
+ * @returns {{ ok: boolean, ledger_seq: number|null, errors: string[] }}
177
+ */
178
+ export function validateFreshnessWithHwm(artifact, hwmPath) {
179
+ const errors = [];
180
+ const seq = extractLedgerSeq(artifact);
181
+
182
+ if (seq === null) {
183
+ errors.push('ledger_seq is missing or not a non-negative integer');
184
+ // Cannot call checkAndAdvance with a null seq — return early.
185
+ return { ok: false, ledger_seq: null, errors };
186
+ }
187
+
188
+ const result = checkAndAdvance(seq, hwmPath);
189
+ if (!result.ok) {
190
+ errors.push(result.error);
191
+ }
192
+
193
+ return { ok: errors.length === 0, ledger_seq: seq, errors };
194
+ }
@@ -7,6 +7,7 @@ import { execSync } from 'node:child_process';
7
7
  import { SCHEMA_VERSION, IGNORE_DEFAULTS, deriveCategories, serializeNode, validateCity, setRedactTerms, findSecrets } from './contract.mjs';
8
8
  import { extractImportSpecifiers, resolveSpec } from './edges.mjs';
9
9
  import { gitChurn } from './churn.mjs';
10
+ import { computeLedgerSeq } from './freshness.mjs';
10
11
 
11
12
  const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1]] : []).filter(Boolean));
12
13
  const ROOT = args.root && path.resolve(args.root);
@@ -163,10 +164,15 @@ const preList = preHits.map(h => h[0] + ':' + h[1]).join(', ');
163
164
  const nodes = raw.map(serializeNode);
164
165
  const catIds = [...new Set(nodes.map(n => n.category))].sort();
165
166
  const categories = deriveCategories(catIds);
167
+ // Ledger-anchored freshness (SAT-444): monotone commit count, used as a
168
+ // replay-resistant sequence number. Consumers compare against the last
169
+ // accepted ledger_seq (V13 high-water-mark guard) to detect rollbacks.
170
+ const ledger_seq = computeLedgerSeq(ROOT);
166
171
  const city = {
167
172
  schema_version: SCHEMA_VERSION,
168
173
  generated_at: new Date().toISOString(),
169
174
  source: { kind: 'fs', ref: INCLUDE_ABS ? ROOT : path.basename(ROOT), rev: REV },
175
+ ledger_seq,
170
176
  categories, node_count: nodes.length, nodes,
171
177
  };
172
178