fix(rectification): harden scoring and replay boundaries
This commit is contained in:
@@ -282,22 +282,19 @@ function currentRange(input: ConversationalRectificationPacketBuildInput) {
|
||||
}
|
||||
|
||||
function scoreableLifeEvents(evidence: readonly LifeEventEvidence[]): LifeEvent[] {
|
||||
return evidence.slice(-10).flatMap((item) => {
|
||||
return evidence.flatMap((item) => {
|
||||
if (item.scoreable !== true || !item.dateValue
|
||||
|| !(["day", "month", "year"] as const).includes(item.datePrecision as "day" | "month" | "year")) {
|
||||
return [];
|
||||
}
|
||||
const domain = item.domain === "family" ? "relationship"
|
||||
: item.domain === "other" ? null
|
||||
: item.domain;
|
||||
if (!domain) return [];
|
||||
if (item.domain === "family" || item.domain === "other") return [];
|
||||
return [{
|
||||
id: item.id,
|
||||
domain,
|
||||
domain: item.domain,
|
||||
precision: item.datePrecision as "day" | "month" | "year",
|
||||
date: item.dateValue,
|
||||
} as LifeEvent];
|
||||
});
|
||||
}).slice(-6);
|
||||
}
|
||||
|
||||
function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] {
|
||||
@@ -336,7 +333,6 @@ function mergeQuestionnaireScans(
|
||||
): RectificationQuestionnaire {
|
||||
const first = scans[0];
|
||||
if (!first) throw new ConversationalRectificationError("service_unavailable");
|
||||
if (scans.length === 1) return first;
|
||||
|
||||
const byTime = new Map<string, {
|
||||
sample: RectificationQuestionnaire["samples"][number];
|
||||
@@ -413,7 +409,7 @@ export async function buildProductionConversationalRectificationPacket(
|
||||
}
|
||||
const baseRange = currentRange(input);
|
||||
const events = scoreableLifeEvents(input.evidence as readonly LifeEventEvidence[]);
|
||||
const eventScore: CandidateResult | null = events.length > 0
|
||||
const eventScore: CandidateResult | null = events.length >= 3
|
||||
? await engine.scoreEvents({
|
||||
birthDate: input.declaredBirthInput.birthDate,
|
||||
startTime: baseRange.startTime,
|
||||
|
||||
+16
-16
@@ -590,10 +590,10 @@ begin
|
||||
if v_receipt.user_id is distinct from p_user_id
|
||||
or v_receipt.action_kind is distinct from 'save_turn'
|
||||
or v_receipt.expected_turn_version is distinct from p_expected_version
|
||||
or (v_receipt.request ? 'commandFingerprint'
|
||||
and v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint)
|
||||
or v_receipt.request_fingerprint is distinct from v_fingerprint then
|
||||
or (case when v_receipt.request ? 'commandFingerprint' then
|
||||
v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint
|
||||
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_receipt.response;
|
||||
@@ -756,10 +756,10 @@ begin
|
||||
if v_receipt.user_id is distinct from p_user_id
|
||||
or v_receipt.action_kind is distinct from 'pause'
|
||||
or v_receipt.expected_turn_version is distinct from p_expected_version
|
||||
or (v_receipt.request ? 'commandFingerprint'
|
||||
and v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint)
|
||||
or v_receipt.request_fingerprint is distinct from v_fingerprint then
|
||||
or (case when v_receipt.request ? 'commandFingerprint' then
|
||||
v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint
|
||||
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_receipt.response;
|
||||
@@ -890,10 +890,10 @@ begin
|
||||
if v_receipt.user_id is distinct from p_user_id
|
||||
or v_receipt.action_kind is distinct from 'abandon'
|
||||
or v_receipt.expected_turn_version is distinct from p_expected_version
|
||||
or (v_receipt.request ? 'commandFingerprint'
|
||||
and v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint)
|
||||
or v_receipt.request_fingerprint is distinct from v_fingerprint then
|
||||
or (case when v_receipt.request ? 'commandFingerprint' then
|
||||
v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint
|
||||
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_receipt.response;
|
||||
@@ -1037,10 +1037,10 @@ begin
|
||||
if v_receipt.user_id is distinct from p_user_id
|
||||
or v_receipt.action_kind is distinct from 'confirm'
|
||||
or v_receipt.expected_turn_version is distinct from p_expected_version
|
||||
or (v_receipt.request ? 'commandFingerprint'
|
||||
and v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint)
|
||||
or v_receipt.request_fingerprint is distinct from v_fingerprint then
|
||||
or (case when v_receipt.request ? 'commandFingerprint' then
|
||||
v_receipt.request ->> 'commandFingerprint'
|
||||
is distinct from p_command_fingerprint
|
||||
else v_receipt.request_fingerprint is distinct from v_fingerprint end) then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_receipt.response;
|
||||
|
||||
@@ -22,6 +22,8 @@ const confirmActionId = "00000000-0000-4000-8000-000000000706";
|
||||
const priorCaseId = "00000000-0000-4000-8000-000000000707";
|
||||
const resultId = "00000000-0000-4000-8000-000000000708";
|
||||
const laterActionId = "00000000-0000-4000-8000-000000000710";
|
||||
const secondAnswerActionId = "00000000-0000-4000-8000-000000000711";
|
||||
const thirdAnswerActionId = "00000000-0000-4000-8000-000000000712";
|
||||
|
||||
const declaredBirthInput = {
|
||||
source: "approximate" as const,
|
||||
@@ -92,10 +94,12 @@ function packet(ready = false): RectificationTechnicalPacket {
|
||||
};
|
||||
}
|
||||
|
||||
function validGenerator(events: string[]) {
|
||||
function validGenerator(events: string[], varyNarrative = false) {
|
||||
let generation = 0;
|
||||
return {
|
||||
modelId: "synthetic-rectification-model",
|
||||
async generate(prompt: string) {
|
||||
generation += 1;
|
||||
events.push("narrative");
|
||||
const request = JSON.parse(prompt) as {
|
||||
phase: "first" | "intermediate" | "final";
|
||||
@@ -113,6 +117,7 @@ function validGenerator(events: string[]) {
|
||||
"D1(Cancer)保持稳定。",
|
||||
"D9(Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。",
|
||||
"D10(Taurus / Libra)呈现分钟敏感差异,事业事件可区分 D10。",
|
||||
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
|
||||
request.phase === "final" ? "当前证据已形成候选总结。" : "请提供已经发生的真实事件,写明哪一年、哪一月以及发生了什么。",
|
||||
"这仅是候选,必须由你确认后才会替换当前排盘时间。",
|
||||
].join("");
|
||||
@@ -145,6 +150,8 @@ function harness(options: {
|
||||
readonly packetFailure?: Error;
|
||||
readonly completeFailures?: number;
|
||||
readonly releaseFailure?: boolean;
|
||||
readonly readyAfterEvidenceCount?: number;
|
||||
readonly varyNarrative?: boolean;
|
||||
} = {}) {
|
||||
const events: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
@@ -204,7 +211,13 @@ function harness(options: {
|
||||
) {
|
||||
const prior = receipts.get(actionId);
|
||||
if (prior) {
|
||||
assert.deepEqual(input, prior.input);
|
||||
if (prior.actionKind && prior.commandFingerprint) {
|
||||
assert.equal(actionKind, prior.actionKind);
|
||||
assert.equal(input.expectedVersion, prior.expectedVersion);
|
||||
assert.equal(input.commandFingerprint, prior.commandFingerprint);
|
||||
} else {
|
||||
assert.deepEqual(input, prior.input);
|
||||
}
|
||||
return prior.response;
|
||||
}
|
||||
const response = make();
|
||||
@@ -344,11 +357,11 @@ function harness(options: {
|
||||
packetBuilds += 1;
|
||||
events.push(input.evidence.length > 0 ? "score-packet" : "packet");
|
||||
if (options.packetFailure) throw options.packetFailure;
|
||||
return input.evidence.length > 0
|
||||
return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1)
|
||||
? { packet: packet(true), resultId }
|
||||
: { packet: packet(false), resultId: null };
|
||||
},
|
||||
narrativeGenerator: validGenerator(events),
|
||||
narrativeGenerator: validGenerator(events, options.varyNarrative),
|
||||
asOfDate: () => "2026-07-21",
|
||||
};
|
||||
|
||||
@@ -489,6 +502,57 @@ test("generic date uncertainty does not suppress clear historical evidence", asy
|
||||
.some((item) => item.eventSummary.includes("毕业") && item.scoreable === true));
|
||||
});
|
||||
|
||||
test("one and two supported events save and narrate before the third accumulated event ranks", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 3 });
|
||||
await start(value, null);
|
||||
const answers = [
|
||||
[answerActionId, "2019年7月毕业"],
|
||||
[secondAnswerActionId, "2020年8月搬家"],
|
||||
[thirdAnswerActionId, "2021年9月换工作"],
|
||||
] as const;
|
||||
|
||||
for (const [index, [receivedActionId, answer]] of answers.entries()) {
|
||||
const turn = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: receivedActionId,
|
||||
turnVersion: index,
|
||||
answer,
|
||||
});
|
||||
const stored = value.cases.get(startActionId)?.row;
|
||||
assert.equal(stored?.eventEvidence.length, index + 1);
|
||||
assert.equal(turn.evidenceRecap.length, index + 1);
|
||||
assert.equal(turn.status, index < 2 ? "active" : "confirming");
|
||||
}
|
||||
|
||||
assert.equal(value.counts().packetBuilds, 4);
|
||||
assert.equal(value.events.filter((event) => event === "narrative").length, 4);
|
||||
});
|
||||
|
||||
test("family evidence remains stored and public without changing its domain", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 3 });
|
||||
await start(value, null);
|
||||
|
||||
const turn = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2020年7月父亲生病",
|
||||
});
|
||||
|
||||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||||
assert.equal(stored.length, 1);
|
||||
assert.equal(stored[0]?.domain, "family");
|
||||
assert.match(stored[0]?.eventSummary ?? "", /父亲/);
|
||||
assert.deepEqual(turn.evidenceRecap, [{
|
||||
id: stored[0]?.id,
|
||||
summary: stored[0]?.eventSummary,
|
||||
dateLabel: "2020-07",
|
||||
}]);
|
||||
assert.equal(turn.status, "active");
|
||||
});
|
||||
|
||||
test("vague, future, and unmatched answers stay conversational and never score", async () => {
|
||||
for (const [answer, domain] of [
|
||||
["后来换了工作", undefined],
|
||||
@@ -556,6 +620,28 @@ test("a lost-response retry replays the saved answer without rescoring or regene
|
||||
assert.deepEqual(value.events, before);
|
||||
});
|
||||
|
||||
test("overlapping identical answers converge on the first receipt despite different derived narratives", async () => {
|
||||
const value = harness({ varyNarrative: true });
|
||||
await start(value, null);
|
||||
const command = {
|
||||
type: "answer" as const,
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2021年7月毕业,并在2022年3月去外地工作",
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
value.service.answer(userId, command),
|
||||
value.service.answer(userId, command),
|
||||
]);
|
||||
|
||||
assert.deepEqual(second, first);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, 2);
|
||||
assert.equal(value.events.filter((event) => event === "narrative").length, 3);
|
||||
assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2);
|
||||
});
|
||||
|
||||
test("receipt-first delayed retries replay the original answer, pause, abandon, and confirm after later turns", async () => {
|
||||
const scenarios = [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "../src/app/api/birth-time-conversation/route.ts";
|
||||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||||
import type { BirthTimeJourneyEngine } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { LifeEventEvidence } from "../src/lib/conversational-rectification/persistence-contracts.ts";
|
||||
import type { LifeEvent } from "../src/lib/birth-time-evidence.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000711";
|
||||
const actionId = "00000000-0000-4000-8000-000000000712";
|
||||
@@ -65,6 +67,105 @@ function service(overrides: Partial<BirthTimeConversationRouteService> = {}): Bi
|
||||
};
|
||||
}
|
||||
|
||||
function syntheticEvidence(
|
||||
index: number,
|
||||
domain: LifeEventEvidence["domain"],
|
||||
): LifeEventEvidence {
|
||||
return {
|
||||
id: `00000000-0000-4000-8000-${String(800 + index).padStart(12, "0")}`,
|
||||
rawText: `synthetic event ${index}`,
|
||||
domain,
|
||||
eventSummary: `synthetic summary ${index}`,
|
||||
dateValue: `${2010 + index}-07`,
|
||||
datePrecision: "month",
|
||||
extractionStatus: "clear",
|
||||
scoreable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function packetEngine(options: {
|
||||
readonly scoreCalls?: LifeEvent[][];
|
||||
readonly scanTimes?: readonly string[];
|
||||
} = {}): BirthTimeJourneyEngine {
|
||||
const minute = (value: string) => {
|
||||
const [hour = 0, part = 0] = value.slice(-5).split(":").map(Number);
|
||||
return hour * 60 + part;
|
||||
};
|
||||
const clock = (value: number) => {
|
||||
const normalized = ((value % 1_440) + 1_440) % 1_440;
|
||||
return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`;
|
||||
};
|
||||
return {
|
||||
async scan(input) {
|
||||
const center = minute(input.birthTime);
|
||||
const times = options.scanTimes ?? [
|
||||
clock(center - input.uncertaintyMinutes),
|
||||
clock(center),
|
||||
clock(center + input.uncertaintyMinutes),
|
||||
];
|
||||
return {
|
||||
questionnaire: {
|
||||
questions: [],
|
||||
samples: times.map((time, index) => ({
|
||||
ascendantSign: "Cancer",
|
||||
d4Sign: index % 2 === 0 ? "Aries" : "Taurus",
|
||||
d9Sign: index % 2 === 0 ? "Gemini" : "Virgo",
|
||||
d10Sign: index % 2 === 0 ? "Leo" : "Libra",
|
||||
d24Sign: "Sagittarius",
|
||||
d30Sign: "Pisces",
|
||||
})),
|
||||
raw: {
|
||||
candidate_scan: {
|
||||
samples: times.map((time) => ({ time: `1990-01-01 ${time}` })),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
async score() { throw new Error("unexpected questionnaire score"); },
|
||||
async scoreEvents(input) {
|
||||
options.scoreCalls?.push([...input.events]);
|
||||
return {
|
||||
resultId: "00000000-0000-4000-8000-000000000899",
|
||||
confidence: "low",
|
||||
canApply: false,
|
||||
winningSegment: null,
|
||||
eventCount: input.events.length,
|
||||
domainCount: new Set(input.events.map((event) => event.domain)).size,
|
||||
topScore: 1,
|
||||
secondScore: 1,
|
||||
marginPercent: 0,
|
||||
reasons: ["synthetic low result"],
|
||||
evidence: [],
|
||||
algorithmVersion: "synthetic-event-score-v1",
|
||||
};
|
||||
},
|
||||
async buildDifferencePacket(input) {
|
||||
return {
|
||||
packet: {
|
||||
caseId: input.caseId,
|
||||
scoringVersion: "birth-time-choice-scoring-v2",
|
||||
currentRange: { startTime: input.startTime, endTime: input.endTime },
|
||||
opportunities: [],
|
||||
askedQuestionFingerprints: [],
|
||||
candidatePartitionFingerprints: [],
|
||||
recentRangeHistory: [],
|
||||
},
|
||||
candidateModel: { version: "birth-time-choice-scoring-v2" },
|
||||
scoringPartitions: {},
|
||||
};
|
||||
},
|
||||
async scoreChoices() { throw new Error("unexpected choice score"); },
|
||||
};
|
||||
}
|
||||
|
||||
const packetBirthplace = {
|
||||
cityCode: "TPE-CITY",
|
||||
latitude: 25.0268,
|
||||
longitude: 121.5434,
|
||||
timezoneOffset: 8,
|
||||
};
|
||||
|
||||
test("authentication happens before body parsing and unauthenticated requests create no privileged service", async () => {
|
||||
const events: string[] = [];
|
||||
let creates = 0;
|
||||
@@ -326,3 +427,129 @@ test("production unknown-time adapter covers the declared full day with bounded
|
||||
assert.equal(new Set(sampleTimes).size, sampleTimes.length);
|
||||
assert.deepEqual(sampleTimes, [...sampleTimes].sort((left, right) => minute(left) - minute(right)));
|
||||
});
|
||||
|
||||
test("production packet waits for three supported events and then scores the accumulated evidence", async () => {
|
||||
const scoreCalls: LifeEvent[][] = [];
|
||||
const engine = packetEngine({ scoreCalls });
|
||||
const evidence = [
|
||||
syntheticEvidence(1, "education"),
|
||||
syntheticEvidence(2, "relocation"),
|
||||
syntheticEvidence(3, "career"),
|
||||
];
|
||||
|
||||
for (let count = 1; count <= 3; count += 1) {
|
||||
const built = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput: {
|
||||
source: "approximate",
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: "05:20",
|
||||
uncertaintyBeforeMinutes: 30,
|
||||
uncertaintyAfterMinutes: 30,
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
},
|
||||
privateCandidate: null,
|
||||
evidence: evidence.slice(0, count),
|
||||
});
|
||||
assert.equal(built.packet.candidate.status, "pending_validation");
|
||||
assert.equal(
|
||||
built.resultId,
|
||||
count < 3 ? null : "00000000-0000-4000-8000-000000000899",
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(scoreCalls.length, 1);
|
||||
assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id));
|
||||
});
|
||||
|
||||
test("production packet deterministically sends only the latest six supported events", async () => {
|
||||
const scoreCalls: LifeEvent[][] = [];
|
||||
const engine = packetEngine({ scoreCalls });
|
||||
const domains = ["education", "relocation", "career", "relationship"] as const;
|
||||
const evidence = Array.from({ length: 8 }, (_, index) =>
|
||||
syntheticEvidence(index + 1, domains[index % domains.length] ?? "career"));
|
||||
|
||||
await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput: {
|
||||
source: "approximate",
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: "05:20",
|
||||
uncertaintyBeforeMinutes: 30,
|
||||
uncertaintyAfterMinutes: 30,
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
},
|
||||
privateCandidate: null,
|
||||
evidence,
|
||||
});
|
||||
|
||||
assert.equal(scoreCalls.length, 1);
|
||||
assert.deepEqual(
|
||||
scoreCalls[0]?.map((event) => event.id),
|
||||
evidence.slice(-6).map((item) => item.id),
|
||||
);
|
||||
});
|
||||
|
||||
test("family evidence stays out of relationship scoring when three real scorer domains exist", async () => {
|
||||
const scoreCalls: LifeEvent[][] = [];
|
||||
const engine = packetEngine({ scoreCalls });
|
||||
const family = syntheticEvidence(1, "family");
|
||||
const supported = [
|
||||
syntheticEvidence(2, "education"),
|
||||
syntheticEvidence(3, "relocation"),
|
||||
syntheticEvidence(4, "career"),
|
||||
];
|
||||
|
||||
await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput: {
|
||||
source: "approximate",
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: "05:20",
|
||||
uncertaintyBeforeMinutes: 30,
|
||||
uncertaintyAfterMinutes: 30,
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
},
|
||||
privateCandidate: null,
|
||||
evidence: [family, ...supported],
|
||||
});
|
||||
|
||||
assert.equal(scoreCalls.length, 1);
|
||||
assert.deepEqual(scoreCalls[0]?.map((event) => event.domain), [
|
||||
"education",
|
||||
"relocation",
|
||||
"career",
|
||||
]);
|
||||
assert.equal(scoreCalls[0]?.some((event) => event.id === family.id), false);
|
||||
assert.equal(scoreCalls[0]?.some((event) => event.domain === "relationship"), false);
|
||||
});
|
||||
|
||||
test("a single period-only scan filters duplicate and out-of-range samples from the exact :59 range", async () => {
|
||||
const engine = packetEngine({ scanTimes: ["08:00", "10:00", "10:00", "12:00"] });
|
||||
const built = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput: {
|
||||
source: "period_only",
|
||||
birthDate: "1990-01-01",
|
||||
reportedPeriod: "morning",
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
},
|
||||
privateCandidate: null,
|
||||
evidence: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(built.packet.candidate.range, { startTime: "08:00", endTime: "11:59" });
|
||||
assert.deepEqual(built.packet.sensitivityScope.sampleTimes, ["08:00", "10:00"]);
|
||||
});
|
||||
|
||||
@@ -296,6 +296,12 @@ def test_historical_action_replay_is_owner_scoped_exact_bounded_and_read_only()
|
||||
assert "p_command_fingerprint text" in body
|
||||
assert "p_command_fingerprint !~ '^[0-9a-f]{64}$'" in body
|
||||
assert "'commandfingerprint', p_command_fingerprint" in body
|
||||
assert (
|
||||
"case when v_receipt.request ? 'commandfingerprint' then "
|
||||
"v_receipt.request ->> 'commandfingerprint' is distinct from "
|
||||
"p_command_fingerprint else v_receipt.request_fingerprint is "
|
||||
"distinct from v_fingerprint end"
|
||||
) in body
|
||||
|
||||
|
||||
def test_start_identity_and_account_concurrency_are_server_guarded() -> None:
|
||||
|
||||
@@ -563,6 +563,87 @@ def test_historical_receipt_replays_exact_public_response_after_later_turns(
|
||||
assert privileges == {"anon": False, "authenticated": False, "serviceRole": True}
|
||||
|
||||
|
||||
def test_overlapping_mutation_uses_command_identity_while_legacy_uses_full_request(
|
||||
pg14_database: PgDatabase,
|
||||
) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000001041"
|
||||
case_id = "00000000-0000-4000-8000-000000001042"
|
||||
action_id = "00000000-0000-4000-8000-000000001043"
|
||||
fingerprint = "c" * 64
|
||||
_create_user(pg14_database, user_id)
|
||||
_reserve(pg14_database, user_id, case_id)
|
||||
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
|
||||
_complete(pg14_database, user_id, case_id)
|
||||
|
||||
original = json.loads(pg14_database.sql(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
0,
|
||||
action_id,
|
||||
[],
|
||||
command_fingerprint=fingerprint,
|
||||
)))
|
||||
alternate_turn = {
|
||||
**_valid_turn(case_id),
|
||||
"turnVersion": 1,
|
||||
"narrative": "A different valid narrative derived by an overlapping request.",
|
||||
}
|
||||
replayed = json.loads(pg14_database.sql(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
0,
|
||||
action_id,
|
||||
[],
|
||||
turn=alternate_turn,
|
||||
validation_receipt={"modelId": "alternate-model", "schemaValidated": True},
|
||||
command_fingerprint=fingerprint,
|
||||
)))
|
||||
assert replayed == original
|
||||
assert pg14_database.rejects(_save_statement(
|
||||
user_id,
|
||||
case_id,
|
||||
0,
|
||||
action_id,
|
||||
[],
|
||||
turn=alternate_turn,
|
||||
command_fingerprint="d" * 64,
|
||||
))
|
||||
|
||||
legacy_user_id = "00000000-0000-4000-8000-000000001051"
|
||||
legacy_case_id = "00000000-0000-4000-8000-000000001052"
|
||||
legacy_action_id = "00000000-0000-4000-8000-000000001053"
|
||||
_create_user(pg14_database, legacy_user_id)
|
||||
_reserve(pg14_database, legacy_user_id, legacy_case_id)
|
||||
_create_case(
|
||||
pg14_database,
|
||||
legacy_user_id,
|
||||
legacy_case_id,
|
||||
_valid_declared_birth_input(),
|
||||
)
|
||||
_complete(pg14_database, legacy_user_id, legacy_case_id)
|
||||
legacy_statement = _save_statement(
|
||||
legacy_user_id,
|
||||
legacy_case_id,
|
||||
0,
|
||||
legacy_action_id,
|
||||
[],
|
||||
)
|
||||
legacy_original = json.loads(pg14_database.sql(legacy_statement))
|
||||
assert json.loads(pg14_database.sql(legacy_statement)) == legacy_original
|
||||
assert pg14_database.rejects(_save_statement(
|
||||
legacy_user_id,
|
||||
legacy_case_id,
|
||||
0,
|
||||
legacy_action_id,
|
||||
[],
|
||||
turn={
|
||||
**_valid_turn(legacy_case_id),
|
||||
"turnVersion": 1,
|
||||
"narrative": "A different legacy-derived narrative must remain a conflict.",
|
||||
},
|
||||
))
|
||||
|
||||
|
||||
def test_database_rejects_oversize_or_unknown_durable_json(pg14_database: PgDatabase) -> None:
|
||||
user_id = "00000000-0000-4000-8000-000000000951"
|
||||
action_id = "00000000-0000-4000-8000-000000000952"
|
||||
|
||||
Reference in New Issue
Block a user