From b8ed740e7179ac93bb7d1545ce6d95162232bb33 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 22 Jul 2026 00:33:41 +0800 Subject: [PATCH] fix: enforce rectification evidence gate --- docs/research/pre_work_error_ledger.md | 6 ++ .../orchestrator.ts | 81 +++++++++++++------ .../conversational-rectification-e2e.test.ts | 25 +++++- ...ational-rectification-orchestrator.test.ts | 49 ++++++----- 4 files changed, 115 insertions(+), 46 deletions(-) diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 865e8f76..04b0b14e 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -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. diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 778d4bbd..16bc3265 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -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], }); diff --git a/frontend/tests/conversational-rectification-e2e.test.ts b/frontend/tests/conversational-rectification-e2e.test.ts index e6078b0a..c086c5d3 100644 --- a/frontend/tests/conversational-rectification-e2e.test.ts +++ b/frontend/tests/conversational-rectification-e2e.test.ts @@ -183,6 +183,7 @@ function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreation?: boolean; packetFailure?: boolean; + initialReady?: boolean; packetEvidenceCalls?: string[][]; } = {}) { const cases = new Map(); @@ -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, command: Record, diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index 9acaf9f8..4c312736 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -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;