fix: enforce rectification evidence gate

This commit is contained in:
Jesse_Chen
2026-07-22 00:33:41 +08:00
parent dc0077eb89
commit b8ed740e71
4 changed files with 115 additions and 46 deletions
+6
View File
@@ -173,3 +173,9 @@ Prevention: resolve the packaged repository root by default, deploy only an atte
The journey trigger copied `birth_time` into `reported_birth_time` and then raised `reported_birth_time_is_immutable` on a later account edit. This both changed the meaning of the user's original declaration and surfaced as a generic `PATCH /api/account` 500.
Prevention: keep reported declarations editable, never derive them from active/candidate time, repair impossible `period_only`/`unknown` rows, and enforce the source/time consistency constraint in the database.
## ERR-089 | Technical readiness bypassed the three-event business gate on a first turn | mitigated 2026-07-21
A real production scan could return `ready_for_confirmation` before any historical evidence existed. The application then built a `confirming` first turn, while the database correctly accepts only an `active` first turn, producing a delayed `action_conflict` after the fee reservation and calculation.
Prevention: gate every technical packet by `MINIMUM_SCOREABLE_EVENTS`; until three effective, historical, scoreable events exist, persist no result ID and expose only an active `pending_validation` turn. Keep the production smoke in `smoke_only` until this path completes against the deployed SHA.
@@ -316,6 +316,19 @@ function actionsFor(status: "active" | "confirming") {
return ["answer", "pause", "abandon"] as const;
}
function confirmationGatedPacket(
packet: RectificationTechnicalPacket,
scoreableEventCount: number,
): RectificationTechnicalPacket {
if (packet.candidate.status !== "ready_for_confirmation"
|| scoreableEventCount >= MINIMUM_SCOREABLE_EVENTS) return packet;
return {
...packet,
candidate: { ...packet.candidate, status: "pending_validation" },
useBoundary: `当前候选仍需至少 ${MINIMUM_SCOREABLE_EVENTS} 条时间明确、可评分的真实经历验证,不能作为已经校正完成的出生分钟。`,
};
}
function turnFromNarrative(input: {
readonly caseId: string;
readonly turnVersion: number;
@@ -671,21 +684,23 @@ export function createConversationalRectificationService(
evidence: projected.evidence,
preserveCandidateRange: true,
});
const gatedPacket = confirmationGatedPacket(computed.packet, 0);
const narrative = await generateRectificationNarrative({
phase: "first",
packet: computed.packet,
packet: gatedPacket,
generator: ports.narrativeGenerator,
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: computed.resultId,
packet: gatedPacket,
resultId: null,
iteration: 0,
forceCollecting: true,
});
const firstTurn = turnFromNarrative({
caseId: actionId,
turnVersion: 0,
pendingConsultationQuestion,
packet: computed.packet,
packet: gatedPacket,
narrative,
evidence: projected.evidence,
});
@@ -847,21 +862,23 @@ export function createConversationalRectificationService(
privateCandidate: null,
evidence: [],
});
const gatedPacket = confirmationGatedPacket(computed.packet, 0);
const narrative = await generateRectificationNarrative({
phase: "first",
packet: computed.packet,
packet: gatedPacket,
generator: ports.narrativeGenerator,
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: computed.resultId,
packet: gatedPacket,
resultId: null,
iteration: 0,
forceCollecting: true,
});
const firstTurn = turnFromNarrative({
caseId,
turnVersion: 0,
pendingConsultationQuestion: command.pendingConsultationQuestion ?? null,
packet: computed.packet,
packet: gatedPacket,
narrative,
evidence: [],
});
@@ -977,6 +994,10 @@ export function createConversationalRectificationService(
privateCandidate: null,
evidence: allScoreable,
});
const gatedPacket = confirmationGatedPacket(
computed.packet,
allScoreable.length,
);
const replacement = evidence[0];
if (!replacement) throw new ConversationalRectificationError("invalid_command");
const resetReason: CorrectionResetReason | null = directionChange
@@ -993,7 +1014,7 @@ export function createConversationalRectificationService(
correctionReset: { packet: computed.packet, reason: resetReason },
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
packet: gatedPacket,
resultId: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: true,
@@ -1012,12 +1033,12 @@ export function createConversationalRectificationService(
return publicTurn(saved);
}
const phase = computed.packet.candidate.status === "ready_for_confirmation"
const phase = gatedPacket.candidate.status === "ready_for_confirmation"
? "final" as const
: "intermediate" as const;
const narrative = await generateRectificationNarrative({
phase,
packet: computed.packet,
packet: gatedPacket,
generator: ports.narrativeGenerator,
});
if (narrative.fallbackUsed) {
@@ -1027,12 +1048,12 @@ export function createConversationalRectificationService(
domain: command.domain,
directionChange: false,
correctionReset: {
packet: computed.packet,
packet: gatedPacket,
reason: "validation_fallback",
},
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
packet: gatedPacket,
resultId: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: true,
@@ -1051,15 +1072,18 @@ export function createConversationalRectificationService(
return publicTurn(saved);
}
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: computed.resultId,
packet: gatedPacket,
resultId: gatedPacket.candidate.status === "ready_for_confirmation"
? computed.resultId
: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: gatedPacket.candidate.status !== "ready_for_confirmation",
});
const turn = turnFromNarrative({
caseId: command.caseId,
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
packet: computed.packet,
packet: gatedPacket,
narrative,
evidence: [...current.eventEvidence, ...evidence],
});
@@ -1113,17 +1137,21 @@ export function createConversationalRectificationService(
privateCandidate: current.privateCandidate,
evidence: allScoreable,
});
const phase = computed.packet.candidate.status === "ready_for_confirmation"
const gatedPacket = confirmationGatedPacket(
computed.packet,
allScoreable.length,
);
const phase = gatedPacket.candidate.status === "ready_for_confirmation"
? "final" as const
: "intermediate" as const;
const narrative = await generateRectificationNarrative({
phase,
packet: computed.packet,
packet: gatedPacket,
generator: ports.narrativeGenerator,
});
const plateauCount = nextPlateauCount(current.privateCandidate, computed.packet);
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
const completionReason = rangeCompletionReason({
packet: computed.packet,
packet: gatedPacket,
scoreableEventCount: allScoreable.length,
plateauCount,
});
@@ -1131,24 +1159,27 @@ export function createConversationalRectificationService(
...narrative,
narrative: evidenceProgressNarrative({
previousCandidate: current.privateCandidate,
packet: computed.packet,
packet: gatedPacket,
newEvidence: evidence,
scoreableEventCount: allScoreable.length,
willContinue: completionReason === null
&& computed.packet.candidate.status !== "ready_for_confirmation",
&& gatedPacket.candidate.status !== "ready_for_confirmation",
}),
} satisfies RectificationNarrativeResult;
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: computed.resultId,
packet: gatedPacket,
resultId: gatedPacket.candidate.status === "ready_for_confirmation"
? computed.resultId
: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
notes: convergenceNotes(current.privateCandidate, plateauCount),
forceCollecting: gatedPacket.candidate.status !== "ready_for_confirmation",
});
const narratedTurn = turnFromNarrative({
caseId: command.caseId,
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
packet: computed.packet,
packet: gatedPacket,
narrative: narrativeWithProgress,
evidence: [...current.eventEvidence, ...evidence],
});
@@ -183,6 +183,7 @@ function createSyntheticBackend(options: {
legacy?: boolean;
allowNewCaseCreation?: boolean;
packetFailure?: boolean;
initialReady?: boolean;
packetEvidenceCalls?: string[][];
} = {}) {
const cases = new Map<string, LoadedConversationalRectificationCase>();
@@ -372,7 +373,8 @@ function createSyntheticBackend(options: {
async buildTechnicalPacket(input) {
if (options.packetFailure) throw new Error("synthetic packet failure");
options.packetEvidenceCalls?.push(input.evidence.map((item) => item.id));
const ready = input.evidence.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification").length >= 3;
const ready = options.initialReady === true
|| input.evidence.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification").length >= 3;
const packet = technicalPacket(ready);
if (input.preserveCandidateRange && input.privateCandidate?.rangeStart && input.privateCandidate.rangeEnd) {
return {
@@ -407,6 +409,27 @@ function createSyntheticBackend(options: {
};
}
test("technical readiness cannot bypass the three-event confirmation gate on the first turn", async () => {
const backend = createSyntheticBackend({ initialReady: true });
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
deploymentSha,
});
const turn = await post(handler, {
type: "start",
actionId: caseId,
pendingConsultationQuestion: originalQuestion,
});
assert.equal(turn.status, "active");
assert.equal(turn.candidate.status, "pending_validation");
assert.equal(turn.actions.includes("confirm"), false);
assert.equal(backend.cases.get(caseId)?.privateCandidate.resultId, null);
assert.equal(backend.cases.get(caseId)?.privateCandidate.workingState?.phase, "collecting_evidence");
});
async function post(
handler: (request: Request) => Promise<Response>,
command: Record<string, unknown>,
@@ -501,17 +501,17 @@ test("clear historical evidence is extracted, scored, narrated, recapped, and at
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月毕业,并在2022年3月去外地工作",
answer: "2018年6月毕业,2020年3月去外地工作2022年8月结婚",
});
assert.equal(value.counts().packetBuilds, 2);
assert.equal(turn.status, "confirming");
assert.equal(turn.candidate.status, "ready_for_confirmation");
assert.equal(turn.turnVersion, 1);
assert.equal(turn.evidenceRecap.length, 2);
assert.equal(turn.evidenceRecap.length, 3);
const saved = value.cases.get(startActionId)?.row.eventEvidence ?? [];
assert.equal(saved.length, 2);
assert.ok(saved.every((item) => item.rawText === "2021年7月毕业,并在2022年3月去外地工作"));
assert.equal(saved.length, 3);
assert.ok(saved.every((item) => item.rawText === "2018年6月毕业,2020年3月去外地工作2022年8月结婚"));
assert.ok(saved.every((item) => item.scoreable === true));
assert.ok(value.events.includes("score-packet"));
});
@@ -642,7 +642,7 @@ test("every non-confirmable correction rescans the declared range and withdraws
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2019年7月开始第一份工作",
answer: "2018年6月毕业,2019年7月开始第一份工作2021年3月搬家",
});
assert.equal(prior.status, "confirming", scenario.name);
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
@@ -673,11 +673,12 @@ test("every non-confirmable correction rescans the declared range and withdraws
assert.equal(corrected.evidenceRecap.some((item) => item.id === replacement.id), true, scenario.name);
assert.equal(value.counts().reserveCount, 1, scenario.name);
if (scenario.name === "narrative validation fallback") {
assert.deepEqual(value.packetEvidenceIds.at(-1), [replacement.id]);
} else {
assert.deepEqual(value.packetEvidenceIds.at(-1), []);
}
const expectedPacketEvidenceIds = stored.eventEvidence
.filter((item) => item.id !== wrongId
&& item.scoreable === true
&& item.extractionStatus !== "needs_clarification")
.map((item) => item.id);
assert.deepEqual(value.packetEvidenceIds.at(-1), expectedPacketEvidenceIds);
}
});
@@ -686,7 +687,7 @@ test("a clear one-to-one correction can form a new confirmation candidate only a
await start(value, null);
await value.service.answer(userId, {
type: "answer", caseId: startActionId, actionId: answerActionId,
turnVersion: 0, answer: "2019年7月开始第一份工作",
turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作2021年3月搬家",
});
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
assert.ok(wrongId);
@@ -701,7 +702,14 @@ test("a clear one-to-one correction can form a new confirmation candidate only a
assert.ok(stored && replacementId);
assert.equal(value.packetPrivateCandidates.at(-1), null);
assert.deepEqual(value.packetEvidenceIds.at(-1), [replacementId]);
assert.deepEqual(
value.packetEvidenceIds.at(-1),
stored.eventEvidence
.filter((item) => item.id !== wrongId
&& item.scoreable === true
&& item.extractionStatus !== "needs_clarification")
.map((item) => item.id),
);
assert.equal(corrected.status, "confirming");
assert.equal(corrected.candidate.status, "ready_for_confirmation");
assert.equal(corrected.actions.includes("confirm"), true);
@@ -753,7 +761,7 @@ test("ordinary new evidence continues incrementally from the current candidate r
assert.deepEqual(value.packetPrivateCandidates, [
null,
{ rangeStart: "04:50", rangeEnd: "05:50", resultId: null },
{ rangeStart: "05:16", rangeEnd: "05:20", resultId },
{ rangeStart: "05:16", rangeEnd: "05:20", resultId: null },
]);
});
@@ -804,7 +812,8 @@ test("generic date uncertainty does not suppress clear historical evidence", asy
answer: "2021年7月毕业,具体日期不确定",
});
assert.equal(turn.status, "confirming");
assert.equal(turn.status, "active");
assert.equal(turn.candidate.status, "pending_validation");
assert.equal(value.counts().packetBuilds, 2);
assert.ok(value.events.includes("score-packet"));
assert.ok((value.cases.get(startActionId)?.row.eventEvidence ?? [])
@@ -824,9 +833,9 @@ test("a rejected professional narrative falls back safely while the first scorea
});
const stored = value.cases.get(startActionId)?.row;
assert.equal(turn.status, "confirming");
assert.equal(turn.candidate.status, "ready_for_confirmation");
assert.equal(stored?.privateCandidate.resultId, resultId);
assert.equal(turn.status, "active");
assert.equal(turn.candidate.status, "pending_validation");
assert.equal(stored?.privateCandidate.resultId, null);
assert.equal(stored?.validationReceipts.at(-1)?.fallbackUsed, true);
assert.match(turn.narrative, /已记录:|本轮区分重点/);
assert.doesNotMatch(turn.narrative, /候选没有推进|请稍后重试/);
@@ -1045,7 +1054,7 @@ test("receipt-first delayed retries replay the original answer, pause, abandon,
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月毕业,并在2022年3月去外地工作",
answer: "2018年6月毕业,2020年3月去外地工作2022年8月结婚",
};
return { command, first: await value.service.answer(userId, command) };
},
@@ -1082,7 +1091,7 @@ test("receipt-first delayed retries replay the original answer, pause, abandon,
caseId: startActionId,
actionId: answerActionId,
turnVersion: 0,
answer: "2021年7月毕业,并在2022年3月去外地工作",
answer: "2018年6月毕业,2020年3月去外地工作2022年8月结婚",
});
const command = {
type: "confirm" as const,
@@ -1138,7 +1147,7 @@ test("confirm delegates to the atomic store call, preserves the old baseline unt
await start(value, "请继续回答原来的事业问题");
const ready = await value.service.answer(userId, {
type: "answer", caseId: startActionId, actionId: answerActionId,
turnVersion: 0, answer: "2021年7月毕业,并在2022年3月去外地工作",
turnVersion: 0, answer: "2018年6月毕业,2020年3月去外地工作2022年8月结婚",
});
assert.equal(value.cases.get(startActionId)?.row.baselineActiveTime, "04:58");
const before = value.mutations.length;