@isparling/engram-cli 0.1.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,80 @@
1
+ // Atomic file replacement that preserves the target's permission bits.
2
+ //
3
+ // Atomic write: temp file in the same directory as the
4
+ // target -> fsync the file descriptor -> rename -> fsync the containing
5
+ // directory. The rename is what makes the write atomic from the point of
6
+ // view of any reader; the two fsyncs are what make it durable across a
7
+ // crash rather than merely atomic in memory.
8
+ //
9
+ // The temp file's mode is set to match the target's existing mode before
10
+ // the rename, so a record that was e.g. 0600 doesn't silently become
11
+ // 0644 (open()'s default, subject to umask) after a routine edit.
12
+ //
13
+ // Concurrency note (explicitly out of scope, not solved here): the write
14
+ // path is single-writer. Two concurrent submitCandidate calls against the
15
+ // same record both read-plan-write independently; the second rename simply
16
+ // wins and the first writer's change is lost without conflict detection.
17
+ // That is accepted for the single-user core, not fixed.
18
+
19
+ import { open, rename, stat } from "node:fs/promises";
20
+ import { basename, dirname, join } from "node:path";
21
+ import { randomBytes } from "node:crypto";
22
+
23
+ /**
24
+ * Thrown when the temp file was written, fsynced, and successfully
25
+ * renamed over the target — the new content is live and readable — but
26
+ * the final fsync of the containing directory failed. The write is not
27
+ * rolled back (a valid write is never undone to make something else
28
+ * look healthy); callers should treat the record as committed but treat
29
+ * anything depending on this write's crash-durability, including the
30
+ * subsequent qmd refresh, as unsafe to attempt.
31
+ */
32
+ export class AtomicWriteDirectorySyncError extends Error {
33
+ readonly cause: unknown;
34
+
35
+ constructor(message: string, cause: unknown) {
36
+ super(message);
37
+ this.name = "AtomicWriteDirectorySyncError";
38
+ this.cause = cause;
39
+ }
40
+ }
41
+
42
+ export async function atomicWriteFile(targetPath: string, content: string): Promise<void> {
43
+ const dir = dirname(targetPath);
44
+ const tmpPath = join(dir, `.${basename(targetPath)}.tmp-${randomBytes(8).toString("hex")}`);
45
+
46
+ let targetMode: number | undefined;
47
+ try {
48
+ const targetStat = await stat(targetPath);
49
+ targetMode = targetStat.mode & 0o777;
50
+ } catch {
51
+ targetMode = undefined; // target does not exist yet; nothing to preserve
52
+ }
53
+
54
+ const fileHandle = await open(tmpPath, "w");
55
+ try {
56
+ await fileHandle.writeFile(content, "utf8");
57
+ if (targetMode !== undefined) {
58
+ await fileHandle.chmod(targetMode);
59
+ }
60
+ await fileHandle.sync();
61
+ } finally {
62
+ await fileHandle.close();
63
+ }
64
+
65
+ await rename(tmpPath, targetPath);
66
+
67
+ try {
68
+ const dirHandle = await open(dir, "r");
69
+ try {
70
+ await dirHandle.sync();
71
+ } finally {
72
+ await dirHandle.close();
73
+ }
74
+ } catch (error) {
75
+ throw new AtomicWriteDirectorySyncError(
76
+ "record content was written and renamed into place, but fsync of the containing directory failed afterward; the write is not rolled back, but its crash-durability is unconfirmed",
77
+ error,
78
+ );
79
+ }
80
+ }
@@ -0,0 +1,229 @@
1
+ // Candidate knowledge envelope validation and reshaping for the submission
2
+ // pipeline. Validated candidates are the single input a knowledge transaction
3
+ // accepts.
4
+ //
5
+ // A candidate is JSON: the target record id, a source tag, claims to add,
6
+ // evidence entries to append, and optionally claims/evidence to remove or
7
+ // rewrite, and optionally a frontmatter edit.
8
+ //
9
+ // Validation is hand-written (no schema library) and returns a discriminated
10
+ // Result instead of throwing. Validation is deliberately strict about unknown
11
+ // top-level fields: this is the only line of defense, alongside the id
12
+ // pattern in spaceBinding.ts, against a candidate trying to smuggle a
13
+ // pointer at another knowledge root or qmd collection through the payload.
14
+ //
15
+ // Every free-text field is also required to be a single line: no `\n` or
16
+ // `\r`. Without this, a value like `"\n## Evidence log\n\n- forged"` in
17
+ // add_claims would forge a second `## Evidence log` heading into the
18
+ // record body — markdownRecord.ts's parser would then either reject the
19
+ // resulting record outright (duplicate section) or, worse, silently
20
+ // collapse it on a later parse. Rejecting the newline at the input
21
+ // boundary is simpler and safer than trying to make the parser robust
22
+ // against arbitrary embedded structure.
23
+
24
+ import { err, ok, requireDefined, type Result } from "./types.ts";
25
+
26
+ export type EvidenceInput = { date: string; text: string };
27
+ export type TextRewrite = { from: string; to: string };
28
+
29
+ export type Candidate = {
30
+ target_id: string;
31
+ source: string;
32
+ add_claims: string[];
33
+ add_evidence: EvidenceInput[];
34
+ remove_claims: string[];
35
+ remove_evidence: string[];
36
+ rewrite_claims: TextRewrite[];
37
+ rewrite_evidence: TextRewrite[];
38
+ frontmatter: { title?: string } | null;
39
+ };
40
+
41
+ const ALLOWED_TOP_LEVEL_KEYS = new Set([
42
+ "target_id",
43
+ "source",
44
+ "add_claims",
45
+ "add_evidence",
46
+ "remove_claims",
47
+ "remove_evidence",
48
+ "rewrite_claims",
49
+ "rewrite_evidence",
50
+ "frontmatter",
51
+ ]);
52
+
53
+ const RECORD_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
54
+ const EVIDENCE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
55
+
56
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
57
+ return typeof value === "object" && value !== null && !Array.isArray(value);
58
+ }
59
+
60
+ function isNonEmptyString(value: unknown): value is string {
61
+ return typeof value === "string" && value.trim().length > 0;
62
+ }
63
+
64
+ function containsNewline(value: string): boolean {
65
+ return /[\r\n]/.test(value);
66
+ }
67
+
68
+ function validateSingleLineStringArray(value: unknown, fieldName: string, errors: string[]): string[] {
69
+ if (value === undefined) return [];
70
+ if (!Array.isArray(value)) {
71
+ errors.push(`${fieldName} must be an array of strings`);
72
+ return [];
73
+ }
74
+ const result: string[] = [];
75
+ value.forEach((item, index) => {
76
+ if (!isNonEmptyString(item)) {
77
+ errors.push(`${fieldName}[${index}] must be a non-empty string`);
78
+ return;
79
+ }
80
+ if (containsNewline(item)) {
81
+ errors.push(`${fieldName}[${index}] must not contain newlines`);
82
+ return;
83
+ }
84
+ result.push(item);
85
+ });
86
+ return result;
87
+ }
88
+
89
+ function validateRewriteArray(value: unknown, fieldName: string, errors: string[]): TextRewrite[] {
90
+ if (value === undefined) return [];
91
+ if (!Array.isArray(value)) {
92
+ errors.push(`${fieldName} must be an array of {from, to} objects`);
93
+ return [];
94
+ }
95
+ const result: TextRewrite[] = [];
96
+ value.forEach((item, index) => {
97
+ if (!isPlainObject(item)) {
98
+ errors.push(`${fieldName}[${index}] must be an object with "from" and "to"`);
99
+ return;
100
+ }
101
+ const keys = Object.keys(item);
102
+ const unexpected = keys.filter((k) => k !== "from" && k !== "to");
103
+ if (unexpected.length > 0) {
104
+ errors.push(`${fieldName}[${index}] has unexpected field(s): ${unexpected.join(", ")}`);
105
+ return;
106
+ }
107
+ if (!isNonEmptyString(item.from) || !isNonEmptyString(item.to)) {
108
+ errors.push(`${fieldName}[${index}] must have non-empty string "from" and "to"`);
109
+ return;
110
+ }
111
+ if (containsNewline(item.from) || containsNewline(item.to)) {
112
+ errors.push(`${fieldName}[${index}] "from"/"to" must not contain newlines`);
113
+ return;
114
+ }
115
+ result.push({ from: item.from, to: item.to });
116
+ });
117
+ return result;
118
+ }
119
+
120
+ function validateEvidenceArray(value: unknown, fieldName: string, errors: string[]): EvidenceInput[] {
121
+ if (value === undefined) return [];
122
+ if (!Array.isArray(value)) {
123
+ errors.push(`${fieldName} must be an array of {date, text} objects`);
124
+ return [];
125
+ }
126
+ const result: EvidenceInput[] = [];
127
+ value.forEach((item, index) => {
128
+ if (!isPlainObject(item)) {
129
+ errors.push(`${fieldName}[${index}] must be an object with "date" and "text"`);
130
+ return;
131
+ }
132
+ const keys = Object.keys(item);
133
+ const unexpected = keys.filter((k) => k !== "date" && k !== "text");
134
+ if (unexpected.length > 0) {
135
+ errors.push(`${fieldName}[${index}] has unexpected field(s): ${unexpected.join(", ")}`);
136
+ return;
137
+ }
138
+ if (typeof item.date !== "string" || !EVIDENCE_DATE_PATTERN.test(item.date) || containsNewline(item.date)) {
139
+ errors.push(`${fieldName}[${index}].date must match YYYY-MM-DD`);
140
+ return;
141
+ }
142
+ if (!isNonEmptyString(item.text)) {
143
+ errors.push(`${fieldName}[${index}].text must be a non-empty string`);
144
+ return;
145
+ }
146
+ if (containsNewline(item.text)) {
147
+ errors.push(`${fieldName}[${index}].text must not contain newlines`);
148
+ return;
149
+ }
150
+ result.push({ date: item.date, text: item.text });
151
+ });
152
+ return result;
153
+ }
154
+
155
+ export function validateCandidate(raw: unknown): Result<Candidate> {
156
+ const errors: string[] = [];
157
+
158
+ if (!isPlainObject(raw)) {
159
+ return err(["candidate must be a JSON object"]);
160
+ }
161
+
162
+ const unexpectedKeys = Object.keys(raw).filter((k) => !ALLOWED_TOP_LEVEL_KEYS.has(k));
163
+ if (unexpectedKeys.length > 0) {
164
+ errors.push(`candidate has unexpected field(s): ${unexpectedKeys.join(", ")}`);
165
+ }
166
+
167
+ let targetId: string | undefined;
168
+ if (!isNonEmptyString(raw.target_id)) {
169
+ errors.push("target_id is required and must be a non-empty string");
170
+ } else if (!RECORD_ID_PATTERN.test(raw.target_id)) {
171
+ errors.push(`target_id must match ${RECORD_ID_PATTERN} (lowercase letters, digits, hyphens; no path separators)`);
172
+ } else {
173
+ targetId = raw.target_id;
174
+ }
175
+
176
+ let source: string | undefined;
177
+ if (!isNonEmptyString(raw.source)) {
178
+ errors.push("source is required and must be a non-empty string");
179
+ } else if (containsNewline(raw.source)) {
180
+ errors.push("source must not contain newlines");
181
+ } else {
182
+ source = raw.source;
183
+ }
184
+
185
+ const addClaims = validateSingleLineStringArray(raw.add_claims, "add_claims", errors);
186
+ const removeClaims = validateSingleLineStringArray(raw.remove_claims, "remove_claims", errors);
187
+ const addEvidence = validateEvidenceArray(raw.add_evidence, "add_evidence", errors);
188
+ const removeEvidence = validateSingleLineStringArray(raw.remove_evidence, "remove_evidence", errors);
189
+ const rewriteClaims = validateRewriteArray(raw.rewrite_claims, "rewrite_claims", errors);
190
+ const rewriteEvidence = validateRewriteArray(raw.rewrite_evidence, "rewrite_evidence", errors);
191
+
192
+ let frontmatter: { title?: string } | null = null;
193
+ if (raw.frontmatter !== undefined && raw.frontmatter !== null) {
194
+ if (!isPlainObject(raw.frontmatter)) {
195
+ errors.push("frontmatter must be an object");
196
+ } else {
197
+ const keys = Object.keys(raw.frontmatter);
198
+ const unexpected = keys.filter((k) => k !== "title");
199
+ if (unexpected.length > 0) {
200
+ errors.push(`frontmatter has unexpected field(s): ${unexpected.join(", ")}`);
201
+ }
202
+ if (raw.frontmatter.title !== undefined) {
203
+ if (!isNonEmptyString(raw.frontmatter.title)) {
204
+ errors.push("frontmatter.title must be a non-empty string");
205
+ } else if (containsNewline(raw.frontmatter.title)) {
206
+ errors.push("frontmatter.title must not contain newlines");
207
+ } else {
208
+ frontmatter = { title: raw.frontmatter.title };
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ if (errors.length > 0) {
215
+ return err(errors);
216
+ }
217
+
218
+ return ok({
219
+ target_id: requireDefined(targetId, "target_id validated but not captured"),
220
+ source: requireDefined(source, "source validated but not captured"),
221
+ add_claims: addClaims,
222
+ add_evidence: addEvidence,
223
+ remove_claims: removeClaims,
224
+ remove_evidence: removeEvidence,
225
+ rewrite_claims: rewriteClaims,
226
+ rewrite_evidence: rewriteEvidence,
227
+ frontmatter,
228
+ });
229
+ }
@@ -0,0 +1,275 @@
1
+ // Mutation classification: a planned delta is additive, non-additive, or
2
+ // no-change, derived from the assembled plan rather than from intent.
3
+ //
4
+ // Classifies a candidate against a parsed record and plans the resulting
5
+ // Markdown mutation.
6
+ //
7
+ // Classification rule: additive means only appending
8
+ // evidence and/or adding new claims. Non-additive means removing or
9
+ // rewriting an existing claim or evidence entry, or altering frontmatter.
10
+ //
11
+ // ASSUMPTION (documented, not litigated further — see harness/README.md):
12
+ // every commit mechanically stamps `updated` to the submission date. That
13
+ // stamp is not, by itself, a classification-relevant frontmatter change;
14
+ // only a candidate that explicitly requests a frontmatter edit (currently
15
+ // just `title`) makes the candidate non-additive. Otherwise a harmless
16
+ // bookkeeping timestamp would force every candidate through the approval
17
+ // gate, which defeats the additive path entirely.
18
+ //
19
+ // Every remove/rewrite directive is resolved against the ORIGINAL record
20
+ // state, not applied as a sequential pipeline: a rewrite whose `to` value
21
+ // happens to match some other original entry's text must not make that
22
+ // unrelated entry vanish just because a later step compares against
23
+ // already-mutated state. Directives that are individually unambiguous but
24
+ // jointly conflicting (the same original text named by both a remove and
25
+ // a rewrite, or by two different rewrites) are rejected outright rather
26
+ // than resolved by pipeline order, which is exactly what "order" was
27
+ // silently deciding before.
28
+ //
29
+ // Classification is NOT trusted from candidate directives alone. A
30
+ // candidate that only names add_claims/add_evidence LOOKS additive, but
31
+ // the actual planned mutation is what can destroy content — through a
32
+ // latent bug in this file, in withMutatedContent, or in the parser's
33
+ // round-trip fidelity (preamble loss was exactly such a bug: it discarded
34
+ // content that no candidate ever asked to touch, and would have kept
35
+ // classifying as additive forever, because the classification check never
36
+ // looked at the record). `mutationPreservesAllContent` compares the
37
+ // planned `after` against the pristine `before` directly — preamble,
38
+ // every "other" section, and every original claim/evidence entry must
39
+ // still be present — and classification is non-additive if EITHER the
40
+ // candidate declared a destructive directive OR the actual plan drops or
41
+ // alters anything, whichever fires. This makes classification a property
42
+ // of the mutation, not a report of intent.
43
+
44
+ import type { Candidate, TextRewrite } from "./candidate.ts";
45
+ import { renderUnifiedDiff } from "./diff.ts";
46
+ import {
47
+ getActiveClaims,
48
+ getEvidenceLog,
49
+ withMutatedContent,
50
+ type OtherSection,
51
+ type ParsedRecord,
52
+ } from "./markdownRecord.ts";
53
+ import { serializeRecord } from "./markdownRecord.ts";
54
+ import { err, ok, type Result } from "./types.ts";
55
+
56
+ export type Classification = "additive" | "non-additive";
57
+
58
+ export type MutationPlan = {
59
+ classification: Classification;
60
+ before: ParsedRecord;
61
+ after: ParsedRecord;
62
+ beforeText: string;
63
+ afterText: string;
64
+ diff: string;
65
+ };
66
+
67
+ /**
68
+ * Resolves remove/rewrite/add directives for a single flat text list
69
+ * (active claims, or evidence log entries) against its original state.
70
+ * Returns errors for: a remove/rewrite target that doesn't exist in the
71
+ * original list, two rewrite directives naming the same original text, or
72
+ * an original text named by both a remove and a rewrite directive.
73
+ */
74
+ function resolveTextList(
75
+ original: string[],
76
+ removeList: string[],
77
+ rewriteList: TextRewrite[],
78
+ additions: string[],
79
+ fieldLabel: string,
80
+ ): Result<string[]> {
81
+ const errors: string[] = [];
82
+ const originalSet = new Set(original);
83
+
84
+ for (const text of removeList) {
85
+ if (!originalSet.has(text)) {
86
+ errors.push(`remove target not found in ${fieldLabel}: ${JSON.stringify(text)}`);
87
+ }
88
+ }
89
+
90
+ const rewriteFromCounts = new Map<string, number>();
91
+ for (const rewrite of rewriteList) {
92
+ if (!originalSet.has(rewrite.from)) {
93
+ errors.push(`rewrite target not found in ${fieldLabel}: ${JSON.stringify(rewrite.from)}`);
94
+ }
95
+ rewriteFromCounts.set(rewrite.from, (rewriteFromCounts.get(rewrite.from) ?? 0) + 1);
96
+ }
97
+
98
+ for (const [from, count] of rewriteFromCounts) {
99
+ if (count > 1) {
100
+ errors.push(`${fieldLabel} has ${count} conflicting rewrite directives for the same original text: ${JSON.stringify(from)}`);
101
+ }
102
+ }
103
+
104
+ const removeSet = new Set(removeList);
105
+ for (const from of rewriteFromCounts.keys()) {
106
+ if (removeSet.has(from)) {
107
+ errors.push(`${fieldLabel} text is targeted by both a remove and a rewrite directive: ${JSON.stringify(from)}`);
108
+ }
109
+ }
110
+
111
+ if (errors.length > 0) {
112
+ return err(errors);
113
+ }
114
+
115
+ const rewriteMap = new Map(rewriteList.map((rewrite) => [rewrite.from, rewrite.to]));
116
+ const resolved: string[] = [];
117
+ for (const item of original) {
118
+ if (removeSet.has(item)) continue;
119
+ const rewritten = rewriteMap.get(item);
120
+ resolved.push(rewritten !== undefined ? rewritten : item);
121
+ }
122
+ for (const addition of additions) {
123
+ resolved.push(addition);
124
+ }
125
+
126
+ return ok(resolved);
127
+ }
128
+
129
+ function arraysEqual(a: string[], b: string[]): boolean {
130
+ if (a.length !== b.length) return false;
131
+ for (let i = 0; i < a.length; i++) {
132
+ if (a[i] !== b[i]) return false;
133
+ }
134
+ return true;
135
+ }
136
+
137
+ /** before ⊆ after as a multiset: every original entry (counting
138
+ * duplicates) must still appear in after at least as many times. A
139
+ * rewrite or removal makes the original text's count drop, which this
140
+ * catches regardless of what the candidate's directives claimed to do;
141
+ * pure additions only ever raise counts, so they always pass. */
142
+ function isTextMultisetSubset(before: string[], after: string[]): boolean {
143
+ const remaining = new Map<string, number>();
144
+ for (const item of after) {
145
+ remaining.set(item, (remaining.get(item) ?? 0) + 1);
146
+ }
147
+ for (const item of before) {
148
+ const count = remaining.get(item) ?? 0;
149
+ if (count <= 0) return false;
150
+ remaining.set(item, count - 1);
151
+ }
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * True iff `after` preserves everything `before` had: the same id and
157
+ * title (the mechanical `updated` bump is expected and excluded), the
158
+ * same preamble, every "other" section unchanged in place, and every
159
+ * original active-claims/evidence-log entry still present at least as
160
+ * many times as it originally appeared. False means the plan destroys or
161
+ * alters pre-existing content, independent of what the candidate declared.
162
+ *
163
+ * Exported so this safety net can be unit-tested directly against
164
+ * synthetic before/after pairs. Under the current (correct)
165
+ * resolveTextList/withMutatedContent implementation, there is no
166
+ * candidate reachable through the public submitCandidate/planMutation API
167
+ * that makes this return false for a directive-declared-additive
168
+ * candidate — which is the point: it is a structural backstop against a
169
+ * FUTURE bug in this file or in markdownRecord.ts's round-trip fidelity
170
+ * (preamble loss was exactly such a bug), not a path exercised by valid
171
+ * input today.
172
+ */
173
+ export function mutationPreservesAllContent(before: ParsedRecord, after: ParsedRecord): boolean {
174
+ if (before.frontmatter.id !== after.frontmatter.id) return false;
175
+ if (before.frontmatter.title !== after.frontmatter.title) return false;
176
+
177
+ if (!arraysEqual(before.preambleLines, after.preambleLines)) return false;
178
+
179
+ const beforeOthers = before.sections.filter((s): s is OtherSection => s.kind === "other");
180
+ const afterOthers = after.sections.filter((s): s is OtherSection => s.kind === "other");
181
+ if (beforeOthers.length !== afterOthers.length) return false;
182
+ for (let i = 0; i < beforeOthers.length; i++) {
183
+ const beforeSection = beforeOthers[i];
184
+ const afterSection = afterOthers[i];
185
+ if (beforeSection === undefined || afterSection === undefined) return false;
186
+ if (beforeSection.heading !== afterSection.heading) return false;
187
+ if (!arraysEqual(beforeSection.lines, afterSection.lines)) return false;
188
+ }
189
+
190
+ if (!isTextMultisetSubset(getActiveClaims(before), getActiveClaims(after))) return false;
191
+ if (!isTextMultisetSubset(getEvidenceLog(before), getEvidenceLog(after))) return false;
192
+
193
+ return true;
194
+ }
195
+
196
+ export type ContentPreservationCheck = (before: ParsedRecord, after: ParsedRecord) => boolean;
197
+
198
+ export function planMutation(
199
+ recordText: string,
200
+ record: ParsedRecord,
201
+ candidate: Candidate,
202
+ submittedAt: string,
203
+ /** Testing seam, defaulting to the real mutationPreservesAllContent.
204
+ * Mirrors the spawnFn/writeRecord injection pattern used elsewhere:
205
+ * lets a test prove the classification WIRING itself combines this
206
+ * check with the directive-declared signal, independent of whether a
207
+ * real candidate can currently drive the real check to false. */
208
+ contentCheck: ContentPreservationCheck = mutationPreservesAllContent,
209
+ ): Result<MutationPlan> {
210
+ const claimsResult = resolveTextList(
211
+ getActiveClaims(record),
212
+ candidate.remove_claims,
213
+ candidate.rewrite_claims,
214
+ candidate.add_claims.map((text) => `${text} [source: ${candidate.source}]`),
215
+ "active claims",
216
+ );
217
+ const evidenceResult = resolveTextList(
218
+ getEvidenceLog(record),
219
+ candidate.remove_evidence,
220
+ candidate.rewrite_evidence,
221
+ candidate.add_evidence.map((evidence) => `${evidence.date} — ${evidence.text} [source: ${candidate.source}]`),
222
+ "evidence log",
223
+ );
224
+
225
+ const errors: string[] = [];
226
+ if (!claimsResult.ok) errors.push(...claimsResult.errors);
227
+ if (!evidenceResult.ok) errors.push(...evidenceResult.errors);
228
+ if (errors.length > 0) {
229
+ return err(errors);
230
+ }
231
+ // Narrowed by the checks above, but the checks live on separate Result
232
+ // values so TypeScript can't see that from `errors.length === 0` alone.
233
+ if (!claimsResult.ok || !evidenceResult.ok) {
234
+ return err(["internal invariant violated: resolved list marked ok=false without contributing errors"]);
235
+ }
236
+
237
+ const after = withMutatedContent(record, {
238
+ frontmatter: {
239
+ id: record.frontmatter.id,
240
+ title: candidate.frontmatter?.title ?? record.frontmatter.title,
241
+ updated: submittedAt,
242
+ },
243
+ activeClaims: claimsResult.value,
244
+ evidenceLog: evidenceResult.value,
245
+ });
246
+
247
+ const declaredNonAdditive =
248
+ candidate.remove_claims.length > 0 ||
249
+ candidate.remove_evidence.length > 0 ||
250
+ candidate.rewrite_claims.length > 0 ||
251
+ candidate.rewrite_evidence.length > 0 ||
252
+ candidate.frontmatter !== null;
253
+
254
+ // The authoritative signal: does the actual planned mutation drop or
255
+ // alter any pre-existing content? This can fire even when
256
+ // declaredNonAdditive is false — e.g. a parser/serializer round-trip
257
+ // bug that silently drops content the candidate never asked to touch —
258
+ // which is exactly the property "no commit may destroy content the
259
+ // candidate did not ask to change" requires.
260
+ const contentPreserved = contentCheck(record, after);
261
+
262
+ const classification: Classification = declaredNonAdditive || !contentPreserved ? "non-additive" : "additive";
263
+
264
+ const afterText = serializeRecord(after);
265
+ const diff = renderUnifiedDiff(record.frontmatter.id, recordText, afterText);
266
+
267
+ return ok({
268
+ classification,
269
+ before: record,
270
+ after,
271
+ beforeText: recordText,
272
+ afterText,
273
+ diff,
274
+ });
275
+ }