test: close conversational rectification review gaps

This commit is contained in:
Jesse_Chen
2026-07-21 15:06:42 +08:00
parent ffaa7dc9c5
commit 6827e0ca45
11 changed files with 685 additions and 54 deletions
+18 -4
View File
@@ -77,6 +77,8 @@ ADMIN_EMAILS=...
RECTIFICATION_PRICE_CREDITS=3
RECTIFICATION_V3_CREATE_ENABLED=true
RECTIFICATION_V3_MIGRATIONS_READY=false
# Set only after the authenticated synthetic smoke passes on this exact image.
RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA=
# Recommended multi-model catalog. The JSON references server-only keys.
LLM_DEFAULT_MODEL_ID=deepseek-pro
@@ -207,15 +209,26 @@ Run `cd frontend && npx supabase db push --linked` with the authorized project
account. Verify the linked migration ledger contains all six versions. Do not
print the database URL or any service-role credential. Then set
`RECTIFICATION_V3_MIGRATIONS_READY=true`, keep
`RECTIFICATION_V3_CREATE_ENABLED=true`, and deploy the tested Git revision.
`RECTIFICATION_V3_CREATE_ENABLED=true`, leave
`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` empty, and deploy the tested Git revision.
Creation is available for the authorized smoke account, but the revision is not
ready for general rollout until that smoke is recorded.
Before declaring rollout successful, fetch `https://jyotisha.chat/api/health`
and verify all of the following against the revision that passed validation:
Before the smoke, fetch `https://jyotisha.chat/api/health` and verify the full
deployment SHA, healthy dependencies, enabled creation, ready migrations,
`syntheticSmoke: pending`, and `readyForNewCases: false`. A missing, abbreviated,
malformed, or previous-revision smoke SHA must remain pending.
After the smoke sequence below passes, set
`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character
lowercase Git SHA and restart the web container. Then fetch health again and
verify all of the following against the revision that passed validation:
- `deployment.gitCommit` exactly equals the tested 40-character Git SHA;
- `rollout.conversationalRectificationV3.protocol` is
`conversational-evidence-v3`;
- `newCaseCreation` and `migrations` are `enabled` and `ready`;
- `syntheticSmoke` is `matched`;
- `readyForNewCases` is `true`;
- ordinary health checks remain healthy. The health response must never contain
environment values or credentials.
@@ -251,7 +264,8 @@ from telemetry.
### Rollback
Rollback is forward-compatible and non-destructive. First set
`RECTIFICATION_V3_CREATE_ENABLED=false` and redeploy a revision that can still
`RECTIFICATION_V3_CREATE_ENABLED=false`, clear
`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA`, and redeploy a revision that can still
read/resume v3. Health must report `newCaseCreation: paused`. This stops only
new v3 starts: keep reads, resume, answer, pause, confirmation, and saved-question
handoff available for existing cases. Never reverse or delete the v3 migrations,
@@ -13,11 +13,11 @@ the tested deployment SHA.
| Issue | Reported failure | Current status | Local evidence | Production closure artifact |
| --- | --- | --- | --- | --- |
| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; Task 12 synthetic flow deletes the chat while retaining the account case. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. |
| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; the PG14 full-flow test deletes a real RLS-owned chat as `authenticated` and proves the account case remains. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. |
| ISSUE-BT-002 | A new chat could not establish a fresh rectification interaction and unfinished progress was coupled to chat state. | verified-local | `20260720010000_conversational_rectification_schema.sql`; account-level resume in `frontend/tests/conversational-rectification-e2e.test.ts` across two route clients; Task 9 current-chat consent tests. | Authenticated new-device/new-chat resume smoke tied to the deployed SHA. |
| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; PG14 confirmation/rollback runtime suite. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. |
| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; the PG14 full-flow test rejects `05:20`, confirms exact `05:21`, and proves the old time survives until commit. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. |
| ISSUE-BT-004 | Choosing `都不符合` surfaced the same raw English pattern error. | verified-local | The actual orchestrator treats `都不符合` as a normal direction change; Task 12 E2E advances the durable turn and preserves the single fee; client maps terminal 502/non-JSON failures to stable Chinese copy. | Authenticated production `都不符合` action followed by reload/resume, tied to the deployed SHA. |
| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; `20260721010000_conversational_legacy_import_projection.sql` imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. |
| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; PG14 proves future background persists without scoring and the legacy suite imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. |
## Release decision
@@ -10,6 +10,7 @@ import {
} from "../../../lib/conversational-rectification/errors.ts";
import {
createConversationalRectificationService,
conversationalRectificationTelemetryOutcome,
evidencePredatesBirthDate,
type ConversationalRectificationPacketBuildInput,
type ConversationalRectificationService,
@@ -691,7 +692,7 @@ async function dispatch(
}
function telemetryPhase(
turn: ConversationalRectificationTurn | null,
turn: Pick<ConversationalRectificationTurn, "status"> | null,
): ConversationalRectificationTelemetryPayload["phase"] {
switch (turn?.status) {
case "active": return "collecting_evidence";
@@ -740,6 +741,7 @@ export function createBirthTimeConversationPostHandler(
);
dependencies.createRequestId?.(request);
let actionKind: ConversationalRectificationTelemetryPayload["actionKind"] = "unknown";
let service: BirthTimeConversationRouteService | null = null;
try {
const authenticated = await dependencies.authenticate(request);
if (!authenticated) throw new ConversationalRectificationError("authentication_required");
@@ -748,29 +750,32 @@ export function createBirthTimeConversationPostHandler(
if (!parsed.success) throw new ConversationalRectificationError("invalid_command");
actionKind = parsed.data.type;
const service = await dependencies.createService(authenticated);
service = await dependencies.createService(authenticated);
const turn = await dispatch(service, authenticated.userId, parsed.data);
const outcome = conversationalRectificationTelemetryOutcome(service);
telemetry({
protocol: "conversational-evidence-v3",
phase: telemetryPhase(turn),
actionKind,
resultCategory: "success",
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: actionKind === "start" ? "unknown" : "unchanged",
billingState: outcome?.billingState ?? (actionKind === "start" ? "unknown" : "unchanged"),
errorCategory: "none",
deploymentSha,
});
return Response.json(turn);
} catch (error) {
const publicError = toConversationalRectificationPublicError(error);
const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null;
dependencies.log?.({ code: publicError.code });
telemetry({
protocol: "conversational-evidence-v3",
phase: "entry",
phase: telemetryPhase(outcome?.caseStatus ? { status: outcome.caseStatus } : null),
actionKind,
resultCategory: telemetryResultCategory(publicError.status),
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: publicError.code === "billing_failed" ? "unknown" : "not_applicable",
billingState: outcome?.billingState
?? (publicError.code === "billing_failed" ? "unknown" : "not_applicable"),
errorCategory: telemetryErrorCategory(publicError.code),
deploymentSha,
});
+18 -12
View File
@@ -7,15 +7,12 @@ type Check = {
};
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const gitCommit =
process.env.GITHUB_SHA
?? process.env.VERCEL_GIT_COMMIT_SHA
?? process.env.NEXT_PUBLIC_GIT_COMMIT
?? "unknown";
const rectificationV3CreationEnabled =
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false";
const rectificationV3MigrationsReady =
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
function deployedGitCommit(): string {
return process.env.GITHUB_SHA
?? process.env.VERCEL_GIT_COMMIT_SHA
?? process.env.NEXT_PUBLIC_GIT_COMMIT
?? "unknown";
}
function envCheck(names: string[]): Check {
const missing = names.filter((name) => !process.env[name]);
@@ -62,6 +59,12 @@ function aggregate(checks: Record<string, Check>) {
}
export async function GET() {
const gitCommit = deployedGitCommit();
const rectificationV3CreationEnabled =
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false";
const rectificationV3MigrationsReady =
process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true";
const smokeSha = process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA?.trim().toLowerCase() ?? "";
const checks = {
web: { status: "ok" } satisfies Check,
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
@@ -70,9 +73,12 @@ export async function GET() {
jyotishApi: await jyotishApiCheck(),
};
const status = aggregate(checks);
const rectificationV3Ready = rectificationV3CreationEnabled
const deploymentHasFullSha = /^[0-9a-f]{40}$/.test(gitCommit);
const smokeMatchesDeployment = deploymentHasFullSha && smokeSha === gitCommit;
const rectificationV3Ready = status === "ok"
&& rectificationV3CreationEnabled
&& rectificationV3MigrationsReady
&& gitCommit !== "unknown";
&& smokeMatchesDeployment;
return NextResponse.json(
{
status,
@@ -85,7 +91,7 @@ export async function GET() {
protocol: "conversational-evidence-v3",
newCaseCreation: rectificationV3CreationEnabled ? "enabled" : "paused",
migrations: rectificationV3MigrationsReady ? "ready" : "unverified",
syntheticSmoke: "required",
syntheticSmoke: smokeMatchesDeployment ? "matched" : "pending",
readyForNewCases: rectificationV3Ready,
},
},
@@ -84,8 +84,8 @@ export function createConversationalRectificationTelemetry(
sink: ConversationalRectificationTelemetrySink = conversationalRectificationConsoleSink,
): ConversationalRectificationTelemetrySink {
return (input) => {
const payload = conversationalRectificationTelemetryPayloadSchema.parse(input);
try {
const payload = conversationalRectificationTelemetryPayloadSchema.parse(input);
sink(payload);
} catch { // no-excuse-ok: observability cannot break the product request
return;
@@ -99,6 +99,22 @@ export type ConversationalRectificationService = Readonly<{
confirm(userId: string, command: CommandOf<"confirm">): Promise<ConversationalRectificationTurn>;
}>;
export type ConversationalRectificationTelemetryOutcome = Readonly<{
billingState: "not_applicable" | "charged" | "released" | "migration_waived" | "unchanged" | "unknown";
caseStatus: ConversationalRectificationTurn["status"] | null;
}>;
const telemetryOutcomes = new WeakMap<
ConversationalRectificationService,
() => ConversationalRectificationTelemetryOutcome
>();
export function conversationalRectificationTelemetryOutcome(
service: ConversationalRectificationService,
): ConversationalRectificationTelemetryOutcome | null {
return telemetryOutcomes.get(service)?.() ?? null;
}
const transitionValidatorVersion = "conversational-rectification-orchestrator-v1";
const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/;
const genericUncertaintyPattern = /(?:不知道|不确定)/;
@@ -458,9 +474,25 @@ function requireMutable(current: LoadedConversationalRectificationCase) {
export function createConversationalRectificationService(
ports: ConversationalRectificationServicePorts,
): ConversationalRectificationService {
let lastTelemetryOutcome: ConversationalRectificationTelemetryOutcome = {
billingState: "not_applicable",
caseStatus: null,
};
function resetTelemetryOutcome() {
lastTelemetryOutcome = { billingState: "not_applicable", caseStatus: null };
}
function observeCase(
value: LoadedConversationalRectificationCase | StoredConversationalRectificationCase,
billingState: ConversationalRectificationTelemetryOutcome["billingState"] = "unchanged",
) {
lastTelemetryOutcome = { billingState, caseStatus: publicTurn(value).status };
}
async function load(userId: string, caseId: string) {
try {
return requireLoaded(await ports.store.loadCase({ userId, caseId }));
const current = requireLoaded(await ports.store.loadCase({ userId, caseId }));
observeCase(current);
return current;
} catch (error) {
throw safeFailure(error);
}
@@ -481,7 +513,9 @@ export function createConversationalRectificationService(
actionKind,
commandFingerprint: fingerprint,
});
return receipt ? publicTurn(receipt) : null;
if (!receipt) return null;
observeCase(receipt);
return publicTurn(receipt);
} catch (error) {
throw safeFailure(error);
}
@@ -493,6 +527,7 @@ export function createConversationalRectificationService(
actionId: string,
pendingConsultationQuestion: string | null = null,
): Promise<ConversationalRectificationTurn> {
resetTelemetryOutcome();
const importer = ports.store.importLegacy;
const loadLegacy = ports.loadLegacyCase;
if (!importer || !loadLegacy) {
@@ -502,6 +537,7 @@ export function createConversationalRectificationService(
try {
const existingByAction = await ports.store.loadCase({ userId, caseId: actionId });
if (existingByAction) {
observeCase(existingByAction);
if (existingByAction.importedFromCaseId !== legacyCaseId
|| existingByAction.billingState !== "migration_waived"
|| existingByAction.pendingConsultationQuestion !== pendingConsultationQuestion) {
@@ -513,6 +549,7 @@ export function createConversationalRectificationService(
if (current?.importedFromCaseId === legacyCaseId
&& current.billingState === "migration_waived"
&& current.pendingConsultationQuestion === pendingConsultationQuestion) {
observeCase(current);
return publicTurn(current);
}
if (current?.importedFromCaseId === legacyCaseId
@@ -579,6 +616,7 @@ export function createConversationalRectificationService(
validationReceipt: narrative.validationReceipt,
privateCandidate,
});
observeCase(imported, "migration_waived");
return publicTurn(imported);
} catch (error) {
if (error instanceof ConversationalRectificationError
@@ -588,6 +626,7 @@ export function createConversationalRectificationService(
if (winner?.importedFromCaseId === legacyCaseId
&& winner.billingState === "migration_waived"
&& winner.pendingConsultationQuestion === pendingConsultationQuestion) {
observeCase(winner);
return publicTurn(winner);
}
} catch {
@@ -623,9 +662,10 @@ export function createConversationalRectificationService(
return extracted;
}
return Object.freeze({
const service: ConversationalRectificationService = Object.freeze({
importLegacyCase,
async start(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("start", rawCommand);
let profile: ConversationalRectificationProfile;
try {
@@ -663,6 +703,7 @@ export function createConversationalRectificationService(
throw safeFailure(error);
}
if (existing) {
observeCase(existing);
if (existing.pendingConsultationQuestion !== (command.pendingConsultationQuestion ?? null)) {
throw new ConversationalRectificationError("action_conflict");
}
@@ -674,6 +715,7 @@ export function createConversationalRectificationService(
expectedVersion: 0,
actionId: command.actionId,
});
observeCase(existing, "charged");
} catch (error) {
try {
await ports.billing.release({
@@ -683,6 +725,7 @@ export function createConversationalRectificationService(
actionId: command.actionId,
price,
});
observeCase(existing, "released");
} catch {
throw new ConversationalRectificationError("billing_failed");
}
@@ -708,6 +751,7 @@ export function createConversationalRectificationService(
price,
});
reserved = reservation.billingState === "reserved";
if (reserved) lastTelemetryOutcome = { billingState: "unknown", caseStatus: null };
const computed = await ports.buildTechnicalPacket({
userId,
caseId,
@@ -746,12 +790,14 @@ export function createConversationalRectificationService(
validationReceipt: narrative.validationReceipt,
privateCandidate,
});
observeCase(created, "unknown");
await ports.billing.complete({
userId,
caseId,
expectedVersion: 0,
actionId: command.actionId,
});
observeCase(created, "charged");
return publicTurn(created);
} catch (error) {
if (reserved) {
@@ -763,6 +809,10 @@ export function createConversationalRectificationService(
actionId: command.actionId,
price,
});
lastTelemetryOutcome = {
billingState: "released",
caseStatus: lastTelemetryOutcome.caseStatus,
};
} catch {
throw new ConversationalRectificationError("billing_failed");
}
@@ -772,12 +822,14 @@ export function createConversationalRectificationService(
},
async resume(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("resume", rawCommand);
const current = await load(userId, command.caseId);
return publicTurn(current);
},
async answer(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("answer", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "save_turn", fingerprint);
@@ -1035,6 +1087,7 @@ export function createConversationalRectificationService(
},
async pause(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("pause", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "pause", fingerprint);
@@ -1081,6 +1134,7 @@ export function createConversationalRectificationService(
},
async abandon(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("abandon", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "abandon", fingerprint);
@@ -1124,6 +1178,7 @@ export function createConversationalRectificationService(
},
async confirm(userId, rawCommand) {
resetTelemetryOutcome();
const command = parseCommand("confirm", rawCommand);
const fingerprint = commandFingerprint(command);
const receipt = await replayMutation(userId, command, "confirm", fingerprint);
@@ -1186,4 +1241,6 @@ export function createConversationalRectificationService(
}
},
});
telemetryOutcomes.set(service, () => lastTelemetryOutcome);
return service;
}
@@ -178,7 +178,12 @@ type Receipt = Readonly<{
row: StoredConversationalRectificationCase;
}>;
function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreation?: boolean } = {}) {
function createSyntheticBackend(options: {
legacy?: boolean;
allowNewCaseCreation?: boolean;
packetFailure?: boolean;
packetEvidenceCalls?: string[][];
} = {}) {
const cases = new Map<string, LoadedConversationalRectificationCase>();
const receipts = new Map<string, Receipt>();
let activeTime = "04:58";
@@ -364,6 +369,8 @@ function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreatio
};
},
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 packet = technicalPacket(ready);
if (input.preserveCandidateRange && input.privateCandidate?.rangeStart && input.privateCandidate.rangeEnd) {
@@ -428,7 +435,8 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
consent = clearBirthTimeConsultationConsent(consent, "chat-a");
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "choice");
const backend = createSyntheticBackend();
const packetEvidenceCalls: string[][] = [];
const backend = createSyntheticBackend({ packetEvidenceCalls });
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
@@ -450,6 +458,7 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false);
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
assert.equal(telemetry.at(-1)?.billingState, "charged");
assert.equal(backend.activeTime(), "04:58", "revision must retain the old active minute");
turn = await post(handler, {
@@ -467,11 +476,23 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
assert.match(turn.narrative, /还缺少.*明确时间/);
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
const futureEvidenceAction = "00000000-0000-4000-8000-000000009050";
turn = await post(handler, {
type: "answer", caseId, actionId: futureEvidenceAction,
turnVersion: turn.turnVersion, domain: "career", answer: "2027年计划换工作",
});
assert.match(turn.narrative, /未来事件只能作为背景.*不能用于校正评分/);
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2027(未来,仅作背景)");
const futureEvidenceId = turn.evidenceRecap.at(-1)?.id;
assert.ok(futureEvidenceId);
const historicalEvidenceIds: string[] = [];
turn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009005",
turnVersion: turn.turnVersion, domain: "career", answer: "2014年7月第一次正式入职",
});
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2014-07");
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
turn = await post(handler, {
type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009006",
turnVersion: turn.turnVersion,
@@ -495,13 +516,17 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009008",
turnVersion: turn.turnVersion, domain: "education", answer: "2011年6月大学毕业",
});
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
turn = await post(secondDevice, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009009",
turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
});
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
assert.equal(turn.status, "confirming");
assert.equal(turn.candidate.representativeTime, "05:18");
assert.equal(backend.activeTime(), "04:58");
assert.equal(packetEvidenceCalls.some((ids) => ids.includes(futureEvidenceId)), false);
assert.deepEqual(packetEvidenceCalls.at(-1), historicalEvidenceIds);
const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
@@ -513,6 +538,8 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
}));
assert.equal(wrong.status, 409);
assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic");
assert.equal(telemetry.at(-1)?.phase, "confirming");
assert.equal(telemetry.at(-1)?.billingState, "unchanged");
turn = await post(secondDevice, {
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011",
@@ -565,7 +592,13 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
test("legacy unfinished work imports once with migration waiver and no questionnaire or charge", async () => {
const backend = createSyntheticBackend({ legacy: true });
const first = await backend.service.start(userId, { type: "start", actionId: caseId });
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: (payload) => telemetry.push(payload),
});
const first = await post(handler, { type: "start", actionId: caseId });
const replay = await backend.service.start(userId, { type: "start", actionId: caseId });
assert.deepEqual(replay, first);
assert.equal(backend.cases.get(caseId)?.importedFromCaseId, backend.legacyCaseId);
@@ -575,9 +608,10 @@ test("legacy unfinished work imports once with migration waiver and no questionn
assert.deepEqual(first.candidate.rangeEnd, "05:50");
assert.doesNotMatch(first.narrative, /2006-2011|2011-2016|哪个时间段/);
assert.equal(JSON.stringify(first).includes("choiceAnswers"), false);
assert.equal(telemetry.at(-1)?.billingState, "migration_waived");
});
test("v3 telemetry rejects every field outside the privacy-safe category contract", () => {
test("v3 telemetry drops invalid payloads without affecting the product request", () => {
const emitted: unknown[] = [];
const record = createConversationalRectificationTelemetry((payload) => emitted.push(payload));
const valid = {
@@ -595,11 +629,66 @@ test("v3 telemetry rejects every field outside the privacy-safe category contrac
"narrative", "eventText", "birthDate", "birthTime", "email", "userId",
"accessToken", "refreshToken", "modelPrompt", "caseId", "actionId",
]) {
assert.throws(() => record({ ...valid, [forbidden]: "private" } as never));
assert.doesNotThrow(() => record({ ...valid, [forbidden]: "private" } as never));
}
assert.deepEqual(emitted, [valid]);
});
test("telemetry reports released reservations and authentication rejects before service creation", async () => {
const backend = createSyntheticBackend({ packetFailure: true });
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: (payload) => telemetry.push(payload),
});
const failed = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: caseId }),
}));
assert.equal(failed.status, 503);
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 0, releaseCount: 1, state: "released" });
assert.equal(telemetry.at(-1)?.billingState, "released");
assert.equal(telemetry.at(-1)?.errorCategory, "dependency");
let serviceCreations = 0;
const unauthenticated = createBirthTimeConversationPostHandler({
authenticate: async () => null,
createService: async () => { serviceCreations += 1; return backend.service; },
telemetry: () => undefined,
});
const rejected = await unauthenticated(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: caseId }),
}));
assert.equal(rejected.status, 401);
assert.equal(serviceCreations, 0);
});
test("authenticated route remains owner-bound", async () => {
const backend = createSyntheticBackend();
const started = await backend.service.start(userId, { type: "start", actionId: caseId });
const foreign = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId: "00000000-0000-4000-8000-000000009999", context: {} }),
createService: async () => backend.service,
telemetry: () => undefined,
});
const response = await foreign(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "resume",
caseId,
actionId: "00000000-0000-4000-8000-000000009998",
turnVersion: started.turnVersion,
}),
}));
assert.equal(response.status, 404);
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
});
test("transient 502 replays the same command and terminal failures expose only stable Chinese copy", async () => {
const originalFetch = globalThis.fetch;
const bodies: string[] = [];
@@ -646,12 +735,20 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho
GITHUB_SHA: process.env.GITHUB_SHA,
RECTIFICATION_V3_CREATE_ENABLED: process.env.RECTIFICATION_V3_CREATE_ENABLED,
RECTIFICATION_V3_MIGRATIONS_READY: process.env.RECTIFICATION_V3_MIGRATIONS_READY,
RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA,
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
};
process.env.GITHUB_SHA = deploymentSha;
process.env.RECTIFICATION_V3_CREATE_ENABLED = "true";
process.env.RECTIFICATION_V3_MIGRATIONS_READY = "true";
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = deploymentSha;
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.invalid";
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "synthetic-public-key";
process.env["SUPABASE_SERVICE_ROLE_KEY"] = "synthetic-runtime-secret-never-return";
process.env.OPENAI_API_KEY = "synthetic-model-key";
globalThis.fetch = async () => Response.json({ status: "ok" });
try {
const { GET: healthGet } = await import(`../src/app/api/health/route.ts?e2e=${Date.now()}`);
@@ -663,7 +760,7 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho
protocol: "conversational-evidence-v3",
newCaseCreation: "enabled",
migrations: "ready",
syntheticSmoke: "required",
syntheticSmoke: "matched",
readyForNewCases: true,
},
});
@@ -781,6 +781,43 @@ test("production packet deterministically sends only the latest six supported ev
);
});
test("persistable future background evidence never reaches the production scorer", async () => {
const scoreCalls: LifeEvent[][] = [];
const engine = packetEngine({ scoreCalls });
const historical = [
syntheticEvidence(61, "education", "2018-06", "month"),
syntheticEvidence(62, "relocation", "2020-09", "month"),
syntheticEvidence(63, "career", "2024-03", "month"),
];
const future = {
...syntheticEvidence(64, "career", "2027", "year"),
rawText: "2027年计划换工作",
eventSummary: "计划换工作",
scoreable: false as const,
};
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: [...historical, future],
});
assert.deepEqual(scoreCalls.map((events) => events.map((event) => event.id)), [[
...historical.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 });
+79
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { GET as healthGet } from "../src/app/api/health/route.ts";
function serviceBlock(compose: string, service: string) {
const match = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][a-z0-9_-]*:|^volumes:)`, "m"));
@@ -83,3 +84,81 @@ test("production workflow rejects a SHA mismatch in verification", () => {
/Verify production must use the tested SHA/,
);
});
test("v3 readiness requires healthy dependencies and smoke proof for the exact full deployment SHA", async () => {
const keys = [
"GITHUB_SHA", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY",
"SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY",
"RECTIFICATION_V3_CREATE_ENABLED", "RECTIFICATION_V3_MIGRATIONS_READY",
"RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA",
] as const;
const prior = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
const originalFetch = globalThis.fetch;
const currentSha = "0123456789abcdef0123456789abcdef01234567";
const oldSha = "89abcdef0123456789abcdef0123456789abcdef";
Object.assign(process.env, {
GITHUB_SHA: currentSha,
NEXT_PUBLIC_SUPABASE_URL: "https://example.invalid",
NEXT_PUBLIC_SUPABASE_ANON_KEY: "synthetic-public-key",
SUPABASE_SERVICE_ROLE_KEY: "synthetic-service-key",
OPENAI_API_KEY: "synthetic-model-key",
RECTIFICATION_V3_CREATE_ENABLED: "true",
RECTIFICATION_V3_MIGRATIONS_READY: "true",
});
globalThis.fetch = async () => Response.json({ status: "ok" });
async function readiness() {
const response = await healthGet();
const body = await response.json() as {
status: string;
rollout: { conversationalRectificationV3: {
syntheticSmoke: string;
readyForNewCases: boolean;
} };
};
return body;
}
try {
delete process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA;
assert.deepEqual((await readiness()).rollout.conversationalRectificationV3, {
protocol: "conversational-evidence-v3",
newCaseCreation: "enabled",
migrations: "ready",
syntheticSmoke: "pending",
readyForNewCases: false,
});
process.env.GITHUB_SHA = "deadbee";
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = "deadbee";
assert.equal((await readiness()).rollout.conversationalRectificationV3.readyForNewCases, false);
process.env.GITHUB_SHA = currentSha;
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = oldSha;
assert.equal((await readiness()).rollout.conversationalRectificationV3.syntheticSmoke, "pending");
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = currentSha;
const ready = await readiness();
assert.equal(ready.status, "ok");
assert.deepEqual(ready.rollout.conversationalRectificationV3, {
protocol: "conversational-evidence-v3",
newCaseCreation: "enabled",
migrations: "ready",
syntheticSmoke: "matched",
readyForNewCases: true,
});
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
const blocked = await readiness();
assert.equal(blocked.status, "blocked");
assert.equal(blocked.rollout.conversationalRectificationV3.syntheticSmoke, "matched");
assert.equal(blocked.rollout.conversationalRectificationV3.readyForNewCases, false);
} finally {
globalThis.fetch = originalFetch;
for (const key of keys) {
const value = prior[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
@@ -2257,3 +2257,284 @@ def test_import_abandon_then_paid_revision_keeps_active_time_and_charges_normall
"importedFrom": None,
"baseline": "04:58",
}
def test_real_database_full_conversational_flow_survives_authenticated_chat_deletion(
pg14_database: PgDatabase,
) -> None:
user_id = "00000000-0000-4000-8000-000000003101"
case_id = "00000000-0000-4000-8000-000000003102"
attach_action = "00000000-0000-4000-8000-000000003103"
pause_action = "00000000-0000-4000-8000-000000003104"
save_action = "00000000-0000-4000-8000-000000003105"
wrong_confirm = "00000000-0000-4000-8000-000000003106"
exact_confirm = "00000000-0000-4000-8000-000000003107"
claim_action = "00000000-0000-4000-8000-000000003108"
chat_id = "00000000-0000-4000-8000-000000003109"
question = "2027年是否适合换工作?"
question_fingerprint = hashlib.sha256(question.encode("utf-8")).hexdigest()
receipt = {"modelId": "synthetic-model", "schemaValidated": True}
_create_user(pg14_database, user_id, credits=20)
assert _reserve(pg14_database, user_id, case_id)["credits"] == 17
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
assert _complete(pg14_database, user_id, case_id)["billing_state"] == "charged"
attached = json.loads(pg14_database.sql(
f"""
select public.attach_conversational_rectification_question(
'{user_id}'::uuid, '{case_id}'::uuid, 0, '{attach_action}'::uuid,
{_text(question)}, '{question_fingerprint}'
)::text;
"""
))
assert attached["pending_consultation_question"] == question
paused_turn = {
**_valid_turn(case_id),
"status": "paused",
"turnVersion": 1,
"actions": ["answer", "abandon"],
"pendingConsultationQuestion": question,
}
paused = json.loads(pg14_database.sql(
f"""
select public.pause_conversational_rectification_case(
'{user_id}'::uuid, '{case_id}'::uuid, 0, '{pause_action}'::uuid,
{_jsonb(paused_turn)}, {_jsonb(receipt)}, '{'a' * 64}'
)::text;
"""
))
assert paused["status"] == "paused"
loaded = json.loads(pg14_database.sql(
f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text"
))
assert loaded["status"] == "paused"
assert loaded["turn_version"] == 1
future_evidence = {
"id": "00000000-0000-4000-8000-000000003110",
"rawText": "2027年计划换工作",
"domain": "career",
"eventSummary": "计划换工作",
"dateValue": "2027",
"datePrecision": "year",
"extractionStatus": "clear",
"scoreable": False,
"correctsEvidenceIds": [],
}
ready_turn = {
**_valid_turn(case_id),
"status": "confirming",
"turnVersion": 2,
"candidate": {
"status": "ready_for_confirmation",
"representativeTime": "05:21",
"rangeStart": "05:10",
"rangeEnd": "05:30",
},
"evidenceRequest": None,
"evidenceRecap": [{
"id": future_evidence["id"],
"summary": future_evidence["eventSummary"],
"dateLabel": "2027(未来,仅作背景)",
}],
"actions": ["answer", "pause", "abandon", "confirm"],
"pendingConsultationQuestion": question,
}
saved = json.loads(pg14_database.sql(_save_statement(
user_id,
case_id,
1,
save_action,
[future_evidence],
turn=ready_turn,
command_fingerprint="b" * 64,
)))
assert saved["status"] == "confirming"
loaded_ready = json.loads(pg14_database.sql(
f"select public.load_conversational_rectification_case('{user_id}'::uuid, '{case_id}'::uuid)::text"
))
assert loaded_ready["event_evidence"][0]["scoreable"] is False
completed_turn = {
**ready_turn,
"status": "completed",
"turnVersion": 3,
"candidate": {**ready_turn["candidate"], "status": "confirmed"},
"actions": ["continue_original_question"],
}
mismatch = f"""
select public.confirm_conversational_rectification_candidate(
'{user_id}'::uuid, '{case_id}'::uuid, 2, '{wrong_confirm}'::uuid,
'00000000-0000-4000-8000-000000000991'::uuid, '05:20'::time,
'rectification-v3.1', {_jsonb(completed_turn)}, {_jsonb(receipt)}, '{'c' * 64}'
);
"""
assert pg14_database.rejects(mismatch)
assert pg14_database.sql(
f"select pg_catalog.to_char(active_birth_time, 'HH24:MI') from public.profiles where id = '{user_id}'::uuid"
) == "04:58"
confirmed = json.loads(pg14_database.sql(
f"""
select public.confirm_conversational_rectification_candidate(
'{user_id}'::uuid, '{case_id}'::uuid, 2, '{exact_confirm}'::uuid,
'00000000-0000-4000-8000-000000000991'::uuid, '05:21'::time,
'rectification-v3.1', {_jsonb(completed_turn)}, {_jsonb(receipt)}, '{'d' * 64}'
)::text;
"""
))
assert confirmed["status"] == "completed"
assert pg14_database.sql(
f"select pg_catalog.to_char(active_birth_time, 'HH24:MI') from public.profiles where id = '{user_id}'::uuid"
) == "05:21"
claimed = json.loads(pg14_database.sql(
f"""
select public.claim_conversational_rectification_handoff(
'{user_id}'::uuid, '{case_id}'::uuid, 3, '{claim_action}'::uuid,
'{question_fingerprint}'
)::text;
"""
))
assert claimed["status"] == "claimed"
request_id = claimed["requestId"]
executing = json.loads(pg14_database.sql(
f"""
select public.begin_conversational_rectification_handoff_execution(
'{user_id}'::uuid, '{case_id}'::uuid, 3, '{claim_action}'::uuid,
'{request_id}'::uuid, '{question_fingerprint}'
)::text;
"""
))
assert executing["status"] == "ready"
reservation = json.loads(pg14_database.sql(
f"""
select row_to_json(result)::text from public.begin_consultation_credit(
'{user_id}'::uuid, '{request_id}'
) result;
"""
))
assert reservation["success"] is True
settlement = json.loads(pg14_database.sql(
f"""
select public.settle_conversational_rectification_handoff(
'{user_id}'::uuid, '{case_id}'::uuid, '{claim_action}'::uuid,
'{request_id}'::uuid, true
)::text;
"""
))
assert settlement["status"] == "consumed"
pg14_database.sql(
f"insert into public.chat_sessions (id, user_id) values ('{chat_id}'::uuid, '{user_id}'::uuid)"
)
pg14_database.sql(
"""
create or replace function auth.uid() returns uuid
language sql stable set search_path = ''
as 'select nullif(pg_catalog.current_setting(''request.jwt.claim.sub'', true), '''')::uuid';
"""
)
try:
pg14_database.sql(
f"""
begin;
set local role authenticated;
select pg_catalog.set_config('request.jwt.claim.sub', '{user_id}', true);
delete from public.chat_sessions where id = '{chat_id}'::uuid;
commit;
"""
)
finally:
pg14_database.sql(
"""
create or replace function auth.uid() returns uuid
language sql stable set search_path = '' as 'select null::uuid';
"""
)
durable = json.loads(pg14_database.sql(
f"""
select pg_catalog.jsonb_build_object(
'chatCount', (select pg_catalog.count(*) from public.chat_sessions where id = '{chat_id}'::uuid),
'caseCount', (select pg_catalog.count(*) from public.birth_time_rectification_cases where id = '{case_id}'::uuid),
'credits', credits,
'rectificationCharges', (select pg_catalog.count(*) from public.birth_time_rectification_billing where case_id = '{case_id}'::uuid and state = 'charged'),
'handoff', (select state from public.birth_time_rectification_question_handoffs where case_id = '{case_id}'::uuid),
'question', (select pending_consultation_question from public.birth_time_rectification_cases where id = '{case_id}'::uuid)
)::text
from public.profiles where id = '{user_id}'::uuid;
"""
))
assert durable == {
"chatCount": 0,
"caseCount": 1,
"credits": 16,
"rectificationCharges": 1,
"handoff": "consumed",
"question": None,
}
def test_all_v3_tables_and_public_rpcs_are_service_role_only(pg14_database: PgDatabase) -> None:
tables = [
"birth_time_rectification_cases",
"birth_time_rectification_turns",
"birth_time_rectification_event_evidence",
"birth_time_rectification_billing",
"birth_time_rectification_action_receipts",
"birth_time_rectification_question_handoffs",
"birth_time_rectification_handoff_attach_receipts",
"birth_time_rectification_handoff_settlements",
]
for table in tables:
privileges = json.loads(pg14_database.sql(
f"""
select pg_catalog.jsonb_build_object(
'anon', pg_catalog.has_table_privilege('anon', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE'),
'authenticated', pg_catalog.has_table_privilege('authenticated', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE'),
'serviceRole', pg_catalog.has_table_privilege('service_role', 'public.{table}', 'SELECT,INSERT,UPDATE,DELETE')
)::text;
"""
))
assert privileges == {"anon": False, "authenticated": False, "serviceRole": True}, table
rpc_names = [
"reserve_conversational_rectification_fee",
"complete_conversational_rectification_fee",
"release_conversational_rectification_fee",
"create_conversational_rectification_case",
"load_conversational_rectification_case",
"replay_conversational_rectification_action",
"save_conversational_rectification_turn",
"pause_conversational_rectification_case",
"abandon_conversational_rectification_case",
"confirm_conversational_rectification_candidate",
"import_legacy_conversational_rectification_case",
"attach_conversational_rectification_question",
"load_conversational_rectification_handoff",
"claim_conversational_rectification_handoff",
"begin_conversational_rectification_handoff_execution",
"settle_conversational_rectification_handoff",
]
rows = json.loads(pg14_database.sql(
f"""
select pg_catalog.jsonb_agg(pg_catalog.jsonb_build_object(
'name', proc.proname,
'anon', pg_catalog.has_function_privilege('anon', proc.oid, 'EXECUTE'),
'authenticated', pg_catalog.has_function_privilege('authenticated', proc.oid, 'EXECUTE'),
'serviceRole', pg_catalog.has_function_privilege('service_role', proc.oid, 'EXECUTE')
) order by proc.proname)::text
from pg_catalog.pg_proc proc
join pg_catalog.pg_namespace namespace on namespace.oid = proc.pronamespace
where namespace.nspname = 'public'
and proc.proname = any({_text('{' + ','.join(rpc_names) + '}')}::text[]);
"""
))
assert {row["name"] for row in rows} == set(rpc_names)
assert all(row == {
"name": row["name"],
"anon": False,
"authenticated": False,
"serviceRole": True,
} for row in rows)
@@ -4,7 +4,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = ROOT / "frontend" / "supabase" / "migrations"
V3_MIGRATIONS = tuple(sorted(MIGRATIONS.glob("2026072*conversational*.sql"))) + (
V3_MIGRATIONS = (MIGRATIONS / "20260717020000_birth_time_journey.sql",) + tuple(
sorted(MIGRATIONS.glob("2026072*conversational*.sql"))
) + (
MIGRATIONS / "20260720040000_rectification_question_handoff.sql",
)
@@ -13,12 +15,24 @@ def _sql() -> str:
return "\n".join(path.read_text(encoding="utf-8") for path in V3_MIGRATIONS)
def _function_body(name: str) -> str:
matches = re.findall(
rf"create or replace function public\.{re.escape(name)}\([\s\S]*?\)\s*"
rf"returns[\s\S]*?\bas \$\$([\s\S]*?)\$\$;",
_sql().lower(),
)
assert matches, f"missing function body: {name}"
return matches[-1]
def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None:
sql = _sql().lower()
tables = (
"birth_time_rectification_cases",
"birth_time_rectification_turns",
"birth_time_rectification_event_evidence",
"birth_time_rectification_billing",
"birth_time_rectification_action_receipts",
"birth_time_rectification_question_handoffs",
"birth_time_rectification_handoff_attach_receipts",
"birth_time_rectification_handoff_settlements",
@@ -26,49 +40,90 @@ def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None:
for table in tables:
assert f"alter table public.{table} enable row level security" in sql
assert re.search(rf"revoke all on table public\.{table}\s+from (?:public, )?anon, authenticated", sql)
assert not re.search(rf"grant (?:select|insert|update|delete|all).+public\.{table}.+to authenticated", sql)
mutation_rpcs = (
public_rpcs = (
"reserve_conversational_rectification_fee",
"complete_conversational_rectification_fee",
"release_conversational_rectification_fee",
"create_conversational_rectification_case",
"load_conversational_rectification_case",
"replay_conversational_rectification_action",
"save_conversational_rectification_turn",
"pause_conversational_rectification_case",
"abandon_conversational_rectification_case",
"confirm_conversational_rectification_candidate",
"import_legacy_conversational_rectification_case",
"attach_conversational_rectification_question",
"load_conversational_rectification_handoff",
"claim_conversational_rectification_handoff",
"begin_conversational_rectification_handoff_execution",
"settle_conversational_rectification_handoff",
)
for function in mutation_rpcs:
for function in public_rpcs:
assert re.search(rf"revoke all on function public\.{function}\(", sql)
assert not re.search(rf"grant execute on function public\.{function}\([\s\S]{{0,800}}?to (?:anon|authenticated)", sql)
def test_mutations_bind_owner_version_action_and_fingerprint_before_writes() -> None:
sql = _sql().lower()
for field in ("p_user_id", "p_case_id", "p_expected_version", "p_action_id"):
assert field in sql
assert "p_command_fingerprint" in sql
assert "p_question_fingerprint" in sql
assert "for update" in sql
assert "conversational_stale_turn" in sql
assert "conversational_action_conflict" in sql
assert "question_fingerprint" in sql
# This is an explicit per-RPC guard matrix. It prevents an unrelated
# function elsewhere in the migration bundle from satisfying a global
# keyword search. Runtime adversarial/no-side-effect behavior is covered
# by test_conversational_rectification_postgres_runtime.py.
guard_matrix = {
"reserve_conversational_rectification_fee": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update",
),
"complete_conversational_rectification_fee": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update",
),
"release_conversational_rectification_fee": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "for update",
),
"create_conversational_rectification_case": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "v_fingerprint", "for update",
),
"save_conversational_rectification_turn": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
),
"pause_conversational_rectification_case": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
),
"abandon_conversational_rectification_case": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
),
"confirm_conversational_rectification_candidate": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_command_fingerprint", "for update",
),
"import_legacy_conversational_rectification_case": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_legacy_case_id", "for update",
),
"attach_conversational_rectification_question": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_question_fingerprint", "for update",
),
"claim_conversational_rectification_handoff": (
"p_user_id", "p_case_id", "p_expected_version", "p_action_id", "p_question_fingerprint", "for update",
),
"begin_conversational_rectification_handoff_execution": (
"p_user_id", "p_case_id", "p_expected_version", "p_claim_action_id", "p_request_id", "p_question_fingerprint", "for update",
),
"settle_conversational_rectification_handoff": (
"p_user_id", "p_case_id", "p_claim_action_id", "p_request_id", "for update",
),
}
for function, guards in guard_matrix.items():
body = _function_body(function)
for guard in guards:
assert guard in body, f"{function} is missing guard {guard}"
def test_public_projection_excludes_private_weights_and_future_evidence_is_not_scored() -> None:
def test_public_projection_excludes_private_weights() -> None:
contracts = (ROOT / "frontend/src/lib/conversational-rectification/contracts.ts").read_text(encoding="utf-8")
projection = (ROOT / "frontend/src/lib/conversational-rectification/technical-packet.ts").read_text(encoding="utf-8")
route = (ROOT / "frontend/src/app/api/birth-time-conversation/route.ts").read_text(encoding="utf-8")
orchestrator = (ROOT / "frontend/src/lib/conversational-rectification/orchestrator.ts").read_text(encoding="utf-8")
public_region = contracts[contracts.index("const candidateSchema"):contracts.index("export type ConversationalRectificationTurn")]
assert "candidateWeights" not in public_region
assert "partitionIds" not in public_region
assert "candidateWeights" not in projection[projection.index("export function projectRectificationTechnicalPacket"):]
assert "item.scoreable !== true" in route
assert "futureWindows" in orchestrator
assert "scoreable: false" in orchestrator
def test_synthetic_fixtures_contain_no_secret_or_token_shapes() -> None:
@@ -78,7 +133,7 @@ def test_synthetic_fixtures_contain_no_secret_or_token_shapes() -> None:
r"eyj[a-z0-9_-]+\.[a-z0-9_-]+\.[a-z0-9_-]+",
r"bearer\s+[a-z0-9._-]{20,}",
r"supabase_service_role_key\s*=",
r"openai_api_key\s*=",
r"openai_api_key\s*=\s*[\"'](?:sk-|[a-z0-9]{32})",
r"refresh_token",
r"auth-token",
)