fix: enforce rectification evidence gate
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user