fix(web): persist rectification C/D answers on an append-only inference ledger
Independent Staging Quality Gate / validate (push) Failing after 4m0s
Independent Staging Quality Gate / publish (push) Has been skipped

Engine result rows stay immutable. Choice answers append transitions, and reads overlay the latest revision instead of patching the cached receipt.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-24 08:22:01 +08:00
parent 29750d3835
commit 909de8b884
14 changed files with 1701 additions and 65 deletions
@@ -5,8 +5,18 @@ import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-p
import {
answersFromEvidence,
applyAnswerToState,
applySupersedeAnswer,
buildInferenceState,
replayInferenceState,
} from "../src/lib/rectification-agentic/core/build-state.ts";
import {
composeInferenceReceipt,
} from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import {
decisionStateFingerprint,
posteriorMap,
} from "../src/lib/rectification-agentic/core/decision-fingerprint.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import { applyChoiceWithoutEvidence } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { HOLDOUT_MESSAGE_PREFIX } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { existsSync, readFileSync } from "node:fs";
@@ -346,6 +356,8 @@ test("C without new evidence updates the posterior immediately and D only marks
});
assert.equal(denied.applied, true);
assert.equal(denied.answerClass, "no");
assert.equal(denied.state.revision, state.revision + 1);
assert.notDeepEqual(posteriorMap(denied.state.candidates), posteriorMap(state.candidates));
assert.equal(denied.state.candidates.find((item) => item.id === "05:00")?.status, "eliminated");
assert.ok(denied.state.entropy < state.entropy);
assert.equal(denied.state.answered_probes.some((item) => item.semantic_key === conflict.semantic_key), true);
@@ -411,17 +423,367 @@ test("holdout and collection declines do not write a probe answer", () => {
assert.equal(collection.reason, "no_choice");
});
test("choice answers without new evidence patch inference_state in place instead of the candidate cache", () => {
test("choice answers append an inference transition instead of patching the candidate cache", () => {
const migration = readFileSync(
fileURLToPath(new URL("../supabase/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
fileURLToPath(new URL("../supabase/migrations/20260824010000_rectification_inference_transition_ledger.sql", import.meta.url)),
"utf8",
);
assert.match(migration, /create or replace function public\.patch_agentic_rectification_inference_state\(/);
assert.match(migration, /jsonb_set\(v_result\.decision_receipt, '\{inference_state\}', p_inference_state, true\)/);
assert.doesNotMatch(migration, /persist_agentic_rectification_candidate_v2/);
assert.match(migration, /create table if not exists public\.agentic_rectification_inference_transitions \(/);
assert.match(migration, /create or replace function public\.append_agentic_rectification_inference_transition\(/);
assert.match(migration, /agentic_rectification_revision_conflict/);
assert.match(migration, /agentic_rectification_stale_probe/);
assert.match(migration, /idempotency_key/);
assert.match(migration, /raise exception 'agentic_rectification_inference_patch_retired'/);
const appendSql = migration.slice(
migration.indexOf("create or replace function public.append_agentic_rectification_inference_transition("),
migration.indexOf("create or replace function public.get_agentic_rectification_latest_inference_transition("),
);
assert.doesNotMatch(appendSql, /update public\.agentic_rectification_results/);
assert.match(
migration,
/compose_agentic_rectification_decision_receipt\(p_case_id, v_cached\.id, v_cached\.decision_receipt\)/,
);
assert.match(
migration,
/compose_agentic_rectification_decision_receipt\(p_case_id, v_result_id, v_saved_decision_receipt\)/,
);
assert.match(
migration,
/compose_agentic_rectification_decision_receipt\(v_case\.id, v_result\.id, v_result\.decision_receipt\)/,
);
assert.doesNotMatch(migration, /and decision_state_fingerprint = /);
assert.equal(
existsSync(new URL("../db/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
existsSync(new URL("../db/migrations/20260824010000_rectification_inference_transition_ledger.sql", import.meta.url)),
false,
"business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)",
);
const retired = readFileSync(
fileURLToPath(new URL("../supabase/migrations/20260823020000_rectification_inference_choice_write.sql", import.meta.url)),
"utf8",
);
assert.match(retired, /patch_agentic_rectification_inference_state/);
});
function fingerprintOf(
caseId: string,
evidenceFp: string,
state: { candidate_set_id: string; revision: number; answered_probes: readonly { probe_id: string }[] },
): string {
return decisionStateFingerprint({
caseId,
evidenceLedgerFingerprint: evidenceFp,
candidateSetId: state.candidate_set_id,
inferenceRevision: state.revision,
answeredProbeIds: state.answered_probes.map((item) => item.probe_id),
scoringPolicyVersion: INFERENCE_ALGORITHM_VERSION,
});
}
type LedgerRow = {
revision: number;
probeId: string;
answerClass: string;
idempotencyKey: string;
inferenceState: ReturnType<typeof buildInferenceState>;
fingerprint: string;
};
function createLedger(seed: ReturnType<typeof buildInferenceState>) {
const rows: LedgerRow[] = [];
const evidenceFp = "e".repeat(64);
const caseId = "case-1";
let engineReceipt: Record<string, unknown> = { inference_state: seed };
return {
evidenceFp,
append(input: {
expectedRevision: number;
probeId: string;
openProbeId: string;
answerClass: "yes" | "weak_yes" | "no" | "unsure";
idempotencyKey: string;
apply: () => ReturnType<typeof buildInferenceState>;
}) {
const existing = rows.find((row) => row.idempotencyKey === input.idempotencyKey);
if (existing) {
return { idempotent: true, row: existing, receipt: composeInferenceReceipt(engineReceipt, {
resultId: "result-1",
revision: existing.revision,
probeId: existing.probeId,
reason: "choice",
decisionStateFingerprint: existing.fingerprint,
inferenceState: existing.inferenceState,
posteriorBefore: {},
posteriorAfter: posteriorMap(existing.inferenceState.candidates),
scoreDeltas: {},
}, "result-1") };
}
const current = rows.at(-1)?.revision ?? seed.revision;
if (input.expectedRevision !== current) {
const error = new Error("agentic_rectification_revision_conflict");
throw error;
}
if (input.probeId !== input.openProbeId) {
throw new Error("agentic_rectification_stale_probe");
}
const next = input.apply();
const fingerprint = fingerprintOf(caseId, evidenceFp, next);
const row: LedgerRow = {
revision: next.revision,
probeId: input.probeId,
answerClass: input.answerClass,
idempotencyKey: input.idempotencyKey,
inferenceState: next,
fingerprint,
};
rows.push(row);
return {
idempotent: false,
row,
receipt: composeInferenceReceipt(engineReceipt, {
resultId: "result-1",
revision: row.revision,
probeId: row.probeId,
reason: "choice",
decisionStateFingerprint: fingerprint,
inferenceState: next,
posteriorBefore: {},
posteriorAfter: posteriorMap(next.candidates),
scoreDeltas: {},
}, "result-1"),
};
},
reread() {
const latest = rows.at(-1);
if (!latest) return composeInferenceReceipt(engineReceipt, null, "result-1");
return composeInferenceReceipt(engineReceipt, {
resultId: "result-1",
revision: latest.revision,
probeId: latest.probeId,
reason: "choice",
decisionStateFingerprint: latest.fingerprint,
inferenceState: latest.inferenceState,
posteriorBefore: {},
posteriorAfter: posteriorMap(latest.inferenceState.candidates),
scoreDeltas: {},
}, "result-1");
},
rows,
};
}
test("evidence fingerprint can stay put while decision-state fingerprint and posterior change", () => {
const conflict = probe({
id: "p-cd",
domain: "career",
year: 2019,
gain: 0.4,
yesSupports: ["05:00"],
yesConflicts: ["05:10"],
});
const state = buildInferenceState({
range_start: "04:50",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 10 },
{ id: "05:10", time: "05:10", relative_support: 10 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "year" },
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
{ id: "e4", domain: "family", year: 2023, precision: "year" },
],
probes: [conflict],
});
const after = applyChoiceWithoutEvidence(state, {
choiceKey: "C",
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
});
assert.equal(after.applied, true);
assert.equal(after.state.revision, state.revision + 1);
const evidenceFp = "e".repeat(64);
const beforeFp = fingerprintOf("case-1", evidenceFp, state);
const afterFp = fingerprintOf("case-1", evidenceFp, after.state);
assert.equal(evidenceFp, "e".repeat(64));
assert.notEqual(afterFp, beforeFp);
const staleReceipt = { inference_state: state, display_allowed: true };
const composed = composeInferenceReceipt(staleReceipt, {
resultId: "result-1",
revision: after.state.revision,
probeId: conflict.id,
reason: "choice",
decisionStateFingerprint: afterFp,
inferenceState: after.state,
posteriorBefore: posteriorMap(state.candidates),
posteriorAfter: posteriorMap(after.state.candidates),
scoreDeltas: {},
}, "result-1");
assert.notDeepEqual(
(composed.inference_state as { candidates: unknown }).candidates,
(staleReceipt.inference_state as { candidates: unknown }).candidates,
);
assert.equal(composed.decision_state_fingerprint, afterFp);
});
test("duplicate D is idempotent, C then D supersedes, stale probes and stale revisions are rejected, replay matches", () => {
const conflict = probe({
id: "p-cd",
domain: "career",
year: 2019,
gain: 0.4,
yesSupports: ["05:00"],
yesConflicts: ["05:10"],
});
const other = probe({
id: "p-old",
domain: "relationship",
year: 2021,
gain: 0.2,
yesSupports: ["05:00"],
yesConflicts: ["05:10"],
split: "05:00|2021",
});
const state = buildInferenceState({
range_start: "04:50",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 10 },
{ id: "05:10", time: "05:10", relative_support: 10 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "year" },
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
{ id: "e4", domain: "family", year: 2023, precision: "year" },
],
probes: [conflict, other],
});
const ledger = createLedger(state);
const firstD = applyChoiceWithoutEvidence(state, {
choiceKey: "D",
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
});
assert.equal(firstD.applied, true);
const stale = applyChoiceWithoutEvidence(state, {
choiceKey: "D",
schema: { probe_id: other.id, semantic_key: other.semantic_key },
});
assert.equal(stale.reason, "stale_probe");
assert.deepEqual(posteriorMap(stale.state.candidates), posteriorMap(state.candidates));
assert.throws(
() => ledger.append({
expectedRevision: state.revision,
probeId: other.id,
openProbeId: conflict.id,
answerClass: "unsure",
idempotencyKey: `choice:${other.id}:unsure`,
apply: () => firstD.state,
}),
/stale_probe/,
);
const persisted = ledger.append({
expectedRevision: state.revision,
probeId: conflict.id,
openProbeId: conflict.id,
answerClass: "unsure",
idempotencyKey: `choice:${conflict.id}:unsure`,
apply: () => firstD.state,
});
assert.equal(persisted.idempotent, false);
assert.equal(persisted.row.revision, state.revision + 1);
const again = ledger.append({
expectedRevision: state.revision,
probeId: conflict.id,
openProbeId: conflict.id,
answerClass: "unsure",
idempotencyKey: `choice:${conflict.id}:unsure`,
apply: () => firstD.state,
});
assert.equal(again.idempotent, true);
assert.equal(again.row.revision, persisted.row.revision);
assert.equal(ledger.rows.length, 1);
const afterC = applyChoiceWithoutEvidence(state, {
choiceKey: "C",
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
});
const superseded = applyChoiceWithoutEvidence(afterC.state, {
choiceKey: "D",
schema: { probe_id: conflict.id, semantic_key: conflict.semantic_key },
});
assert.equal(superseded.reason, "superseded");
assert.equal(superseded.state.revision, afterC.state.revision + 1);
assert.equal(superseded.state.answered_probes.filter((item) => item.probe_id === conflict.id).length, 1);
assert.equal(superseded.state.answered_probes[0]?.answer_class, "unsure");
const keptC = applySupersedeAnswer(afterC.state, conflict.id, "unsure");
assert.equal(keptC.revision, superseded.state.revision);
const corrections = createLedger(state);
const writtenC = corrections.append({
expectedRevision: state.revision,
probeId: conflict.id,
openProbeId: conflict.id,
answerClass: "no",
idempotencyKey: `choice:${conflict.id}:no`,
apply: () => afterC.state,
});
const writtenD = corrections.append({
expectedRevision: afterC.state.revision,
probeId: conflict.id,
openProbeId: conflict.id,
answerClass: "unsure",
idempotencyKey: `supersede:${conflict.id}:unsure`,
apply: () => superseded.state,
});
assert.equal(writtenC.idempotent, false);
assert.equal(writtenD.idempotent, false);
assert.equal(writtenD.row.revision, writtenC.row.revision + 1);
assert.equal(corrections.rows.length, 2);
assert.equal(corrections.rows[0]?.answerClass, "no");
assert.equal(corrections.rows[1]?.answerClass, "unsure");
assert.throws(
() => ledger.append({
expectedRevision: state.revision,
probeId: conflict.id,
openProbeId: conflict.id,
answerClass: "no",
idempotencyKey: `choice:${conflict.id}:no`,
apply: () => afterC.state,
}),
/revision_conflict/,
);
const reread = ledger.reread();
assert.deepEqual(
posteriorMap((reread.inference_state as typeof firstD.state).candidates),
posteriorMap(firstD.state.candidates),
);
const replayed = replayInferenceState(state, firstD.state.answered_probes);
assert.deepEqual(posteriorMap(replayed.candidates), posteriorMap(firstD.state.candidates));
assert.equal(replayed.revision, firstD.state.revision);
const rescored = buildInferenceState({
range_start: "04:50",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 12 },
{ id: "05:10", time: "05:10", relative_support: 8 },
{ id: "05:04", time: "05:04", relative_support: 9 },
],
events: [
{ id: "e1", domain: "education", year: 2016, precision: "month" },
{ id: "e2", domain: "career", year: 2018, precision: "year" },
{ id: "e3", domain: "relationship", year: 2021, precision: "year" },
{ id: "e4", domain: "family", year: 2023, precision: "year" },
],
probes: [conflict, other],
previous: firstD.state,
});
assert.notEqual(rescored.candidate_set_id, firstD.state.candidate_set_id);
assert.equal(rescored.revision, firstD.state.revision);
assert.equal(
rescored.answered_probes.some((item) => item.probe_id === conflict.id && item.answer_class === "unsure"),
true,
);
});
@@ -368,7 +368,7 @@ test("batch preserves three independent items, parses all outcomes, and keeps it
);
});
test("resolve-focus C without new evidence patches inference_state on the latest result", async () => {
test("resolve-focus C without new evidence appends an inference transition", async () => {
const inference = buildInferenceState({
range_start: "04:50",
range_end: "05:10",
@@ -401,7 +401,7 @@ test("resolve-focus C without new evidence patches inference_state on the latest
});
const snapshot = candidateSnapshotFixture();
snapshot.decision_receipt = { ...snapshot.decision_receipt, inference_state: inference };
let patchedState: unknown = null;
let appendedState: unknown = null;
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture({
@@ -416,18 +416,24 @@ test("resolve-focus C without new evidence patches inference_state on the latest
option_c: "没有明显发生",
option_d: "不记得 / 不确定",
},
probe_id: "p-cd",
semantic_key: "career.2019",
},
}),
}),
}),
patch_agentic_rectification_inference_state: (_fn, args) => {
patchedState = args.p_inference_state;
append_agentic_rectification_inference_transition: (_fn, args) => {
appendedState = args.p_inference_state;
return {
transition_id: "99999999-9999-4999-8999-999999999999",
result_id: RESULT_ID,
revision: (args.p_expected_revision as number) + 1,
idempotent: false,
decision_state_fingerprint: args.p_decision_state_fingerprint,
decision_receipt: {
...snapshot.decision_receipt,
inference_state: args.p_inference_state,
decision_state_fingerprint: args.p_decision_state_fingerprint,
},
};
},
@@ -448,11 +454,19 @@ test("resolve-focus C without new evidence patches inference_state on the latest
assert.equal(resolved.status, "declined");
assert.equal(resolved.evidence_id, null);
assert.ok(resolved.inference_state);
assert.ok(patchedState);
const answers = (patchedState as { answered_probes?: Array<{ answer_class?: string; semantic_key?: string }> }).answered_probes ?? [];
assert.ok(appendedState);
const answers = (appendedState as { answered_probes?: Array<{ answer_class?: string; semantic_key?: string }> }).answered_probes ?? [];
assert.equal(answers.some((item) => item.semantic_key === "career.2019" && item.answer_class === "no"), true);
const appendCall = accounting.calls.find((call) => call.fn === "append_agentic_rectification_inference_transition");
assert.ok(appendCall);
assert.equal(appendCall.args.p_probe_id, "p-cd");
assert.equal(appendCall.args.p_reason, "choice");
assert.equal(
accounting.calls.some((call) => call.fn === "persist_agentic_rectification_candidate_v2"),
false,
);
assert.equal(
accounting.calls.some((call) => call.fn === "patch_agentic_rectification_inference_state"),
false,
);
});
+26 -10
View File
@@ -456,10 +456,26 @@ test("a failed opening does not let the next turn skip the server Skill load gat
});
test("same evidence + range fingerprints reuse the cached candidate snapshot", async () => {
const inputReceipt = {
receipt_version: "candidate-decision-receipt-v2",
policy_version: "rectification-candidate-policy-v2",
selection_allowed: true,
acceptance_allowed: true,
confirmation_allowed: false,
representative_candidate_id: null,
overall_confidence: "medium",
inference_state: { revision: 1, candidates: [{ id: "05:02", posterior_score: 10 }] },
};
const composedReceipt = {
...inputReceipt,
inference_state: { revision: 2, candidates: [{ id: "05:02", posterior_score: 18 }] },
decision_state_fingerprint: "a".repeat(64),
};
const accounting = fakeAccounting({
persist_agentic_rectification_candidate_v2: () => ({
...candidateSnapshotFixture(),
cached: true,
decision_receipt: composedReceipt,
}),
});
const cached = await persistV9Candidate(accounting.client, USER_ID, CASE_ID, {
@@ -472,15 +488,7 @@ test("same evidence + range fingerprints reuse the cached candidate snapshot", a
eventContractVersion: "rectification-event-contract-v2",
policyVersion: "rectification-candidate-policy-v2",
candidates: [{ candidateId: CANDIDATE_ID, rank: 1, time: "05:02", relativeSupport: 58, tiedMinuteCount: 2 }],
decisionReceipt: {
receipt_version: "candidate-decision-receipt-v2",
policy_version: "rectification-candidate-policy-v2",
selection_allowed: true,
acceptance_allowed: true,
confirmation_allowed: false,
representative_candidate_id: null,
overall_confidence: "medium",
},
decisionReceipt: inputReceipt,
executionLedger: [{ method: "d1-rashi", status: "executed" }],
});
assert.equal(cached.cached, true);
@@ -491,12 +499,20 @@ test("same evidence + range fingerprints reuse the cached candidate snapshot", a
assert.equal(call.args.p_skill_version, "9.0.0");
assert.equal(call.args.p_event_contract_version, "rectification-event-contract-v2");
assert.equal(call.args.p_decision_policy_version, "rectification-candidate-policy-v2");
assert.deepEqual(call.args.p_decision_receipt, cached.decisionReceipt);
assert.deepEqual(call.args.p_decision_receipt, inputReceipt);
assert.notDeepEqual(cached.decisionReceipt, inputReceipt);
assert.deepEqual(cached.decisionReceipt, composedReceipt);
assert.equal(
(cached.decisionReceipt.inference_state as { revision?: number }).revision,
2,
);
assert.equal(cached.decisionReceipt.decision_state_fingerprint, "a".repeat(64));
assert.deepEqual(call.args.p_execution_ledger, cached.executionLedger);
assert.equal("p_selection_allowed" in call.args, false);
assert.equal("p_confirmation_allowed" in call.args, false);
assert.equal("p_representative_time" in call.args, false);
assert.equal("p_margin_percent" in call.args, false);
assert.equal("p_decision_state_fingerprint" in call.args, false);
});
test("accept-candidate requires a server-persisted result; no tool means no minute", async () => {
@@ -664,4 +664,16 @@ test("rpc errors map to safe public views without leaking database text", async
const publicLegacy = mapRectificationRpcError(new Error("agentic_rectification_skill_identity_unverifiable"));
assert.equal(publicLegacy.status, 409);
assert.equal(publicLegacy.code, "skill_identity_unverifiable");
const staleProbe = mapRectificationRpcError(new Error("agentic_rectification_stale_probe"));
assert.equal(staleProbe.status, 409);
assert.equal(staleProbe.code, "stale_probe");
const revisionConflict = mapRectificationRpcError(new Error("agentic_rectification_revision_conflict"));
assert.equal(revisionConflict.status, 409);
assert.equal(revisionConflict.code, "revision_conflict");
const patchRetired = mapRectificationRpcError(new Error("agentic_rectification_inference_patch_retired"));
assert.equal(patchRetired.status, 409);
assert.equal(patchRetired.code, "inference_patch_retired");
});