@@ -354,6 +355,14 @@ const narrativePoints = [
.frame-section {
padding-bottom: 3rem;
}
+ .simulation-note {
+ margin: 0 0 0.75rem;
+ padding: 0.7rem 0.9rem;
+ border: 1px solid var(--cc-border-strong);
+ background: var(--cc-surface-raised);
+ color: var(--cc-text-body);
+ font-size: 0.78rem;
+ }
/* The stage holds the inline height; the panel fills it absolutely so that
when it goes fixed (immersive) the page keeps its place — no jump. */
.stage {
diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro
index 8bb245da..408f5924 100644
--- a/apps/site/src/pages/index.astro
+++ b/apps/site/src/pages/index.astro
@@ -68,7 +68,7 @@ const principles = [
{
number: '03',
title: 'Disagreements stay with the answer.',
- body: 'ContextCake keeps the other value, its source, and its date beside the selected answer. An agent can see the conflict before it acts.',
+ body: 'ContextCake keeps structurally aligned alternatives, their sources, and their dates beside the selected answer. An agent can see the discrepancy before it acts.',
layer: 'company',
},
];
@@ -81,8 +81,8 @@ const capabilities = [
},
{
icon: 'compare',
- title: 'Conflicts with dates',
- body: 'Contradictions remain readable beside the effective answer, not buried in history.',
+ title: 'Structural discrepancies with dates',
+ body: 'Differences between shared OKF identities remain readable beside the effective answer, not buried in history.',
},
{
icon: 'share',
@@ -152,7 +152,7 @@ const mcpTools = ['search', 'read_file', 'list_concepts', 'get_links', 'find_cap
Context console
Overview
- Conflicts 2
+ Discrepancies 2
Concepts
diff --git a/package.json b/package.json
index 7826c545..bf51f7ae 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "Federated team knowledge with cascading layer precedence — OKF-compatible, MCP-ready.",
"type": "module",
"scripts": {
- "test": "bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && node --test packages/core/tests/tokens.test.mjs && node --test packages/core/tests/git-auth.test.mjs && node --test packages/core/tests/manifest.test.mjs && node --test packages/core/tests/index-keys.test.mjs && node --test packages/core/tests/cache-source.test.mjs && node --test packages/core/tests/search.test.mjs && node --test packages/core/tests/conflict-resolutions.test.mjs && bash packages/core/tests/profile-runtime-test.sh && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh && npm run eval && npm run test:metrics && npm run test:release-workflow && bash packages/core/tests/index-stability-test.sh && bash packages/core/tests/index-lifecycle-test.sh && bash packages/core/tests/graph-latency-test.sh",
+ "test": "bash packages/core/tests/smoke-test.sh && bash packages/core/tests/resolver-test.sh && bash packages/core/tests/source-test.sh && bash packages/core/tests/files-source-test.sh && bash packages/core/tests/github-source-test.sh && node --test packages/core/tests/tokens.test.mjs && node --test packages/core/tests/git-auth.test.mjs && node --test packages/core/tests/manifest.test.mjs && node --test packages/core/tests/index-keys.test.mjs && node --test packages/core/tests/cache-source.test.mjs && node --test packages/core/tests/search.test.mjs && node --test packages/core/tests/conflict-resolutions.test.mjs && node --test packages/core/tests/discrepancies.test.mjs && node --test packages/core/tests/discrepancy-transactions.test.mjs && bash packages/core/tests/profile-runtime-test.sh && bash packages/core/tests/pack-test.sh && bash packages/core/tests/git-sync-test.sh && bash packages/core/tests/capture-test.sh && bash packages/core/tests/team-sync-mcp-test.sh && bash packages/core/tests/playground-test.sh && bash packages/core/tests/service-test.sh && bash packages/core/tests/mcp-respawn-test.sh && bash packages/core/tests/setup-robustness-test.sh && npm run eval && npm run test:metrics && npm run test:release-workflow && bash packages/core/tests/index-stability-test.sh && bash packages/core/tests/index-lifecycle-test.sh && bash packages/core/tests/graph-latency-test.sh",
"eval": "node packages/core/eval/run.mjs",
"mcp": "node mcp-server.mjs",
"playground": "node apps/playground/server.mjs",
diff --git a/packages/core/src/conflict-resolutions.mjs b/packages/core/src/conflict-resolutions.mjs
index 9bbbf469..0a3c93f0 100644
--- a/packages/core/src/conflict-resolutions.mjs
+++ b/packages/core/src/conflict-resolutions.mjs
@@ -9,6 +9,7 @@ import fsp from "node:fs/promises";
import path from "node:path";
const SCHEMA_VERSION = 1;
+const SUPPORTED_SCHEMA_VERSIONS = new Set([1, 2]);
/**
* A deliberately narrow equivalence rule for the magic wand.
@@ -56,7 +57,7 @@ export function createConflictResolutionLog(manifestPath) {
if (!line.trim()) continue;
try {
const record = JSON.parse(line);
- if (record?.schemaVersion !== SCHEMA_VERSION || typeof record.id !== "string") {
+ if (!SUPPORTED_SCHEMA_VERSIONS.has(record?.schemaVersion) || typeof record.id !== "string") {
throw new Error("unsupported record");
}
records.push(record);
@@ -69,7 +70,7 @@ export function createConflictResolutionLog(manifestPath) {
async function append(record) {
await prepare();
- const saved = { schemaVersion: SCHEMA_VERSION, ...record };
+ const saved = { schemaVersion: record.schemaVersion ?? SCHEMA_VERSION, ...record };
appendTail = appendTail.then(() => fsp.appendFile(file, `${JSON.stringify(saved)}\n`, { encoding: "utf8", mode: 0o600 }));
await appendTail;
return saved;
@@ -81,3 +82,81 @@ export function createConflictResolutionLog(manifestPath) {
return { file, prepare, list, append, find };
}
+
+export function createDiscrepancyTransactionJournal(manifestPath) {
+ const dir = path.join(path.dirname(path.resolve(manifestPath)), ".contextcake");
+ const file = path.join(dir, "discrepancy-transactions.ndjson");
+ let appendTail = Promise.resolve();
+
+ async function append(record) {
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
+ appendTail = appendTail.then(() => fsp.appendFile(file, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 }));
+ await appendTail;
+ return record;
+ }
+
+ async function list() {
+ let text;
+ try { text = await fsp.readFile(file, "utf8"); }
+ catch (error) { if (error.code === "ENOENT") return []; throw error; }
+ return text.split("\n").filter(Boolean).map((line, index) => {
+ try { return JSON.parse(line); }
+ catch { throw new Error(`Discrepancy transaction journal is unreadable at line ${index + 1}`); }
+ });
+ }
+
+ async function recover(allowedRoots = [], committedTransactionIds = []) {
+ const records = await list();
+ const final = new Set(records.filter((r) => r.state === "committed" || r.state === "rolled_back").map((r) => r.id));
+ const committedDecisions = new Set(committedTransactionIds);
+ const pending = records.filter((r) => r.state === "prepared" && !final.has(r.id));
+ const recovered = [];
+ const failures = [];
+ for (const tx of pending) {
+ try {
+ // A decision is appended only after every replacement succeeds. If the
+ // process died before the journal's final marker, that durable decision
+ // proves the write committed; rolling it back would make history lie.
+ if (committedDecisions.has(tx.id)) {
+ for (const target of tx.targets ?? []) {
+ if (!insideAnyRoot(target.path, allowedRoots)
+ || !insideAnyRoot(target.staged, allowedRoots)
+ || !insideAnyRoot(target.backup, allowedRoots)) {
+ throw new Error("journal target is outside the selected source roots");
+ }
+ await fsp.unlink(target.staged).catch(() => {});
+ await fsp.unlink(target.backup).catch(() => {});
+ }
+ await append({ id: tx.id, state: "committed", recoveredAt: new Date().toISOString(), reason: "decision log confirmed commit" });
+ continue;
+ }
+ for (const target of tx.targets ?? []) {
+ if (!insideAnyRoot(target.path, allowedRoots) || !insideAnyRoot(target.backup, allowedRoots)) {
+ throw new Error("journal target is outside the selected source roots");
+ }
+ await fsp.copyFile(target.backup, target.path);
+ await fsp.unlink(target.staged).catch(() => {});
+ await fsp.unlink(target.backup).catch(() => {});
+ }
+ await append({ id: tx.id, state: "rolled_back", recoveredAt: new Date().toISOString(), reason: "startup recovery" });
+ recovered.push(tx.id);
+ } catch (error) {
+ await append({ id: tx.id, state: "recovery_required", failedAt: new Date().toISOString(), error: error.message });
+ failures.push(`${tx.id}: ${error.message}`);
+ }
+ }
+ if (failures.length) throw new Error(`Recovery is required for ${failures.join("; ")}`);
+ return recovered;
+ }
+
+ return { file, append, list, recover };
+}
+
+function insideAnyRoot(target, roots) {
+ const resolved = path.resolve(String(target));
+ return roots.some((root) => {
+ const base = path.resolve(root);
+ const rel = path.relative(base, resolved);
+ return rel && !rel.startsWith("..") && !path.isAbsolute(rel);
+ });
+}
diff --git a/packages/core/src/discrepancies.mjs b/packages/core/src/discrepancies.mjs
new file mode 100644
index 00000000..425ce03d
--- /dev/null
+++ b/packages/core/src/discrepancies.mjs
@@ -0,0 +1,207 @@
+// Structural discrepancy projection over resolved concepts. This module never
+// changes resolver precedence and never performs semantic/model inference.
+
+import { createHash } from "node:crypto";
+
+export const DISCREPANCY_KINDS = new Set([
+ "section_content", "frontmatter_value", "broken_link", "changed_after_decision",
+]);
+
+export function fingerprint(value) {
+ return createHash("sha256").update(stable(value)).digest("hex");
+}
+
+export function discrepancyRevision(contributions) {
+ return fingerprint(contributions
+ .map((item) => [item.source, item.level, item.fingerprint])
+ .sort(([a], [b]) => String(a).localeCompare(String(b))));
+}
+
+export function buildDiscrepancies(concepts, {
+ decisions = [], coverageComplete = true, sourceHealth = [], priorities = {}, rules = [],
+} = {}) {
+ const out = [];
+ const ids = new Set(concepts.map((concept) => concept.id));
+ const healthBySource = new Map(sourceHealth.map((source) => [source.name, source]));
+ const decisionsById = decisionMap(decisions);
+
+ for (const concept of concepts) {
+ const owner = String(concept.frontmatter?.owner ?? "Unassigned");
+ const conceptType = String(concept.frontmatter?.type ?? "concept");
+ const levels = new Map(concept.contributors.map((item) => [item.layer, item.level]));
+
+ for (const section of concept.sections) {
+ if (section.conflicts?.length) {
+ const baseKind = "section_content";
+ const baseId = `${baseKind}::${concept.id}::${section.key}`;
+ const contributions = [
+ contribution(section.sourceLayer, levels.get(section.sourceLayer), section.sourceUpdated, section.content, true),
+ ...section.conflicts.map((item) => contribution(item.layer, levels.get(item.layer), item.updated, item.content, false)),
+ ];
+ out.push(finalize({
+ id: baseId, legacyId: `${concept.id}::${section.key}`, kind: baseKind,
+ conceptId: concept.id, conceptTitle: String(concept.frontmatter?.title ?? concept.id), conceptType,
+ key: section.key, label: headingText(section.heading) || section.key, owner,
+ effectiveSource: section.sourceLayer, effectiveValue: section.content,
+ winnerReason: `${section.sourceLayer} wins by configured layer precedence.`,
+ contributions, fresherDissent: section.fresherDissent === true,
+ sourceHealth: contributions.map((item) => healthSummary(healthBySource.get(item.source))),
+ priority: priorities[baseId] ?? "unassigned",
+ }, decisionsById, rules));
+ }
+
+ if (coverageComplete) {
+ for (const target of extractLinks(section.content)) {
+ if (ids.has(target)) continue;
+ const id = `broken_link::${concept.id}::${section.key}::${target}`;
+ const contributions = [contribution(section.sourceLayer, levels.get(section.sourceLayer), section.sourceUpdated, target, true)];
+ out.push(finalize({
+ id, kind: "broken_link", conceptId: concept.id,
+ conceptTitle: String(concept.frontmatter?.title ?? concept.id), conceptType,
+ key: section.key, label: `Missing link target: ${target}`, target, owner,
+ effectiveSource: section.sourceLayer, effectiveValue: target,
+ winnerReason: "The effective section links to a concept that no settled source provides.",
+ contributions, fresherDissent: false,
+ sourceHealth: contributions.map((item) => healthSummary(healthBySource.get(item.source))),
+ priority: priorities[id] ?? "unassigned",
+ }, decisionsById, rules));
+ }
+ }
+ }
+
+ for (const conflict of concept.frontmatterConflicts ?? []) {
+ const id = `frontmatter_value::${concept.id}::${conflict.key}`;
+ const contributions = conflict.contributions.map((item) => contribution(
+ item.layer, item.level, item.updated, item.value, item.layer === conflict.winner.layer,
+ ));
+ out.push(finalize({
+ id, kind: "frontmatter_value", conceptId: concept.id,
+ conceptTitle: String(concept.frontmatter?.title ?? concept.id), conceptType,
+ key: conflict.key, label: conflict.key, owner,
+ effectiveSource: conflict.winner.layer, effectiveValue: conflict.winner.value,
+ winnerReason: `${conflict.winner.layer} wins by configured layer precedence.`,
+ contributions, fresherDissent: false,
+ sourceHealth: contributions.map((item) => healthSummary(healthBySource.get(item.source))),
+ priority: priorities[id] ?? "unassigned",
+ }, decisionsById, rules));
+ }
+ }
+ // A completed write removes the structural disagreement from the resolver's
+ // current output. Keep its evidence discoverable from the append-only log so
+ // "resolved" never means "forgotten". A current finding always wins this
+ // projection (including a reopened finding after a later source edit).
+ const currentIds = new Set(out.map((item) => item.id));
+ for (const [id, history] of decisionsById) {
+ if (!id.includes("::") || currentIds.has(id)) continue;
+ const latest = history.at(-1);
+ if (latest?.schemaVersion !== 2 || latest.action === "acknowledge") continue;
+ const contributions = (latest.contributions ?? []).map((item) => contribution(
+ item.layer, item.level, item.updated, item.content, item.layer === latest.chosen?.layer,
+ ));
+ out.push({
+ id, legacyId: latest.conflictId, kind: latest.discrepancyKind ?? "section_content",
+ originalKind: latest.discrepancyKind ?? "section_content", conceptId: latest.conceptId,
+ conceptTitle: latest.title ?? latest.conceptId, conceptType: latest.conceptType ?? "concept",
+ key: latest.sectionKey ?? latest.fieldKey ?? latest.linkTarget ?? "unknown",
+ label: latest.sectionHeading ?? latest.fieldKey ?? latest.linkTarget ?? "Resolved discrepancy",
+ owner: latest.owner ?? "Unassigned", effectiveSource: latest.chosen?.layer ?? null,
+ effectiveValue: latest.chosen?.content ?? latest.reconciledContent ?? null,
+ winnerReason: "This value was established by the recorded decision.", contributions,
+ revision: latest.revision, fresherDissent: false, sourceHealth: [], priority: latest.priority ?? "unassigned",
+ freshness: { effectiveUpdated: latest.chosen?.updated ?? null, newestUpdated: newestDate(contributions.map((entry) => entry.updated)), hasNewerDissent: false },
+ affectedLinks: [...new Set(contributions.flatMap((entry) => typeof entry.value === "string" ? extractLinks(entry.value) : []))],
+ status: latest.transactionState === "committed" || latest.transactionState === "not_required" ? "resolved" : "blocked",
+ history, matchingRules: [],
+ });
+ }
+ return { discrepancies: out, coverageComplete };
+}
+
+function finalize(item, decisionsById, rules) {
+ item.originalKind = item.kind;
+ item.freshness = {
+ effectiveUpdated: item.contributions.find((entry) => entry.effective)?.updated ?? null,
+ newestUpdated: newestDate(item.contributions.map((entry) => entry.updated)),
+ hasNewerDissent: item.fresherDissent === true,
+ };
+ item.affectedLinks = [...new Set(item.contributions.flatMap((entry) => typeof entry.value === "string" ? extractLinks(entry.value) : []))];
+ item.revision = discrepancyRevision(item.contributions);
+ const history = decisionsById.get(item.id) ?? decisionsById.get(item.legacyId) ?? [];
+ const latest = history.at(-1) ?? null;
+ const recorded = latest?.contributorFingerprints ?? latest?.contributions?.map((c) => ({ source: c.layer, fingerprint: fingerprint(c.content) }));
+ const changed = Boolean(latest && recorded && !sameFingerprints(recorded, item.contributions));
+ const ruleMatch = matchRules(item, rules);
+ const matchingRules = ruleMatch.rules;
+ item.kind = changed ? "changed_after_decision" : item.kind;
+ item.status = changed ? "reopened"
+ : latest?.action === "acknowledge" ? "acknowledged"
+ : !ruleMatch.conflict && matchingRules.some((rule) => rule.mode === "automatic") ? "auto_ready"
+ : !ruleMatch.conflict && matchingRules.length ? "recommended" : "needs_review";
+ item.history = history;
+ item.matchingRules = matchingRules.map(publicRule);
+ item.ruleConflict = ruleMatch.conflict;
+ return item;
+}
+
+function contribution(source, level, updated, value, effective) {
+ return { source, level: level ?? 0, updated: updated ?? null, value, fingerprint: fingerprint(value), effective };
+}
+
+function decisionMap(decisions) {
+ const map = new Map();
+ for (const decision of decisions) {
+ const keys = [decision.discrepancyId, decision.conflictId].filter(Boolean);
+ for (const key of keys) {
+ const rows = map.get(key) ?? [];
+ rows.push(decision);
+ map.set(key, rows);
+ }
+ }
+ return map;
+}
+
+function sameFingerprints(recorded, current) {
+ const a = recorded.map((x) => `${x.source ?? x.layer}:${x.fingerprint}`).sort();
+ const b = current.map((x) => `${x.source}:${x.fingerprint}`).sort();
+ return a.length === b.length && a.every((value, index) => value === b[index]);
+}
+
+function matchRules(item, rules) {
+ const sources = item.contributions.map((c) => c.source).sort().join("|");
+ const matches = rules.filter((rule) => rule.enabled !== false
+ && rule.match?.kind === item.kind
+ && rule.match?.conceptType === item.conceptType
+ && rule.match?.key === item.key
+ && [...(rule.match?.sources ?? [])].sort().join("|") === sources);
+ const actions = new Set(matches.map((rule) => stable(rule.action)));
+ return { rules: matches, conflict: actions.size > 1 };
+}
+
+function publicRule(rule) {
+ return { id: rule.id, scope: rule.scope, mode: rule.mode, action: rule.action, evidenceDecisionIds: rule.evidenceDecisionIds ?? [] };
+}
+
+function extractLinks(text) {
+ const out = [];
+ for (const match of String(text).matchAll(/!?\[[^\]]*]\(([^)]+)\)/g)) {
+ if (!match[0].startsWith("!") && localTarget(match[1])) out.push(cleanTarget(match[1]));
+ }
+ for (const match of String(text).matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?]]/g)) {
+ if (localTarget(match[1])) out.push(cleanTarget(match[1]));
+ }
+ return [...new Set(out.filter(Boolean))];
+}
+
+function localTarget(target) { return !/^[a-z][a-z0-9+.-]*:/i.test(String(target)) && !String(target).startsWith("#"); }
+function cleanTarget(target) { return String(target).split("#")[0].replace(/^\.\//, "").replace(/\.md$/i, "").trim(); }
+function headingText(value) { return String(value ?? "").replace(/^#+\s*/, "").replace(/\s*\{#.*\}\s*$/, "").trim(); }
+function healthSummary(source) { return source ? { source: source.name, status: source.status, error: source.error ?? null } : null; }
+function newestDate(values) {
+ return values.filter(Boolean).sort((a, b) => new Date(b).getTime() - new Date(a).getTime())[0] ?? null;
+}
+function stable(value) {
+ if (value === undefined) return "undefined";
+ if (Array.isArray(value)) return `[${value.map((item) => stable(item)).join(",")}]`;
+ if (value && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`;
+ return JSON.stringify(value);
+}
diff --git a/packages/core/src/discrepancy-priorities.mjs b/packages/core/src/discrepancy-priorities.mjs
new file mode 100644
index 00000000..ad5c1c40
--- /dev/null
+++ b/packages/core/src/discrepancy-priorities.mjs
@@ -0,0 +1,33 @@
+import fsp from "node:fs/promises";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+
+const ALLOWED = new Set(["unassigned", "high", "medium", "low"]);
+
+export function createDiscrepancyPriorityStore(manifestPath) {
+ const dir = path.join(path.dirname(path.resolve(manifestPath)), ".contextcake");
+ const file = path.join(dir, "discrepancy-priorities.json");
+
+ async function list() {
+ try {
+ const value = JSON.parse(await fsp.readFile(file, "utf8"));
+ if (value?.version !== 1 || !value.priorities || Array.isArray(value.priorities)) throw new Error("unsupported document");
+ return Object.fromEntries(Object.entries(value.priorities).filter(([, priority]) => ALLOWED.has(priority)));
+ } catch (error) {
+ if (error.code === "ENOENT") return {};
+ throw new Error(`Discrepancy priorities are unreadable: ${error.message}`);
+ }
+ }
+
+ async function set(id, priority) {
+ if (typeof id !== "string" || !id || !ALLOWED.has(priority)) throw new Error("Invalid discrepancy priority");
+ const priorities = await list();
+ if (priority === "unassigned") delete priorities[id]; else priorities[id] = priority;
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
+ const temp = `${file}.${randomUUID()}.tmp`;
+ await fsp.writeFile(temp, `${JSON.stringify({ version: 1, priorities }, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
+ await fsp.rename(temp, file);
+ return priority;
+ }
+ return { file, list, set };
+}
diff --git a/packages/core/src/discrepancy-rules.mjs b/packages/core/src/discrepancy-rules.mjs
new file mode 100644
index 00000000..0ca81d14
--- /dev/null
+++ b/packages/core/src/discrepancy-rules.mjs
@@ -0,0 +1,149 @@
+import fsp from "node:fs/promises";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+
+const VERSION = 1;
+
+export function createDiscrepancyRuleStore(manifestPath) {
+ const dir = path.join(path.dirname(path.resolve(manifestPath)), ".contextcake");
+ const file = path.join(dir, "discrepancy-rules.json");
+
+ async function list() {
+ try {
+ const parsed = JSON.parse(await fsp.readFile(file, "utf8"));
+ return parsed?.version === VERSION && Array.isArray(parsed.rules) ? parsed.rules.map(validateRule) : [];
+ } catch (error) {
+ if (error.code === "ENOENT") return [];
+ throw new Error(`Discrepancy rules are unreadable: ${error.message}`);
+ }
+ }
+
+ async function save(rules) {
+ await fsp.mkdir(dir, { recursive: true, mode: 0o700 });
+ const temp = `${file}.${randomUUID()}.tmp`;
+ await fsp.writeFile(temp, `${JSON.stringify({ version: VERSION, rules }, null, 2)}\n`, { mode: 0o600 });
+ await fsp.rename(temp, file);
+ }
+
+ async function create(input) {
+ const rules = await list();
+ // The API accepts only the structural contract. Content, notes, excerpts,
+ // prompts, and arbitrary caller metadata must never enter a rule file.
+ const rule = validateRule({
+ id: randomUUID(), scope: "local", mode: "recommend", enabled: true,
+ createdAt: new Date().toISOString(),
+ match: input?.match, action: input?.action,
+ evidenceDecisionIds: input?.evidenceDecisionIds,
+ });
+ rules.push(rule);
+ await save(rules);
+ return rule;
+ }
+
+ async function patch(id, changes) {
+ const rules = await list();
+ const index = rules.findIndex((rule) => rule.id === id);
+ if (index === -1) throw Object.assign(new Error("Discrepancy rule not found"), { status: 404 });
+ const allowed = {};
+ if (changes.mode === "recommend" || changes.mode === "automatic") allowed.mode = changes.mode;
+ if (typeof changes.enabled === "boolean") allowed.enabled = changes.enabled;
+ rules[index] = validateRule({ ...rules[index], ...allowed, updatedAt: new Date().toISOString() });
+ await save(rules);
+ return rules[index];
+ }
+
+ async function setLocalOverride(teamRule, changes) {
+ const rules = await list();
+ const index = rules.findIndex((rule) => rule.id === teamRule.id);
+ const base = index === -1 ? { ...teamRule, scope: "local", createdAt: new Date().toISOString() } : rules[index];
+ const next = validateRule({
+ ...base,
+ ...(changes.mode === "recommend" || changes.mode === "automatic" ? { mode: changes.mode } : {}),
+ ...(typeof changes.enabled === "boolean" ? { enabled: changes.enabled } : {}),
+ updatedAt: new Date().toISOString(),
+ });
+ if (index === -1) rules.push(next); else rules[index] = next;
+ await save(rules);
+ return next;
+ }
+
+ return { file, list, create, patch, setLocalOverride };
+}
+
+export function parseRuleDocument(text) {
+ const parsed = JSON.parse(text);
+ if (parsed?.version !== VERSION || !Array.isArray(parsed.rules)) throw new Error("Unsupported discrepancy rule document");
+ return parsed.rules.map((rule) => validateRule({ ...rule, scope: "team", mode: "recommend" }));
+}
+
+export function serializeRuleDocument(rules) {
+ return `${JSON.stringify({ version: VERSION, rules: rules.map((rule) => validateRule({ ...rule, scope: "team", mode: "recommend" })) }, null, 2)}\n`;
+}
+
+export function suggestDiscrepancyRules(decisions, existing = []) {
+ const latestByDiscrepancy = new Map();
+ const contradictory = new Set();
+ for (const row of decisions) {
+ if (row.schemaVersion !== 2 || row.method === "automatic" || !row.learningPattern || !row.ruleAction) continue;
+ const prior = latestByDiscrepancy.get(row.discrepancyId);
+ if (prior && JSON.stringify(prior.ruleAction) !== JSON.stringify(row.ruleAction)) contradictory.add(row.discrepancyId);
+ latestByDiscrepancy.set(row.discrepancyId, row);
+ }
+ const groups = new Map();
+ for (const row of latestByDiscrepancy.values()) {
+ if (contradictory.has(row.discrepancyId)) continue;
+ const key = JSON.stringify(row.learningPattern);
+ const group = groups.get(key) ?? [];
+ group.push(row);
+ groups.set(key, group);
+ }
+ const existingPatterns = new Set(existing.map((rule) => JSON.stringify(rule.match)));
+ const suggestions = [];
+ for (const [key, rows] of groups) {
+ const distinct = [...new Map(rows.map((row) => [row.discrepancyId, row])).values()];
+ if (distinct.length < 3 || existingPatterns.has(key)) continue;
+ const actions = new Set(distinct.map((row) => JSON.stringify(row.ruleAction)));
+ if (actions.size !== 1) continue;
+ suggestions.push({
+ id: `suggestion:${Buffer.from(key).toString("base64url").slice(0, 24)}`,
+ match: JSON.parse(key), action: distinct[0].ruleAction,
+ evidenceDecisionIds: distinct.map((row) => row.id), evidenceCount: distinct.length,
+ });
+ }
+ return suggestions;
+}
+
+function validateRule(rule) {
+ if (!rule || typeof rule.id !== "string" || !rule.match || !rule.action) throw new Error("Invalid discrepancy rule");
+ if (rule.mode !== "recommend" && rule.mode !== "automatic") throw new Error("Invalid discrepancy rule mode");
+ const { kind, conceptType, key, sources } = rule.match;
+ if (!["section_content", "frontmatter_value"].includes(kind)
+ || typeof conceptType !== "string" || !conceptType
+ || typeof key !== "string" || !key
+ || !Array.isArray(sources) || sources.length < 2
+ || sources.some((source) => typeof source !== "string" || !source)) {
+ throw new Error("Invalid discrepancy rule match");
+ }
+ if (rule.action.type === "prefer_source") {
+ if (typeof rule.action.source !== "string" || !sources.includes(rule.action.source)) throw new Error("Invalid preferred source");
+ } else if (rule.action.type === "acknowledge") {
+ if (!["different_scopes", "temporary_migration", "source_specific_authority", "other"].includes(rule.action.reasonCode)) {
+ throw new Error("Invalid acknowledgement reason");
+ }
+ } else throw new Error("Invalid discrepancy rule action");
+ if (rule.evidenceDecisionIds !== undefined
+ && (!Array.isArray(rule.evidenceDecisionIds) || rule.evidenceDecisionIds.some((id) => typeof id !== "string"))) {
+ throw new Error("Invalid discrepancy rule evidence");
+ }
+ return {
+ id: rule.id, scope: rule.scope === "team" ? "team" : "local",
+ mode: rule.mode, enabled: rule.enabled !== false,
+ match: { kind, conceptType, key, sources: [...new Set(sources)].sort() },
+ action: rule.action.type === "prefer_source"
+ ? { type: "prefer_source", source: rule.action.source }
+ : { type: "acknowledge", reasonCode: rule.action.reasonCode },
+ evidenceDecisionIds: [...new Set(rule.evidenceDecisionIds ?? [])],
+ ...(typeof rule.createdAt === "string" ? { createdAt: rule.createdAt } : {}),
+ ...(typeof rule.updatedAt === "string" ? { updatedAt: rule.updatedAt } : {}),
+ };
+}
diff --git a/packages/core/src/layer-files.mjs b/packages/core/src/layer-files.mjs
index 8134d536..64eccab1 100644
--- a/packages/core/src/layer-files.mjs
+++ b/packages/core/src/layer-files.mjs
@@ -205,7 +205,7 @@ export async function writeFileApi(rawBody, roots) {
* partial disagreement. Omitting a layer (or the map) skips its check, which is
* also the compatibility path for older clients.
*/
-export async function writeSectionApi(rawBody, roots) {
+export async function prepareSectionWrites(rawBody, roots) {
const body = parseJson(rawBody);
const { conceptId, sectionKey, layers, content } = body;
if (typeof conceptId !== "string" || typeof sectionKey !== "string" || typeof content !== "string") {
@@ -253,7 +253,7 @@ export async function writeSectionApi(rawBody, roots) {
? replacePlainTextBody(originalText, sectionKey, content)
: replaceSection(originalText, sectionKey, content, { refreshUpdatedTo: today });
if (!replaced) { skipped.push({ layer, reason: `section "${sectionKey}" not found` }); continue; }
- writes.push({ layer, abs: target.abs, text, currentContent, mtime: stat.mtime.toISOString() });
+ writes.push({ layer, abs: target.abs, text, currentContent, mtime: stat.mtime.toISOString(), mode: stat.mode });
}
if (body.requireAll === true && skipped.length > 0) {
throw httpError(409, `Nothing was changed. ${skipped.map((item) => `${item.layer}: ${item.reason}`).join("; ")}`);
@@ -268,6 +268,11 @@ export async function writeSectionApi(rawBody, roots) {
throw httpError(409, `${write.layer}/${conceptId}.md changed after this conflict was loaded. Reload it before resolving — nothing was written.`);
}
}
+ return { body, writes, skipped };
+}
+
+export async function writeSectionApi(rawBody, roots) {
+ const { writes, skipped } = await prepareSectionWrites(rawBody, roots);
// A section write into a clone-backed layer dirties .cache/repos, and a later
// Sync's `git pull --ff-only` will surface that as a failure. Acceptable —
// the user chose to edit their copy; don't guard it here.
@@ -275,6 +280,130 @@ export async function writeSectionApi(rawBody, roots) {
return { ok: true, written: writes.map((write) => write.layer), skipped };
}
+/**
+ * Stage a recoverable multi-file section transaction. New and original bytes
+ * live beside each target so rename/copy never crosses filesystems. The caller
+ * journals `targets` before commit and owns final cleanup.
+ */
+export async function stageSectionTransaction(rawBody, roots, transactionId, options = {}) {
+ const { writes, skipped } = await prepareSectionWrites(rawBody, roots);
+ return stagePreparedWrites(writes, skipped, transactionId, options);
+}
+
+export async function stageFrontmatterTransaction(rawBody, roots, transactionId) {
+ const body = parseJson(rawBody);
+ const { conceptId, key, layers, value } = body;
+ if (typeof conceptId !== "string" || typeof key !== "string" || !Array.isArray(layers) || !layers.length) {
+ throw httpError(400, "Provide conceptId, key, and layers");
+ }
+ if (key === "updated" || key === "override") throw httpError(400, `Frontmatter field ${key} is resolver-managed`);
+ const expectedValues = body.expectedValues ?? {};
+ const writes = [];
+ const skipped = [];
+ for (const layer of layers) {
+ const root = roots.get(layer);
+ if (!root) { skipped.push({ layer, reason: "source is not locally writable" }); continue; }
+ let target = null;
+ let stat = null;
+ for (const ext of root.kind === "files" ? FILES_EXTENSIONS.filter((item) => item !== ".txt") : [".md"]) {
+ const candidate = resolveLayerFile(`${layer}/${conceptId}${ext}`, roots);
+ try { stat = await fsp.stat(candidate.abs); } catch { stat = null; }
+ if (stat?.isFile()) { target = candidate; break; }
+ }
+ if (!target || !stat) { skipped.push({ layer, reason: "no writable frontmatter document" }); continue; }
+ const originalText = await fsp.readFile(target.abs, "utf8");
+ const currentValue = readFrontmatterValue(originalText, key);
+ if (stableScalar(currentValue) !== stableScalar(expectedValues[layer])) {
+ throw httpError(409, `${layer}/${conceptId}${target.ext} changed after this discrepancy loaded. Reload it before resolving — nothing was written.`);
+ }
+ const text = replaceFrontmatterValue(originalText, key, value);
+ writes.push({ layer, abs: target.abs, text, mode: stat.mode });
+ }
+ if (skipped.length) throw httpError(409, `Nothing was changed. ${skipped.map((item) => `${item.layer}: ${item.reason}`).join("; ")}`);
+ return stagePreparedWrites(writes, skipped, transactionId);
+}
+
+async function stagePreparedWrites(writes, skipped, transactionId, options = {}) {
+ const targets = [];
+ try {
+ for (const [index, write] of writes.entries()) {
+ const suffix = `.contextcake-${transactionId}-${index}`;
+ const staged = `${write.abs}${suffix}.new`;
+ const backup = `${write.abs}${suffix}.bak`;
+ await fsp.writeFile(staged, write.text, { encoding: "utf8", flag: "wx", mode: write.mode & 0o777 });
+ await fsp.copyFile(write.abs, backup, fs.constants.COPYFILE_EXCL);
+ targets.push({ layer: write.layer, path: write.abs, staged, backup });
+ }
+ } catch (error) {
+ await cleanupTargets(targets);
+ throw error;
+ }
+
+ async function commit() {
+ const changed = [];
+ try {
+ for (const [index, target] of targets.entries()) {
+ await options.beforeReplace?.(index, target);
+ await fsp.rename(target.staged, target.path);
+ changed.push(target);
+ }
+ return changed.map((target) => target.layer);
+ } catch (error) {
+ try {
+ for (const target of changed.reverse()) await fsp.copyFile(target.backup, target.path);
+ } catch (rollbackError) {
+ const combined = new Error(`${error.message}; rollback failed: ${rollbackError.message}`);
+ combined.code = "RecoveryRequired";
+ throw combined;
+ }
+ throw error;
+ }
+ }
+
+ async function rollback() {
+ for (const target of targets) await fsp.copyFile(target.backup, target.path);
+ }
+
+ async function cleanup() { await cleanupTargets(targets); }
+ return { targets, skipped, commit, rollback, cleanup };
+}
+
+async function cleanupTargets(targets) {
+ await Promise.all(targets.flatMap((target) => [target.staged, target.backup].map((file) => fsp.unlink(file).catch(() => {}))));
+}
+
+function readFrontmatterValue(text, key) {
+ if (!text.startsWith("---\n")) return undefined;
+ const end = text.indexOf("\n---", 4);
+ if (end === -1) return undefined;
+ for (const line of text.slice(4, end).split(/\r?\n/)) {
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
+ if (match?.[1] === key) return parseScalar(match[2].trim());
+ }
+ return undefined;
+}
+
+function replaceFrontmatterValue(text, key, value) {
+ if (!text.startsWith("---\n")) throw httpError(409, "This document has no writable frontmatter");
+ const end = text.indexOf("\n---", 4);
+ if (end === -1) throw httpError(409, "This document has malformed frontmatter");
+ const before = text.slice(4, end).split(/\r?\n/);
+ const index = before.findIndex((line) => line.startsWith(`${key}:`));
+ if (index === -1) throw httpError(409, `Frontmatter field ${key} no longer exists`);
+ before[index] = `${key}: ${renderScalar(value)}`;
+ return `---\n${before.join("\n")}\n---${text.slice(end + 4)}`;
+}
+
+function parseScalar(value) {
+ if (value.startsWith("[") && value.endsWith("]")) return value.slice(1, -1).split(",").map((part) => part.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
+ return value.replace(/^['"]|['"]$/g, "");
+}
+function renderScalar(value) {
+ if (Array.isArray(value)) return `[${value.map((item) => JSON.stringify(String(item))).join(", ")}]`;
+ return JSON.stringify(String(value));
+}
+function stableScalar(value) { return JSON.stringify(value); }
+
// Replace the body of the section identified by `key`, keeping its heading.
// Mirrors the OKF parser's key derivation ({#anchor} or normalized heading).
// `refreshUpdatedTo` (YYYY-MM-DD) rewrites an authored `updated=` attr on that
diff --git a/packages/core/src/mcp-server.mjs b/packages/core/src/mcp-server.mjs
index f5b4880f..dba2887d 100755
--- a/packages/core/src/mcp-server.mjs
+++ b/packages/core/src/mcp-server.mjs
@@ -12,6 +12,8 @@ import path from "node:path";
import readline from "node:readline";
import { isNewerDay } from "./conflict-policy.mjs";
import { resolveConcept } from "./resolver.mjs";
+import { createConflictResolutionLog } from "./conflict-resolutions.mjs";
+import { fingerprint } from "./discrepancies.mjs";
import { searchConcepts, searchCaptures } from "./search.mjs";
import { buildSources } from "./sources/index.mjs";
import { isTraversal } from "./sources/okf-local.mjs";
@@ -59,6 +61,7 @@ if ((args.capture || args.telemetry) && !liveLayer) {
}
const layerByName = new Map(layers.map((layer) => [layer.name, layer]));
+const discrepancyDecisions = runtime ? createConflictResolutionLog(runtime.manifestPath) : null;
const serverInfo = { name: "contextcake", version: "0.5.0" };
const serverInstructions = [
"Consult ContextCake before answering project-specific questions.",
@@ -478,10 +481,34 @@ async function readFileTool({ concept_id, layer }) {
const resolved = await resolveConcept(id, layers);
if (!resolved) throw new Error(`Concept not found in any layer: ${id}`);
+ await decorateDiscrepancyDisposition(resolved);
emitTelemetry({ event: "read", concept: id, layer: resolved.contributors[0]?.layer ?? null });
return { ...resolved, markdown: assembleMarkdown(resolved) };
}
+async function decorateDiscrepancyDisposition(resolved) {
+ const decisions = discrepancyDecisions ? await discrepancyDecisions.list() : [];
+ for (const section of resolved.sections) {
+ if (!section.conflicts?.length) continue;
+ const discrepancyId = `section_content::${resolved.id}::${section.key}`;
+ const history = decisions.filter((row) => row.discrepancyId === discrepancyId || row.conflictId === `${resolved.id}::${section.key}`);
+ const latest = history.at(-1);
+ const contributions = [
+ { source: section.sourceLayer, fingerprint: fingerprint(section.content) },
+ ...section.conflicts.map((item) => ({ source: item.layer, fingerprint: fingerprint(item.content) })),
+ ];
+ const recorded = latest?.contributorFingerprints ?? [];
+ const unchanged = recorded.length === contributions.length
+ && recorded.map((item) => `${item.source}:${item.fingerprint}`).sort().every((value, index) => value === contributions.map((item) => `${item.source}:${item.fingerprint}`).sort()[index]);
+ section.discrepancy = {
+ id: discrepancyId,
+ status: latest?.action === "acknowledge" && unchanged ? "acknowledged" : latest ? "reopened" : "needs_review",
+ ...(latest?.id ? { decisionId: latest.id } : {}),
+ ...(latest?.reasonCode ? { reasonCode: latest.reasonCode } : {}),
+ };
+ }
+}
+
async function listConcepts({ type } = {}) {
const byId = new Map();
for (const source of layers) {
diff --git a/packages/core/src/resolver.mjs b/packages/core/src/resolver.mjs
index 68f8aad9..05eb1528 100644
--- a/packages/core/src/resolver.mjs
+++ b/packages/core/src/resolver.mjs
@@ -92,6 +92,26 @@ export function mergeConcepts(contributors) {
}
}
+ // Frontmatter still resolves by precedence, exactly as before. The additive
+ // conflict list gives inspection surfaces the values that lost without
+ // changing the effective object or treating a singly-defined field as a
+ // disagreement. `updated` and `override` are resolver mechanics, not domain
+ // facts a person should reconcile.
+ const frontmatterConflicts = [];
+ const frontmatterKeys = new Set(active.flatMap((c) => Object.keys(c.frontmatter)));
+ for (const key of frontmatterKeys) {
+ if (key === "updated" || key === "override") continue;
+ const definitions = active
+ .filter((c) => Object.prototype.hasOwnProperty.call(c.frontmatter, key))
+ .map((c) => ({ layer: c.layer, level: c.level, updated: c.updated, value: c.frontmatter[key] }));
+ if (definitions.length < 2) continue;
+ const signatures = new Set(definitions.map((item) => stableValue(item.value)));
+ if (signatures.size < 2) continue;
+ const winnerLayer = frontmatterProvenance[key];
+ const winner = definitions.find((item) => item.layer === winnerLayer);
+ frontmatterConflicts.push({ key, winner, contributions: definitions });
+ }
+
// Per-section winner: highest level wins (vertical precedence). Display order
// follows first appearance in precedence order, so a higher layer's section
// ordering leads. Dissenters are collected per section for honest-conflict output.
@@ -147,7 +167,20 @@ export function mergeConcepts(contributors) {
};
});
- return { frontmatter, frontmatterProvenance, sections };
+ return {
+ frontmatter,
+ frontmatterProvenance,
+ ...(frontmatterConflicts.length ? { frontmatterConflicts } : {}),
+ sections,
+ };
+}
+
+function stableValue(value) {
+ if (Array.isArray(value)) return JSON.stringify(value);
+ if (value && typeof value === "object") {
+ return JSON.stringify(Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))));
+ }
+ return JSON.stringify(value);
}
// Higher level wins; equal level keeps the first contributor seen. Contributors
diff --git a/packages/core/src/service.mjs b/packages/core/src/service.mjs
index 6c3ba551..7ed4b564 100644
--- a/packages/core/src/service.mjs
+++ b/packages/core/src/service.mjs
@@ -37,8 +37,18 @@ import {
} from "./http-util.mjs";
import {
layerRootMap, listFilesApi, readFileApi, serveRawApi, writeFileApi, writeSectionApi,
+ stageSectionTransaction, stageFrontmatterTransaction,
} from "./layer-files.mjs";
-import { createConflictResolutionLog, trivialConflictReason } from "./conflict-resolutions.mjs";
+import {
+ createConflictResolutionLog, createDiscrepancyTransactionJournal, trivialConflictReason,
+} from "./conflict-resolutions.mjs";
+import { buildDiscrepancies } from "./discrepancies.mjs";
+import {
+ createDiscrepancyRuleStore, parseRuleDocument, serializeRuleDocument, suggestDiscrepancyRules,
+} from "./discrepancy-rules.mjs";
+import { createDiscrepancyPriorityStore } from "./discrepancy-priorities.mjs";
+import { resolveLiveLayer } from "./sources/git-sync.mjs";
+import { commitPathsWithMutation, push as pushGit } from "./sources/git-core.mjs";
import { indexEntryKeys, layerIdentity } from "./index-keys.mjs";
import {
classifyManifest,
@@ -317,6 +327,12 @@ export function createEngineService({
// Git-backed sources clone next to the manifest that declares them.
const CACHE_DIR = path.join(MANIFEST_DIR, ".cache", "repos");
const conflictResolutionLog = createConflictResolutionLog(MANIFEST);
+ const discrepancyTransactionJournal = createDiscrepancyTransactionJournal(MANIFEST);
+ const discrepancyRuleStore = createDiscrepancyRuleStore(MANIFEST);
+ const discrepancyPriorityStore = createDiscrepancyPriorityStore(MANIFEST);
+ let discrepancyRecovery = null;
+ let automaticTimer = null;
+ let automaticTail = Promise.resolve();
// ---- source lifecycle ------------------------------------------------------
//
@@ -495,6 +511,8 @@ export function createEngineService({
function close() {
closed = true;
+ clearTimeout(automaticTimer);
+ automaticTimer = null;
closeWatchers();
const prev = cache;
cache = null;
@@ -657,6 +675,7 @@ export function createEngineService({
entry.dirty = false;
scheduleFollowUp(entry);
}
+ scheduleAutomaticRules();
});
return entry;
}
@@ -957,6 +976,58 @@ export function createEngineService({
if (p === "/api/status") { json(res, 200, statusApi()); return true; }
if (p === "/api/resolve") { json(res, 200, await resolveOne(url.searchParams.get("concept"))); return true; }
if (p === "/api/resolve-all") { json(res, 200, await resolveAllApi(waitParam(url))); return true; }
+ if (p === "/api/discrepancies" && req.method === "GET") {
+ json(res, 200, await discrepanciesApi(waitParam(url)));
+ return true;
+ }
+ if (p === "/api/discrepancies" && req.method === "PATCH") {
+ if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; }
+ const id = url.searchParams.get("id");
+ if (!id) throw httpError(400, "Provide ?id=");
+ const body = parseJson(await readBody(req));
+ try {
+ const priority = await withManifestLockAsync(MANIFEST, () => discrepancyPriorityStore.set(id, body.priority));
+ json(res, 200, { id, priority });
+ } catch (error) { throw httpError(400, error.message); }
+ return true;
+ }
+ if (p === "/api/discrepancy-decisions" && req.method === "POST") {
+ if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; }
+ const rawBody = await readBody(req);
+ json(res, 200, await withManifestLockAsync(MANIFEST, () => decideDiscrepancyApi(rawBody)));
+ return true;
+ }
+ if (p === "/api/discrepancy-rules") {
+ if (req.method === "GET") { json(res, 200, await discrepancyRulesApi()); return true; }
+ if (req.method === "POST") {
+ if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; }
+ const body = parseJson(await readBody(req));
+ const rule = await withManifestLockAsync(MANIFEST, async () => {
+ // Re-evaluate evidence inside the same lock used by decisions so a
+ // reversal cannot race approval after the preview was shown.
+ const available = await discrepancyRulesApi();
+ const suggestion = available.suggestions.find((item) => item.id === body.suggestionId);
+ if (!suggestion) throw httpError(409, "That rule suggestion is no longer supported by three consistent decisions");
+ return discrepancyRuleStore.create(suggestion);
+ });
+ json(res, 200, { rule });
+ return true;
+ }
+ if (req.method === "PATCH") {
+ if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; }
+ const id = url.searchParams.get("id");
+ if (!id) throw httpError(400, "Provide ?id=");
+ const body = parseJson(await readBody(req));
+ try { json(res, 200, { rule: await withManifestLockAsync(MANIFEST, () => patchDiscrepancyRule(id, body)) }); }
+ catch (error) { throw httpError(error.status ?? 400, error.message); }
+ return true;
+ }
+ }
+ if (p === "/api/discrepancy-rules/promote" && req.method === "POST") {
+ if (!allowMutations) { json(res, 405, { error: "Mutations are disabled on this service" }); return true; }
+ json(res, 200, await promoteDiscrepancyRule(parseJson(await readBody(req))));
+ return true;
+ }
if (p === "/api/conflict-resolutions") {
if (req.method === "GET") { json(res, 200, { resolutions: await conflictResolutionLog.list() }); return true; }
if (req.method === "POST") {
@@ -1322,9 +1393,30 @@ export function createEngineService({
const { sources } = openSources();
const resolved = await resolveConcept(conceptId, sources);
if (!resolved) throw httpError(404, `Concept not found in any source: ${conceptId}`);
+ decorateResolvedDispositions(resolved, await conflictResolutionLog.list());
return resolved;
}
+ function decorateResolvedDispositions(resolved, decisions) {
+ for (const section of resolved.sections) {
+ if (!section.conflicts?.length) continue;
+ const id = `section_content::${resolved.id}::${section.key}`;
+ const latest = decisions.filter((row) => row.discrepancyId === id || row.conflictId === `${resolved.id}::${section.key}`).at(-1);
+ const current = [
+ { source: section.sourceLayer, fingerprint: createHash("sha256").update(section.content).digest("hex") },
+ ...section.conflicts.map((item) => ({ source: item.layer, fingerprint: createHash("sha256").update(item.content).digest("hex") })),
+ ].map((item) => `${item.source}:${item.fingerprint}`).sort();
+ const recorded = (latest?.contributorFingerprints ?? []).map((item) => `${item.source}:${item.fingerprint}`).sort();
+ const unchanged = recorded.length === current.length && recorded.every((value, index) => value === current[index]);
+ section.discrepancy = {
+ id,
+ status: latest?.action === "acknowledge" && unchanged ? "acknowledged" : latest ? "reopened" : "needs_review",
+ ...(latest?.id ? { decisionId: latest.id } : {}),
+ ...(latest?.reasonCode ? { reasonCode: latest.reasonCode } : {}),
+ };
+ }
+ }
+
/**
* Apply one conflict choice across every contributing local layer, then keep
* the original choices in the append-only decision log. The server derives
@@ -1347,6 +1439,7 @@ export function createEngineService({
let title;
let sectionHeading;
let supersedes;
+ let currentDiscrepancy = null;
if (resolutionId !== undefined) {
if (typeof resolutionId !== "string" || !resolutionId) throw httpError(400, "resolutionId must be a non-empty string");
@@ -1370,6 +1463,10 @@ export function createEngineService({
expectedContent = Object.fromEntries(contributions.map((item) => [item.layer, item.content]));
title = resolved.frontmatter?.title ?? conceptId;
sectionHeading = section.heading;
+ currentDiscrepancy = buildDiscrepancies([resolved], {
+ decisions: await conflictResolutionLog.list(), rules: await effectiveDiscrepancyRules(),
+ coverageComplete: false, sourceHealth: statusApi().sources,
+ }).discrepancies.find((item) => item.id === `section_content::${conceptId}::${sectionKey}`) ?? null;
}
const chosen = contributions.find((item) => item.layer === selectedLayer);
@@ -1383,19 +1480,35 @@ export function createEngineService({
}
}
- // Prove the log location is writable before source files are touched.
- await conflictResolutionLog.prepare();
- const write = await writeSectionApi(JSON.stringify({
+ // New open-conflict requests are a compatibility adapter over the v2
+ // discrepancy engine. This preserves the route and response envelope while
+ // gaining revision verification, recoverable writes, and schema-v2 history.
+ if (resolutionId === undefined) {
+ const discrepancyId = `section_content::${conceptId}::${sectionKey}`;
+ const discrepancy = currentDiscrepancy;
+ if (!discrepancy) throw httpError(409, "This conflict changed while it was being resolved. Reload before trying again.");
+ const result = await applyDiscrepancyDecision(discrepancy, {
+ discrepancyId, revision: discrepancy.revision, action: "choose_contribution", selectedSource: selectedLayer,
+ }, { methodOverride: method, reasonOverride: method === "automatic" ? safeReason : undefined });
+ return { ok: true, resolution: result.decision, written: result.written };
+ }
+
+ const transactionId = randomUUID();
+ const staged = await stageSectionTransaction(JSON.stringify({
conceptId,
sectionKey,
layers: contributions.map((item) => item.layer),
content: chosen.content,
expectedContent,
requireAll: true,
- }), fileRoots());
-
- const record = await conflictResolutionLog.append({
- id: randomUUID(),
+ }), fileRoots(), transactionId);
+ await conflictResolutionLog.prepare();
+ await discrepancyTransactionJournal.append({
+ id: transactionId, state: "prepared", preparedAt: new Date().toISOString(),
+ targets: staged.targets.map((target) => ({ path: target.path, staged: target.staged, backup: target.backup })),
+ });
+ const record = {
+ schemaVersion: 2, id: randomUUID(), discrepancyId: `section_content::${conceptId}::${sectionKey}`,
conflictId,
conceptId,
title: String(title),
@@ -1407,10 +1520,32 @@ export function createEngineService({
reason: method === "automatic" ? safeReason : `You chose the ${selectedLayer} answer.`,
actor: "local-user",
decidedAt: new Date().toISOString(),
+ discrepancyKind: "section_content", revision: `legacy-change:${supersedes}`,
+ action: "choose_contribution", transactionId, transactionState: "committed",
+ contributorFingerprints: contributions.map((item) => ({ source: item.layer, fingerprint: createHash("sha256").update(item.content).digest("hex") })),
+ writtenTargets: staged.targets.map((target) => ({ layer: target.layer, path: target.path })),
+ learningPattern: null, ruleAction: null,
...(supersedes ? { supersedes } : {}),
- });
- invalidateIndex();
- return { ok: true, resolution: record, written: write.written };
+ };
+ try {
+ const written = await staged.commit();
+ const saved = await conflictResolutionLog.append(record);
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "committed", committedAt: new Date().toISOString() });
+ await staged.cleanup();
+ invalidateIndex();
+ return { ok: true, resolution: saved, written };
+ } catch (error) {
+ try {
+ await staged.rollback();
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "rolled_back", rolledBackAt: new Date().toISOString(), error: error.message });
+ await staged.cleanup();
+ throw httpError(409, `Nothing was changed. ${error.message}`);
+ } catch (rollbackError) {
+ if (rollbackError.status === 409) throw rollbackError;
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "recovery_required", failedAt: new Date().toISOString(), error: `${error.message}; rollback failed: ${rollbackError.message}` });
+ throw httpError(500, `A write could not be rolled back automatically. Recovery is required: ${rollbackError.message}`);
+ }
+ }
}
// Resolve every indexed concept in one pass. The console's initial load calls
@@ -1439,10 +1574,307 @@ export function createEngineService({
errors.push({ concept: id, error: err.message });
}
}
+ const decisions = await conflictResolutionLog.list();
+ for (const concept of concepts) decorateResolvedDispositions(concept, decisions);
const pending = pinned.filter((p) => p.progress.status === "indexing").map((p) => p.source.name);
return { concepts, errors, indexing: pending.length > 0, indexingSources: pending };
}
+ async function discrepanciesApi(waitMs = 0) {
+ const resolved = await resolveAllApi(waitMs);
+ const status = statusApi();
+ const decisions = await conflictResolutionLog.list();
+ const [rules, priorities] = await Promise.all([effectiveDiscrepancyRules(), discrepancyPriorityStore.list()]);
+ const coverageComplete = !resolved.indexing
+ && status.sources.every((source) => source.status !== "error" && source.status !== "degraded" && source.status !== "indexing");
+ return {
+ ...buildDiscrepancies(resolved.concepts, {
+ decisions, rules, priorities, coverageComplete, sourceHealth: status.sources,
+ }),
+ indexing: resolved.indexing,
+ indexingSources: resolved.indexingSources,
+ errors: resolved.errors,
+ generation: status.generation,
+ };
+ }
+
+ async function discrepancyRulesApi() {
+ const rules = await effectiveDiscrepancyRules();
+ const decisions = await conflictResolutionLog.list();
+ return { rules, suggestions: suggestDiscrepancyRules(decisions, rules) };
+ }
+
+ function liveRuleFile() {
+ const selected = openSources().manifest.layers ?? [];
+ const live = resolveLiveLayer(selected, MANIFEST_DIR);
+ return live ? { ...live, relative: ".contextcake/discrepancy-rules.json", file: path.join(live.root, ".contextcake", "discrepancy-rules.json") } : null;
+ }
+
+ async function teamDiscrepancyRules() {
+ const live = liveRuleFile();
+ if (!live) return [];
+ try { return parseRuleDocument(await fsp.readFile(live.file, "utf8")); }
+ catch (error) { if (error.code === "ENOENT") return []; throw httpError(409, `Team discrepancy rules are unreadable: ${error.message}`); }
+ }
+
+ async function effectiveDiscrepancyRules() {
+ const [local, team] = await Promise.all([discrepancyRuleStore.list(), teamDiscrepancyRules()]);
+ const localById = new Map(local.map((rule) => [rule.id, rule]));
+ return [
+ ...team.map((rule) => localById.get(rule.id) ?? rule),
+ ...local.filter((rule) => !team.some((shared) => shared.id === rule.id)),
+ ];
+ }
+
+ async function patchDiscrepancyRule(id, body) {
+ try { return await discrepancyRuleStore.patch(id, body); }
+ catch (error) {
+ if (error.status !== 404) throw error;
+ const team = (await teamDiscrepancyRules()).find((rule) => rule.id === id);
+ if (!team) throw error;
+ // Enabling a promoted rule automatically is deliberately a per-profile,
+ // local decision. The shared file itself remains recommendation-only.
+ return discrepancyRuleStore.setLocalOverride(team, body);
+ }
+ }
+
+ async function promoteDiscrepancyRule(body) {
+ const rule = (await discrepancyRuleStore.list()).find((item) => item.id === body.id);
+ if (!rule) throw httpError(404, "Local discrepancy rule not found");
+ const live = liveRuleFile();
+ if (!live) throw httpError(409, "This profile has no writable live team layer");
+ const preview = {
+ id: rule.id, scope: "team", mode: "recommend", enabled: true,
+ match: rule.match, action: rule.action, evidenceDecisionIds: rule.evidenceDecisionIds,
+ createdAt: rule.createdAt, promotedAt: new Date().toISOString(),
+ };
+ if (body.confirm !== true) return { requiresConfirmation: true, preview, target: `${live.name}/${live.relative}` };
+ let previous = null;
+ try { previous = await fsp.readFile(live.file); } catch (error) { if (error.code !== "ENOENT") throw error; }
+ const current = previous ? parseRuleDocument(previous.toString("utf8")) : [];
+ const next = [...current.filter((item) => item.id !== preview.id), preview];
+ const nextText = serializeRuleDocument(next);
+ await commitPathsWithMutation(live.root, [live.relative], `chore: promote discrepancy rule ${rule.id}`, {
+ mutate: async () => {
+ await fsp.mkdir(path.dirname(live.file), { recursive: true });
+ await fsp.writeFile(live.file, nextText, { encoding: "utf8", mode: 0o600 });
+ },
+ rollback: async () => {
+ if (previous) await fsp.writeFile(live.file, previous);
+ else await fsp.unlink(live.file).catch(() => {});
+ },
+ author: live.profileName,
+ });
+ const pushed = await pushGit(live.root);
+ return { promoted: true, rule: preview, pushed: pushed.pushed === true, queued: pushed.queued === true };
+ }
+
+ function scheduleAutomaticRules() {
+ if (!allowMutations || closed || automaticTimer) return;
+ automaticTimer = setTimeout(() => {
+ automaticTimer = null;
+ automaticTail = automaticTail.then(runAutomaticRules).catch((error) => {
+ console.error(`contextcake: automatic discrepancy rules failed: ${error.message}`);
+ });
+ }, 50);
+ automaticTimer.unref?.();
+ }
+
+ async function runAutomaticRules() {
+ const { entries } = ensureIndexes();
+ if (entries.some(({ entry }) => entry.running || entry.followUp || entry.status !== "ready")) return;
+ const payload = await discrepanciesApi(0);
+ if (!payload.coverageComplete) return;
+ const decisions = await conflictResolutionLog.list();
+ for (const discrepancy of payload.discrepancies) {
+ if (discrepancy.status !== "auto_ready") continue;
+ const matches = discrepancy.matchingRules.filter((rule) => rule.mode === "automatic");
+ if (matches.length !== 1) continue;
+ const rule = matches[0];
+ if (decisions.some((row) => row.discrepancyId === discrepancy.id && row.revision === discrepancy.revision
+ && row.method === "automatic" && ["committed", "blocked", "not_required"].includes(row.transactionState))) continue;
+ const sourceHealthy = discrepancy.sourceHealth.every((health) => health && health.status === "ok");
+ const allWritable = discrepancy.contributions.every((item) => fileRoots().has(item.source));
+ if (!sourceHealthy || (rule.action.type === "prefer_source" && !allWritable)) continue;
+ const request = rule.action.type === "prefer_source"
+ ? { discrepancyId: discrepancy.id, revision: discrepancy.revision, action: "choose_contribution", selectedSource: rule.action.source, ruleId: rule.id }
+ : { discrepancyId: discrepancy.id, revision: discrepancy.revision, action: "acknowledge", reasonCode: rule.action.reasonCode, ruleId: rule.id };
+ await withManifestLockAsync(MANIFEST, async () => {
+ // Rule state can change while this job waits for the manifest lock.
+ // Re-read everything under the lock so disabling a rule, introducing
+ // an ambiguity, or changing a source generation always wins over a
+ // previously scheduled action.
+ const currentPayload = await discrepanciesApi(15_000);
+ if (!currentPayload.coverageComplete || currentPayload.indexing) return;
+ const current = currentPayload.discrepancies.find((item) => item.id === discrepancy.id);
+ if (!current || current.revision !== discrepancy.revision || current.status !== "auto_ready" || current.ruleConflict) return;
+ const currentMatches = current.matchingRules.filter((item) => item.mode === "automatic");
+ if (currentMatches.length !== 1 || currentMatches[0].id !== rule.id
+ || JSON.stringify(currentMatches[0].action) !== JSON.stringify(rule.action)) return;
+ if (!current.sourceHealth.every((health) => health && health.status === "ok")) return;
+ if (rule.action.type === "prefer_source" && !current.contributions.every((item) => fileRoots().has(item.source))) return;
+ try {
+ await applyDiscrepancyDecision(current, request, { methodOverride: "automatic" });
+ } catch (error) {
+ // The failure record participates in the same serialization boundary
+ // as successful decisions. Otherwise a manual decision can commit
+ // after this lock is released but before `blocked` is appended,
+ // leaving the failed automatic attempt as the misleading latest
+ // disposition for this revision.
+ await conflictResolutionLog.append({
+ schemaVersion: 2, id: randomUUID(), discrepancyId: discrepancy.id,
+ discrepancyKind: discrepancy.originalKind ?? discrepancy.kind, revision: discrepancy.revision,
+ action: request.action, method: "automatic", actor: "local-user", ruleId: rule.id,
+ transactionState: "blocked", reason: error.message, decidedAt: new Date().toISOString(),
+ contributorFingerprints: discrepancy.contributions.map((item) => ({ source: item.source, fingerprint: item.fingerprint })),
+ contributions: discrepancy.contributions.map((item) => ({ layer: item.source, level: item.level, content: item.value, updated: item.updated })),
+ });
+ }
+ });
+ return;
+ }
+ }
+
+ async function ensureDiscrepancyRecovery() {
+ if (!discrepancyRecovery) {
+ const roots = [...fileRoots().values()].map((entry) => entry.root);
+ discrepancyRecovery = conflictResolutionLog.list().then((decisions) => discrepancyTransactionJournal.recover(
+ roots,
+ decisions.filter((row) => row.transactionState === "committed").map((row) => row.transactionId).filter(Boolean),
+ )).catch((error) => {
+ discrepancyRecovery = null;
+ throw error;
+ });
+ }
+ return discrepancyRecovery;
+ }
+
+ async function decideDiscrepancyApi(rawBody, { methodOverride = null, reasonOverride = null } = {}) {
+ await ensureDiscrepancyRecovery();
+ const body = parseJson(rawBody);
+ const { discrepancyId, action, selectedSource, content, reasonCode, note, ruleId } = body;
+ if (ruleId !== undefined && methodOverride !== "automatic") {
+ throw httpError(400, "Rule authority is reserved for approved background actions");
+ }
+ if (typeof discrepancyId !== "string" || !discrepancyId) throw httpError(400, "Provide discrepancyId");
+ if (!["choose_contribution", "compose", "acknowledge"].includes(action)) throw httpError(400, "Unsupported discrepancy action");
+ // A file watcher may have invalidated the index between the review GET and
+ // this mutation. Decisions must re-resolve against a settled generation;
+ // treating an in-flight empty snapshot as "no longer open" is both
+ // misleading and can make a valid current-revision decision impossible.
+ const payload = await discrepanciesApi(15_000);
+ if (!payload.coverageComplete || payload.indexing) {
+ throw httpError(409, "Sources are still indexing. Wait for settled coverage before deciding.");
+ }
+ const discrepancy = payload.discrepancies.find((item) => item.id === discrepancyId);
+ if (!discrepancy) throw httpError(409, "This discrepancy is no longer open. Reload before deciding it.");
+ if (body.revision !== undefined && body.revision !== discrepancy.revision) {
+ throw httpError(409, "This discrepancy changed after you opened it. Reload before deciding it.");
+ }
+ return applyDiscrepancyDecision(discrepancy, body, { methodOverride, reasonOverride });
+ }
+
+ async function applyDiscrepancyDecision(discrepancy, body, { methodOverride = null, reasonOverride = null } = {}) {
+ await ensureDiscrepancyRecovery();
+ const { action, selectedSource, content, reasonCode, note, ruleId } = body;
+ if (ruleId !== undefined && methodOverride !== "automatic") {
+ throw httpError(400, "Rule authority is reserved for approved background actions");
+ }
+ if (!discrepancy || body.revision !== discrepancy.revision) {
+ throw httpError(409, "This discrepancy changed after you opened it. Reload before deciding it.");
+ }
+ const allowedReasons = new Set(["different_scopes", "temporary_migration", "source_specific_authority", "other"]);
+ if (action === "acknowledge" && !allowedReasons.has(reasonCode)) throw httpError(400, "Choose why this scoped difference should remain");
+ const chosen = action === "choose_contribution"
+ ? discrepancy.contributions.find((item) => item.source === selectedSource)
+ : null;
+ if (action === "choose_contribution" && !chosen) throw httpError(400, "Choose one of this discrepancy's contributing sources");
+ if (action === "compose" && typeof content !== "string") throw httpError(400, "Provide reconciled content");
+
+ const transactionId = randomUUID();
+ const now = new Date().toISOString();
+ const originalKind = discrepancy.originalKind ?? discrepancy.kind;
+ const previousDecision = discrepancy.history?.at(-1) ?? null;
+ const decision = {
+ schemaVersion: 2,
+ id: randomUUID(), discrepancyId: discrepancy.id,
+ ...(discrepancy.legacyId ? { conflictId: discrepancy.legacyId } : {}),
+ conceptId: discrepancy.conceptId, title: discrepancy.conceptTitle,
+ discrepancyKind: originalKind, sectionKey: originalKind === "section_content" ? discrepancy.key : undefined,
+ sectionHeading: discrepancy.label, fieldKey: originalKind === "frontmatter_value" ? discrepancy.key : undefined,
+ revision: discrepancy.revision, action,
+ conceptType: discrepancy.conceptType, owner: discrepancy.owner, priority: discrepancy.priority,
+ contributions: discrepancy.contributions.map((item) => ({ layer: item.source, level: item.level, content: item.value, updated: item.updated })),
+ contributorFingerprints: discrepancy.contributions.map((item) => ({ source: item.source, fingerprint: item.fingerprint })),
+ chosen: chosen ? { layer: chosen.source, level: chosen.level, content: chosen.value, updated: chosen.updated } : null,
+ method: methodOverride ?? "manual", actor: "local-user", decidedAt: now,
+ reason: reasonOverride ?? (action === "acknowledge" ? reasonCode : action === "compose" ? "You wrote a reconciled answer." : `You chose the ${selectedSource} answer.`),
+ ...(reasonCode ? { reasonCode } : {}), ...(typeof note === "string" && note.trim() ? { note: note.trim() } : {}),
+ ...(ruleId ? { ruleId } : {}), transactionId,
+ ...(previousDecision ? { supersedes: previousDecision.id, supersededDecisionId: previousDecision.id } : {}),
+ ...(action === "compose" ? { reconciledContent: content } : {}),
+ learningPattern: {
+ kind: originalKind, conceptType: discrepancy.conceptType, key: discrepancy.key,
+ sources: discrepancy.contributions.map((item) => item.source).sort(),
+ },
+ ruleAction: action === "choose_contribution"
+ ? { type: "prefer_source", source: selectedSource }
+ : action === "acknowledge" ? { type: "acknowledge", reasonCode } : null,
+ };
+
+ if (action === "acknowledge") {
+ decision.transactionState = "not_required";
+ decision.writtenTargets = [];
+ return { ok: true, decision: await conflictResolutionLog.append(decision), written: [] };
+ }
+ if (originalKind === "broken_link") throw httpError(409, "Open the source file to repair this link, or acknowledge the scoped difference.");
+
+ const value = action === "compose" ? content : chosen.value;
+ const writableSources = discrepancy.contributions.map((item) => item.source).filter((source) => fileRoots().has(source));
+ if (writableSources.length === 0) throw httpError(409, "None of this discrepancy's contributors is locally writable. Open the source files to resolve it.");
+ let staged;
+ if (originalKind === "frontmatter_value") {
+ staged = await stageFrontmatterTransaction(JSON.stringify({
+ conceptId: discrepancy.conceptId, key: discrepancy.key,
+ layers: writableSources, value,
+ expectedValues: Object.fromEntries(discrepancy.contributions.map((item) => [item.source, item.value])),
+ }), fileRoots(), transactionId);
+ } else {
+ staged = await stageSectionTransaction(JSON.stringify({
+ conceptId: discrepancy.conceptId, sectionKey: discrepancy.key,
+ layers: writableSources, content: value,
+ expectedContent: Object.fromEntries(discrepancy.contributions.map((item) => [item.source, item.value])), requireAll: true,
+ }), fileRoots(), transactionId);
+ }
+ const journalTargets = staged.targets.map((target) => ({ path: target.path, staged: target.staged, backup: target.backup }));
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "prepared", preparedAt: now, targets: journalTargets });
+ try {
+ const written = await staged.commit();
+ decision.transactionState = "committed";
+ decision.writtenTargets = staged.targets.map((target) => ({ layer: target.layer, path: target.path }));
+ const saved = await conflictResolutionLog.append(decision);
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "committed", committedAt: new Date().toISOString() });
+ await staged.cleanup();
+ invalidateIndex();
+ return { ok: true, decision: saved, written };
+ } catch (error) {
+ if (error.code !== "RecoveryRequired") {
+ try {
+ await staged.rollback();
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "rolled_back", rolledBackAt: new Date().toISOString(), error: error.message });
+ await staged.cleanup();
+ throw httpError(409, `Nothing was changed. ${error.message}`);
+ } catch (rollbackError) {
+ if (rollbackError.status === 409) throw rollbackError;
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "recovery_required", failedAt: new Date().toISOString(), error: `${error.message}; rollback failed: ${rollbackError.message}` });
+ throw httpError(500, `A write could not be rolled back automatically. Recovery is required: ${rollbackError.message}`);
+ }
+ }
+ await discrepancyTransactionJournal.append({ id: transactionId, state: "recovery_required", failedAt: new Date().toISOString(), error: error.message });
+ throw httpError(500, `A write could not be rolled back automatically. Recovery is required: ${error.message}`);
+ }
+ }
+
// ---- settings ---------------------------------------------------------------
function getSettingsApi() {
@@ -2018,5 +2450,12 @@ export function createEngineService({
});
}
+ // Recovery is a startup responsibility, not something deferred until a user
+ // happens to open the Discrepancy Center. Failure remains visible through
+ // the journal and is retried before any later decision.
+ queueMicrotask(() => ensureDiscrepancyRecovery().catch((error) => {
+ console.error(`contextcake: discrepancy transaction recovery requires attention: ${error.message}`);
+ }));
+
return { handleRequest, close, getSources, reload, setTokens };
}
diff --git a/packages/core/tests/discrepancies.test.mjs b/packages/core/tests/discrepancies.test.mjs
new file mode 100644
index 00000000..d3f74014
--- /dev/null
+++ b/packages/core/tests/discrepancies.test.mjs
@@ -0,0 +1,115 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { buildDiscrepancies, discrepancyRevision, fingerprint } from "../src/discrepancies.mjs";
+import { serializeRuleDocument, suggestDiscrepancyRules } from "../src/discrepancy-rules.mjs";
+import { mergeConcepts } from "../src/resolver.mjs";
+
+const concept = {
+ id: "decisions/db",
+ contributors: [
+ { layer: "team", level: 2, updated: "2026-08-01" },
+ { layer: "company", level: 0, updated: "2026-07-01" },
+ ],
+ frontmatter: { title: "Database", type: "decision", owner: "Platform" },
+ frontmatterConflicts: [{
+ key: "owner",
+ winner: { layer: "team", level: 2, updated: "2026-08-01", value: "Platform" },
+ contributions: [
+ { layer: "team", level: 2, updated: "2026-08-01", value: "Platform" },
+ { layer: "company", level: 0, updated: "2026-07-01", value: "Architecture" },
+ ],
+ }],
+ sections: [{
+ key: "choice", heading: "## Choice {#choice}", content: "Use Postgres. See [[runbooks/missing]].",
+ sourceLayer: "team", sourceUpdated: "2026-08-01",
+ conflicts: [{ layer: "company", updated: "2026-07-01", content: "Use MySQL." }],
+ }],
+};
+
+test("builds section, frontmatter, and settled broken-link discrepancies", () => {
+ const result = buildDiscrepancies([concept], { coverageComplete: true });
+ assert.deepEqual(result.discrepancies.map((item) => item.kind).sort(), ["broken_link", "frontmatter_value", "section_content"]);
+ const section = result.discrepancies.find((item) => item.originalKind === "section_content");
+ assert.equal(section.owner, "Platform");
+ assert.equal(section.status, "needs_review");
+ assert.equal(section.contributions.length, 2);
+});
+
+test("does not manufacture broken links with incomplete coverage", () => {
+ const result = buildDiscrepancies([concept], { coverageComplete: false });
+ assert.equal(result.discrepancies.some((item) => item.kind === "broken_link"), false);
+});
+
+test("an acknowledged discrepancy reopens when a contributor fingerprint changes", () => {
+ const decisions = [{
+ schemaVersion: 2, id: "d1", discrepancyId: "section_content::decisions/db::choice", action: "acknowledge",
+ contributorFingerprints: [
+ { source: "team", fingerprint: fingerprint("old") },
+ { source: "company", fingerprint: fingerprint("Use MySQL.") },
+ ],
+ }];
+ const result = buildDiscrepancies([concept], { decisions, coverageComplete: false });
+ const section = result.discrepancies.find((item) => item.originalKind === "section_content");
+ assert.equal(section.kind, "changed_after_decision");
+ assert.equal(section.status, "reopened");
+});
+
+test("suggestions need three distinct, consistent decisions", () => {
+ const pattern = { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] };
+ const rows = ["a", "b", "c"].map((id) => ({
+ schemaVersion: 2, id: `decision-${id}`, discrepancyId: `section_content::${id}::choice`,
+ method: "manual", learningPattern: pattern, ruleAction: { type: "prefer_source", source: "team" },
+ }));
+ assert.equal(suggestDiscrepancyRules(rows.slice(0, 2)).length, 0);
+ const [suggestion] = suggestDiscrepancyRules(rows);
+ assert.equal(suggestion.evidenceCount, 3);
+ assert.equal(suggestion.action.source, "team");
+});
+
+test("frontmatter detection excludes resolver mechanics and singly defined fields", () => {
+ const merged = mergeConcepts([
+ { layer: "team", level: 2, updated: "2026-08-02", frontmatter: { owner: "Platform", updated: "2026-08-02", override: "merge", solo: "normal" }, sections: [] },
+ { layer: "company", level: 0, updated: "2026-07-01", frontmatter: { owner: "Architecture", updated: "2026-07-01", override: "full" }, sections: [] },
+ ]);
+ assert.deepEqual(merged.frontmatterConflicts.map((item) => item.key), ["owner"]);
+});
+
+test("revisions are stable across contribution order and change with fingerprints", () => {
+ const contributions = [
+ { source: "team", level: 2, fingerprint: fingerprint("a") },
+ { source: "company", level: 0, fingerprint: fingerprint("b") },
+ ];
+ assert.equal(discrepancyRevision(contributions), discrepancyRevision([...contributions].reverse()));
+ assert.notEqual(discrepancyRevision(contributions), discrepancyRevision([{ ...contributions[0], fingerprint: fingerprint("changed") }, contributions[1]]));
+});
+
+test("a reversal disqualifies that discrepancy from learned evidence", () => {
+ const pattern = { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] };
+ const rows = ["a", "b", "c"].flatMap((id) => [{
+ schemaVersion: 2, id: `${id}-1`, discrepancyId: id, method: "manual", learningPattern: pattern,
+ ruleAction: { type: "prefer_source", source: "team" },
+ }, ...(id === "c" ? [{
+ schemaVersion: 2, id: `${id}-2`, discrepancyId: id, method: "manual", learningPattern: pattern,
+ ruleAction: { type: "prefer_source", source: "company" }, supersedes: `${id}-1`,
+ }] : [])]);
+ assert.equal(suggestDiscrepancyRules(rows).length, 0);
+});
+
+test("conflicting matching rules disable automation and surface the ambiguity", () => {
+ const match = { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] };
+ const rules = ["team", "company"].map((source) => ({ id: source, scope: "local", mode: "automatic", enabled: true, match, action: { type: "prefer_source", source } }));
+ const item = buildDiscrepancies([concept], { rules, coverageComplete: false }).discrepancies.find((row) => row.originalKind === "section_content");
+ assert.equal(item.ruleConflict, true);
+ assert.equal(item.status, "needs_review");
+});
+
+test("serialized shared rules contain structural metadata only", () => {
+ const text = serializeRuleDocument([{
+ id: "r1", scope: "local", mode: "automatic", enabled: true,
+ match: { kind: "section_content", conceptType: "decision", key: "choice", sources: ["company", "team"] },
+ action: { type: "prefer_source", source: "team" }, evidenceDecisionIds: ["d1", "d2", "d3"],
+ note: "secret note", content: "secret source content", prompt: "secret prompt",
+ }]);
+ assert.equal(text.includes("secret"), false);
+ assert.match(text, /"mode": "recommend"/);
+});
diff --git a/packages/core/tests/discrepancy-transactions.test.mjs b/packages/core/tests/discrepancy-transactions.test.mjs
new file mode 100644
index 00000000..1664383b
--- /dev/null
+++ b/packages/core/tests/discrepancy-transactions.test.mjs
@@ -0,0 +1,96 @@
+import assert from "node:assert/strict";
+import fsp from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { stageSectionTransaction } from "../src/layer-files.mjs";
+import { createDiscrepancyTransactionJournal } from "../src/conflict-resolutions.mjs";
+
+const document = (value) => `---\ntype: decision\ntitle: Database\n---\n\n# Database\n\n## Choice {#choice}\n\n${value}\n`;
+
+async function fixture() {
+ const dir = await fsp.mkdtemp(path.join(os.tmpdir(), "cc-discrepancy-tx-"));
+ const roots = new Map();
+ for (const [name, value] of [["team", "Postgres"], ["company", "MySQL"]]) {
+ const root = path.join(dir, name);
+ await fsp.mkdir(root);
+ await fsp.writeFile(path.join(root, "database.md"), document(value), { mode: 0o644 });
+ roots.set(name, { root, kind: "okf-local" });
+ }
+ return { dir, roots };
+}
+
+test("a failure at every replacement position restores the full original write set", async (t) => {
+ for (const failAt of [0, 1]) {
+ await t.test(`replacement ${failAt + 1}`, async () => {
+ const { dir, roots } = await fixture();
+ try {
+ const original = await Promise.all([...roots.values()].map(({ root }) => fsp.readFile(path.join(root, "database.md"), "utf8")));
+ const staged = await stageSectionTransaction(JSON.stringify({
+ conceptId: "database", sectionKey: "choice", layers: ["team", "company"],
+ content: "SQLite", expectedContent: { team: "Postgres", company: "MySQL" }, requireAll: true,
+ }), roots, `failure-${failAt}`, { beforeReplace(index) { if (index === failAt) throw new Error("injected replacement failure"); } });
+ await assert.rejects(staged.commit(), /injected replacement failure/);
+ const after = await Promise.all([...roots.values()].map(({ root }) => fsp.readFile(path.join(root, "database.md"), "utf8")));
+ assert.deepEqual(after, original);
+ await staged.cleanup();
+ } finally { await fsp.rm(dir, { recursive: true, force: true }); }
+ });
+ }
+});
+
+test("startup recovery restores prepared targets and records rolled_back", async () => {
+ const { dir, roots } = await fixture();
+ try {
+ const journal = createDiscrepancyTransactionJournal(path.join(dir, "manifest.json"));
+ const target = path.join(dir, "team", "database.md");
+ const backup = `${target}.bak`;
+ const staged = `${target}.new`;
+ const original = await fsp.readFile(target, "utf8");
+ await fsp.writeFile(backup, original);
+ await fsp.writeFile(staged, document("new"));
+ await fsp.writeFile(target, document("partially replaced"));
+ await journal.append({ id: "tx-incomplete", state: "prepared", targets: [{ path: target, backup, staged }] });
+ assert.deepEqual(await journal.recover([...roots.values()].map((entry) => entry.root)), ["tx-incomplete"]);
+ assert.equal(await fsp.readFile(target, "utf8"), original);
+ assert.equal((await journal.list()).at(-1).state, "rolled_back");
+ } finally { await fsp.rm(dir, { recursive: true, force: true }); }
+});
+
+test("startup recovery preserves a write confirmed by the committed decision log", async () => {
+ const { dir, roots } = await fixture();
+ try {
+ const journal = createDiscrepancyTransactionJournal(path.join(dir, "manifest.json"));
+ const target = path.join(dir, "team", "database.md");
+ const backup = `${target}.bak`;
+ const staged = `${target}.new`;
+ await fsp.writeFile(backup, document("original"));
+ await fsp.writeFile(staged, document("stale staged bytes"));
+ await fsp.writeFile(target, document("committed"));
+ await journal.append({ id: "tx-confirmed", state: "prepared", targets: [{ path: target, backup, staged }] });
+
+ assert.deepEqual(await journal.recover([...roots.values()].map((entry) => entry.root), ["tx-confirmed"]), []);
+ assert.equal(await fsp.readFile(target, "utf8"), document("committed"));
+ await assert.rejects(fsp.stat(staged), { code: "ENOENT" });
+ await assert.rejects(fsp.stat(backup), { code: "ENOENT" });
+ assert.equal((await journal.list()).at(-1).state, "committed");
+ } finally { await fsp.rm(dir, { recursive: true, force: true }); }
+});
+
+test("failed startup recovery records recovery_required and rejects", async () => {
+ const { dir, roots } = await fixture();
+ try {
+ const journal = createDiscrepancyTransactionJournal(path.join(dir, "manifest.json"));
+ const target = path.join(dir, "team", "database.md");
+ await journal.append({
+ id: "tx-broken", state: "prepared",
+ targets: [{ path: target, backup: `${target}.missing-backup`, staged: `${target}.missing-stage` }],
+ });
+
+ await assert.rejects(
+ journal.recover([...roots.values()].map((entry) => entry.root)),
+ /Recovery is required for tx-broken/,
+ );
+ assert.equal((await journal.list()).at(-1).state, "recovery_required");
+ } finally { await fsp.rm(dir, { recursive: true, force: true }); }
+});
diff --git a/packages/core/tests/service-test.sh b/packages/core/tests/service-test.sh
index ea1b539a..d78abc18 100755
--- a/packages/core/tests/service-test.sh
+++ b/packages/core/tests/service-test.sh
@@ -201,6 +201,48 @@ grep -q 'AGREED.' "$TMP/m2/merge-me.md" && pass "changed decision reused the sav
[ "$(curl -s "${AUTH[@]}" "$BASE/api/conflict-resolutions" | JQ 'String(d.resolutions.length)')" = "2" ] && pass "changed decision appends instead of rewriting history" || fail "changed decision did not append"
code 405 "$(C -X POST -H 'content-type: application/json' -d '{}' "$BASE2/api/conflict-resolutions")" "resolution respects the service mutation gate"
+echo "professional discrepancy API: revision guard, acknowledgement, and transactional choice"
+printf -- '---\ntype: decision\ntitle: Governed\nowner: Platform\n---\n\n# Governed\n\n## Pick {#pick}\n\nteam answer\n' > "$TMP/bundle/governed.md"
+printf -- '---\ntype: decision\ntitle: Governed\nowner: Architecture\n---\n\n# Governed\n\n## Pick {#pick}\n\ncompany answer\n' > "$TMP/m2/governed.md"
+DID=""
+for _ in $(seq 1 60); do
+ DSET="$(curl -s "${AUTH[@]}" "$BASE/api/discrepancies?wait=15000")"
+ DID="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "governed")?.id ?? ""' <<<"$DSET")"
+ [ -n "$DID" ] && break
+ sleep 0.1
+done
+DREV="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "governed")?.revision ?? ""' <<<"$DSET")"
+[ -n "$DID" ] && [ -n "$DREV" ] && pass "unified API detects the section discrepancy" || fail "section discrepancy missing ($DSET)"
+code 409 "$(C -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"discrepancyId\":\"$DID\",\"revision\":\"stale\",\"action\":\"choose_contribution\",\"selectedSource\":\"t\"}" "$BASE/api/discrepancy-decisions")" "stale discrepancy revision is refused"
+code 400 "$(C -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"discrepancyId\":\"$DID\",\"revision\":\"$DREV\",\"action\":\"choose_contribution\",\"selectedSource\":\"t\",\"ruleId\":\"spoofed-rule\"}" "$BASE/api/discrepancy-decisions")" "manual callers cannot claim automatic rule authority"
+DDEC="$(curl -s -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"discrepancyId\":\"$DID\",\"revision\":\"$DREV\",\"action\":\"choose_contribution\",\"selectedSource\":\"t\"}" "$BASE/api/discrepancy-decisions")"
+[ "$(JQ '`${d.decision.schemaVersion}:${d.decision.transactionState}`' <<<"$DDEC")" = "2:committed" ] && pass "new endpoint records a committed schema-v2 decision" || fail "v2 decision failed ($DDEC)"
+grep -q 'team answer' "$TMP/m2/governed.md" && pass "transactional choice reached every writable contributor" || fail "transactional choice missed a contributor"
+code 405 "$(C -X POST -H 'content-type: application/json' -d '{}' "$BASE2/api/discrepancy-decisions")" "discrepancy decisions respect the mutation gate"
+
+printf -- '# Scoped\n\n## Pick {#pick}\n\nlocal scope\n' > "$TMP/bundle/scoped.md"
+printf -- '# Scoped\n\n## Pick {#pick}\n\nteam scope\n' > "$TMP/m2/scoped.md"
+SID=""
+for _ in $(seq 1 60); do
+ SSET="$(curl -s "${AUTH[@]}" "$BASE/api/discrepancies?wait=15000")"
+ SID="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "scoped")?.id ?? ""' <<<"$SSET")"
+ [ -n "$SID" ] && break
+ sleep 0.1
+done
+SREV="$(JQ 'd.discrepancies.find((x) => x.originalKind === "section_content" && x.conceptId === "scoped")?.revision ?? ""' <<<"$SSET")"
+SACK="$(curl -s -X POST "${AUTH[@]}" -H 'content-type: application/json' -d "{\"discrepancyId\":\"$SID\",\"revision\":\"$SREV\",\"action\":\"acknowledge\",\"reasonCode\":\"different_scopes\"}" "$BASE/api/discrepancy-decisions")"
+[ "$(JQ 'd.decision.transactionState' <<<"$SACK")" = "not_required" ] && pass "acknowledgement records a reason without a file transaction" || fail "acknowledgement failed ($SACK)"
+grep -q 'team scope' "$TMP/m2/scoped.md" && pass "acknowledgement leaves source content unchanged" || fail "acknowledgement mutated source content"
+printf -- '# Scoped\n\n## Pick {#pick}\n\nteam scope changed\n' > "$TMP/m2/scoped.md"
+SSTATUS=""
+for _ in $(seq 1 60); do
+ SOPEN="$(curl -s "${AUTH[@]}" "$BASE/api/discrepancies?wait=15000")"
+ SSTATUS="$(JQ 'd.discrepancies.find((x) => x.conceptId === "scoped")?.status ?? ""' <<<"$SOPEN")"
+ [ "$SSTATUS" = "reopened" ] && break
+ sleep 0.1
+done
+[ "$SSTATUS" = "reopened" ] && pass "acknowledged discrepancy reopens when a fingerprint changes" || fail "acknowledgement did not reopen ($SOPEN)"
+
printf -- '# Format only\n\n## Pick {#pick}\n\nUse **Postgres** for writes.\n' > "$TMP/bundle/format-only.md"
printf -- '# Format only\n\n## Pick {#pick}\n\nUse postgres for writes\n' > "$TMP/m2/format-only.mdx"
AUTO="$(curl -s -X POST "${AUTH[@]}" -H 'content-type: application/json' -d '{"conceptId":"format-only","sectionKey":"pick","selectedLayer":"t","method":"automatic"}' "$BASE/api/conflict-resolutions")"
diff --git a/specs/contextcake-conflict-resolution/spec.md b/specs/contextcake-conflict-resolution/spec.md
index b9d26428..0177697f 100644
--- a/specs/contextcake-conflict-resolution/spec.md
+++ b/specs/contextcake-conflict-resolution/spec.md
@@ -1,5 +1,8 @@
# Conflict Resolution
+**Status:** Shipped predecessor. The additive professional workflow and governed
+learning contract lives in `specs/contextcake-discrepancy-center/spec.md`.
+
ContextCake turns surfaced section conflicts into quick, reversible decisions. It resolves only meaning-preserving differences automatically, asks one clear question when judgment is required, and keeps an append-only local record of every applied choice.
## Problem Statement
diff --git a/specs/contextcake-discrepancy-center/spec.md b/specs/contextcake-discrepancy-center/spec.md
new file mode 100644
index 00000000..382911dd
--- /dev/null
+++ b/specs/contextcake-discrepancy-center/spec.md
@@ -0,0 +1,68 @@
+# ContextCake Discrepancy Center
+
+ContextCake turns structural disagreement across a selected source profile into
+decision-ready evidence, safe resolution, and user-approved reusable policy. It
+extends the shipped conflict-resolution workflow without changing resolver
+precedence or inferring semantic contradictions.
+
+## Problem Statement
+
+The shipped resolver and Conflicts view expose same-concept, same-section text
+differences, but professional teams also need metadata disagreements, broken
+knowledge links, decisions that reopen after sources change, inspectable diffs,
+and a trustworthy explanation of what an action will write. Repeated human
+choices should make the product faster without granting an opaque model the
+authority to change knowledge.
+
+## User Stories
+
+- As an engineer, I can understand why a discrepancy exists and what currently wins.
+- As a technical leader, I can filter actionable discrepancies by kind, owner, source, status, and priority.
+- As a reviewer, I can choose an existing answer, compose a reconciled answer, or acknowledge an intentional scoped difference.
+- As an auditor, I can inspect every original answer and every superseding decision.
+- As a user, I can approve an explainable rule recommendation and separately decide whether it may run automatically.
+- As a teammate, I can promote a local recommendation without silently enabling automation for other people.
+
+## Acceptance Criteria
+
+- [x] WHEN contributors define the same section differently THE SYSTEM SHALL emit a `section_content` discrepancy with every contribution and the deterministic winner reason.
+- [x] WHEN contributors define the same authored frontmatter field differently THE SYSTEM SHALL emit a `frontmatter_value` discrepancy, excluding `updated` and `override`.
+- [x] WHEN an outgoing OKF link has no target in a healthy, settled selected profile THE SYSTEM SHALL emit a `broken_link` discrepancy.
+- [x] WHEN a recorded contributor fingerprint changes after a decision THE SYSTEM SHALL reopen it as `changed_after_decision`, even if the authored date did not change.
+- [x] WHEN sources are indexing or unavailable THE SYSTEM SHALL report incomplete coverage and SHALL NOT manufacture broken-link findings.
+- [x] WHEN a discrepancy is resolved THE SYSTEM SHALL support choosing a contribution, composing a reconciled value, or acknowledging a scoped difference.
+- [x] WHEN a scoped difference is acknowledged THE SYSTEM SHALL write no source content and SHALL require a reason code.
+- [x] WHEN any source write or decision-log append fails THE SYSTEM SHALL restore every changed target or explicitly report recovery-required state.
+- [x] WHEN an incomplete prepared transaction is found at startup THE SYSTEM SHALL restore its original files and append a rollback outcome.
+- [x] WHEN a v1 conflict-resolution record is read THE SYSTEM SHALL preserve and display it without rewriting the file.
+- [x] WHEN three distinct discrepancies receive the same structural manual decision THE SYSTEM SHALL offer an evidence-backed local rule suggestion.
+- [x] WHEN a rule is approved THE SYSTEM SHALL default it to recommendation mode; automatic mode requires a separate explicit action.
+- [x] WHEN multiple matching rules disagree THE SYSTEM SHALL perform no automatic action.
+- [x] WHEN a promoted team rule reaches another user THE SYSTEM SHALL remain a recommendation until that user explicitly enables local automation.
+- [x] WHEN the Web Demo performs a decision THE SYSTEM SHALL identify it as a simulation, write no files, run no automatic rules, and state that history resets on reload.
+- [x] WHEN an agent reads an acknowledged discrepancy THE SYSTEM SHALL expose additive disposition metadata without removing the original conflicts.
+- [x] WHEN the primary review workflow is used with a keyboard THE SYSTEM SHALL expose the same evidence, actions, focus state, status, and errors as pointer use.
+
+## Out of Scope
+
+- Semantic entity matching, embeddings, or model-inferred contradictions.
+- Cross-source incoming links not exposed by a source adapter.
+- Hosted policy infrastructure, approval routing, or new shared telemetry fields.
+- Treating missing concepts or singly defined fields as discrepancies.
+- Learning or replaying free-form reconciled content.
+
+## Defaults and Boundaries
+
+- Resolver precedence, per-section conflict retention, and explicit suppression remain unchanged.
+- Priority defaults to `unassigned`; ContextCake does not invent business severity.
+- Local rules outrank shared recommendations only for the current profile; conflicts between rules disable automation.
+- Rules store structural metadata and decision ids only, never source content, notes, prompts, or excerpts.
+- Shared rules use the existing live-layer git trust boundary and remain recommendations for other users.
+
+## Dependencies
+
+- Existing resolver provenance and conflict output.
+- Existing manifest/profile lock and guarded section writers.
+- Existing append-only conflict-resolution log.
+- Existing live-layer git mutation and offline queue.
+- Existing console Review surface and generated demo bundle.