test: verify conversational birth-time rectification

This commit is contained in:
Jesse_Chen
2026-07-21 14:40:43 +08:00
parent de9a687779
commit ffaa7dc9c5
9 changed files with 1138 additions and 22 deletions
+77
View File
@@ -72,6 +72,12 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=...
SUPABASE_SERVICE_ROLE_KEY=...
ADMIN_EMAILS=...
# Conversational birth-time rectification rollout controls.
# Keep migrations false until the ordered database gate below has passed.
RECTIFICATION_PRICE_CREDITS=3
RECTIFICATION_V3_CREATE_ENABLED=true
RECTIFICATION_V3_MIGRATIONS_READY=false
# Recommended multi-model catalog. The JSON references server-only keys.
LLM_DEFAULT_MODEL_ID=deepseek-pro
LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]'
@@ -184,6 +190,77 @@ Editor shows `You do not have access to this project`, use the correct Supabase
organization account or invite the current GitHub user to project
`vtvnfqmonbfuxmqkqdlc` before retrying.
## Conversational birth-time rectification v3 rollout
`conversational-evidence-v3` is an account-level workflow. A web-image rollout
does not prove its database contract is present. Apply migrations before the
web image, in this order:
1. `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`
2. `20260720010000_conversational_rectification_schema.sql`
3. `20260720020000_conversational_rectification_billing.sql`
4. `20260720030000_conversational_rectification_transitions.sql`
5. `20260720040000_rectification_question_handoff.sql`
6. `20260721010000_conversational_legacy_import_projection.sql`
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.
Before declaring rollout successful, fetch `https://jyotisha.chat/api/health`
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`;
- `readyForNewCases` is `true`;
- ordinary health checks remain healthy. The health response must never contain
environment values or credentials.
Using an authorized synthetic account with no real birth data, run this smoke
sequence. A plain HTTP `200` is not substitute evidence:
1. Finish onboarding without rectification. Verify an unverified reported time
offers current-chat consent or `先校正再询问`.
2. Save a synthetic ordinary question and start v3. Verify one fixed fee and a
rich first turn containing the candidate boundary, stable/sensitive layers,
domain rationale, and a dated historical-event request.
3. Answer with one explicit event, choose `都不符合`, submit one ambiguous
event, then a clear event. Verify the ambiguous/future facts do not score.
4. Pause, reload, and resume from a second authenticated browser session.
Verify no second rectification charge.
5. Reach a candidate, verify the prior active time is still in force, reject a
mismatched candidate confirmation, then explicitly confirm the exact
candidate. Verify the time changes atomically.
6. Explicitly continue the saved ordinary question. Verify one normal
consultation reservation. Delete its chat and verify the account case still
resumes/loads.
7. For an unfinished legacy case, verify exactly one
`migration_waived` import, unchanged history, and no broad-year questionnaire.
8. Inject one transient 502. Verify byte-identical retry and stable Chinese
fallback, never raw browser English.
Record only protocol, phase, action kind, result category, latency bucket,
billing state, error category, and deployment SHA. Narrative, event text, birth
data, email, user/user-case identifiers, tokens, and model prompts are forbidden
from telemetry.
### Rollback
Rollback is forward-compatible and non-destructive. First set
`RECTIFICATION_V3_CREATE_ENABLED=false` 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,
rows, turns, evidence, receipts, or legacy import links. Never point an imported
case back to mutable legacy history. A revision in progress keeps the account's
prior active time until its exact atomic confirmation succeeds. If no compatible
reader is available, leave the current image serving existing cases and disable
only creation; do not deploy an older schema consumer.
## Common operations
```bash
@@ -0,0 +1,27 @@
# User-reported birth-time flow issues — 2026-07-20
This ledger records the five reported product failures and the evidence needed
to close them. It contains no copied authentication header, cookie, token,
email, user UUID, or real birth record. The original plan described this file as
an existing modification target; it did not exist in this checkout, so Task 12
created it.
`verified-local` means deterministic contract tests and the equivalent local
PostgreSQL 14 workflow pass. It is deliberately not `closed`: closure also
requires an authenticated synthetic production smoke whose health Git SHA is
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-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-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. |
## Release decision
All five issues remain `verified-local` until the production closure artifacts
above are attached. A public 200 response, an unverified browser session, or a
local in-memory test cannot change them to `closed`. The deployment sequence and
non-destructive rollback are defined in `deploy/README.md`.
@@ -22,6 +22,14 @@ import {
import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts";
import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts";
import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts";
import {
conversationalRectificationLatencyBucket,
createConversationalRectificationTelemetry,
recordConversationalRectificationTelemetry,
safeConversationalRectificationDeploymentSha,
type ConversationalRectificationTelemetryPayload,
type ConversationalRectificationTelemetrySink,
} from "../../../lib/birth-time-journey-telemetry.ts";
export const runtime = "nodejs";
export const maxDuration = 60;
@@ -34,9 +42,6 @@ type AuthenticatedRequest = Readonly<{
export type BirthTimeConversationRouteService = ConversationalRectificationService;
export type BirthTimeConversationRouteLog = Readonly<{
requestId: string;
actionId: string | null;
caseId: string | null;
code: string;
}>;
@@ -45,6 +50,9 @@ export type BirthTimeConversationPostDependencies = Readonly<{
createService(authenticated: AuthenticatedRequest): Promise<BirthTimeConversationRouteService>;
createRequestId?(request: Request): string;
log?(entry: BirthTimeConversationRouteLog): void;
telemetry?: ConversationalRectificationTelemetrySink;
deploymentSha?: string;
now?(): number;
}>;
type ProfileQueryResult = Readonly<{
@@ -580,6 +588,8 @@ async function createProductionService(
store: createSupabaseConversationalRectificationStore(admin),
billing: createSupabaseConversationalRectificationBilling(admin),
get rectificationPriceCredits() { return priceCredits(); },
allowNewCaseCreation:
process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false",
async loadDeclaredProfile(userId) {
return loadProductionConversationalRectificationProfile({
async loadProfile(receivedUserId) {
@@ -665,11 +675,6 @@ function stableRequestId(request: Request): string {
: randomUUID();
}
function errorResponse(error: unknown) {
const publicError = toConversationalRectificationPublicError(error);
return Response.json(publicError, { status: publicError.status });
}
async function dispatch(
service: BirthTimeConversationRouteService,
userId: string,
@@ -685,27 +690,90 @@ async function dispatch(
}
}
function telemetryPhase(
turn: ConversationalRectificationTurn | null,
): ConversationalRectificationTelemetryPayload["phase"] {
switch (turn?.status) {
case "active": return "collecting_evidence";
case "paused": return "paused";
case "confirming": return "confirming";
case "completed": return "completed";
case "abandoned": return "abandoned";
default: return "entry";
}
}
function telemetryErrorCategory(
code: string,
): ConversationalRectificationTelemetryPayload["errorCategory"] {
if (code === "authentication_required") return "authentication";
if (code === "invalid_command" || code === "profile_incomplete") return "validation";
if (code === "stale_turn" || code === "action_conflict" || code === "candidate_changed"
|| code === "invalid_transition" || code === "case_not_found") return "conflict";
if (code === "billing_failed") return "billing";
if (code === "service_unavailable" || code === "store_unavailable") return "dependency";
return "unknown";
}
function telemetryResultCategory(
status: number,
): ConversationalRectificationTelemetryPayload["resultCategory"] {
if (status === 409) return "conflict";
if (status >= 400 && status < 500) return "rejected";
return "failed";
}
export function createBirthTimeConversationPostHandler(
dependencies: BirthTimeConversationPostDependencies,
) {
return async function handleBirthTimeConversationPost(request: Request): Promise<Response> {
const requestId = dependencies.createRequestId?.(request) ?? stableRequestId(request);
let actionId: string | null = null;
let caseId: string | null = null;
const startedAt = dependencies.now?.() ?? Date.now();
const now = dependencies.now ?? Date.now;
const telemetry = dependencies.telemetry
? createConversationalRectificationTelemetry(dependencies.telemetry)
: recordConversationalRectificationTelemetry;
const deploymentSha = safeConversationalRectificationDeploymentSha(
dependencies.deploymentSha
?? process.env.GITHUB_SHA
?? process.env.VERCEL_GIT_COMMIT_SHA
?? process.env.NEXT_PUBLIC_GIT_COMMIT,
);
dependencies.createRequestId?.(request);
let actionKind: ConversationalRectificationTelemetryPayload["actionKind"] = "unknown";
try {
const authenticated = await dependencies.authenticate(request);
if (!authenticated) return errorResponse(new ConversationalRectificationError("authentication_required"));
if (!authenticated) throw new ConversationalRectificationError("authentication_required");
const parsed = conversationalRectificationCommandSchema.safeParse(await requestPayload(request));
if (!parsed.success) return errorResponse(new ConversationalRectificationError("invalid_command"));
actionId = parsed.data.actionId;
caseId = parsed.data.type === "start" ? parsed.data.actionId : parsed.data.caseId;
if (!parsed.success) throw new ConversationalRectificationError("invalid_command");
actionKind = parsed.data.type;
const service = await dependencies.createService(authenticated);
return Response.json(await dispatch(service, authenticated.userId, parsed.data));
const turn = await dispatch(service, authenticated.userId, parsed.data);
telemetry({
protocol: "conversational-evidence-v3",
phase: telemetryPhase(turn),
actionKind,
resultCategory: "success",
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: actionKind === "start" ? "unknown" : "unchanged",
errorCategory: "none",
deploymentSha,
});
return Response.json(turn);
} catch (error) {
const publicError = toConversationalRectificationPublicError(error);
dependencies.log?.({ requestId, actionId, caseId, code: publicError.code });
dependencies.log?.({ code: publicError.code });
telemetry({
protocol: "conversational-evidence-v3",
phase: "entry",
actionKind,
resultCategory: telemetryResultCategory(publicError.status),
latencyBucket: conversationalRectificationLatencyBucket(now() - startedAt),
billingState: publicError.code === "billing_failed" ? "unknown" : "not_applicable",
errorCategory: telemetryErrorCategory(publicError.code),
deploymentSha,
});
return Response.json(publicError, { status: publicError.status });
}
};
@@ -715,10 +783,11 @@ const productionPost = createBirthTimeConversationPostHandler({
authenticate: authenticateProductionRequest,
createService: createProductionService,
createRequestId: stableRequestId,
deploymentSha: process.env.GITHUB_SHA
?? process.env.VERCEL_GIT_COMMIT_SHA
?? process.env.NEXT_PUBLIC_GIT_COMMIT,
log(entry) {
console.error(
`[birth-time-conversation] request=${entry.requestId} action=${entry.actionId ?? "none"} case=${entry.caseId ?? "none"} code=${entry.code}`,
);
console.error(`[birth-time-conversation] code=${entry.code}`);
},
});
+16
View File
@@ -12,6 +12,10 @@ const gitCommit =
?? 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 envCheck(names: string[]): Check {
const missing = names.filter((name) => !process.env[name]);
@@ -66,6 +70,9 @@ export async function GET() {
jyotishApi: await jyotishApiCheck(),
};
const status = aggregate(checks);
const rectificationV3Ready = rectificationV3CreationEnabled
&& rectificationV3MigrationsReady
&& gitCommit !== "unknown";
return NextResponse.json(
{
status,
@@ -73,6 +80,15 @@ export async function GET() {
deployment: {
gitCommit,
},
rollout: {
conversationalRectificationV3: {
protocol: "conversational-evidence-v3",
newCaseCreation: rectificationV3CreationEnabled ? "enabled" : "paused",
migrations: rectificationV3MigrationsReady ? "ready" : "unverified",
syntheticSmoke: "required",
readyForNewCases: rectificationV3Ready,
},
},
checks,
},
{ status: status === "ok" ? 200 : 503 },
@@ -1,6 +1,118 @@
import { z } from "zod";
import type { VersionedJourneyResponse } from "./birth-time-journey-service.ts";
export const conversationalRectificationTelemetryProtocols = [
"conversational-evidence-v3",
] as const;
export const conversationalRectificationTelemetryPhases = [
"entry",
"collecting_evidence",
"paused",
"confirming",
"completed",
"abandoned",
] as const;
export const conversationalRectificationTelemetryActionKinds = [
"start",
"resume",
"answer",
"pause",
"abandon",
"confirm",
"unknown",
] as const;
export const conversationalRectificationTelemetryResultCategories = [
"success",
"rejected",
"conflict",
"failed",
] as const;
export const conversationalRectificationTelemetryLatencyBuckets = [
"lt_100ms",
"100_499ms",
"500_1999ms",
"2s_plus",
] as const;
export const conversationalRectificationTelemetryBillingStates = [
"not_applicable",
"charged",
"released",
"migration_waived",
"unchanged",
"unknown",
] as const;
export const conversationalRectificationTelemetryErrorCategories = [
"none",
"authentication",
"validation",
"conflict",
"billing",
"dependency",
"unknown",
] as const;
const deploymentShaSchema = z.union([
z.string().regex(/^[0-9a-f]{7,64}$/),
z.literal("unknown"),
]);
export const conversationalRectificationTelemetryPayloadSchema = z.object({
protocol: z.enum(conversationalRectificationTelemetryProtocols),
phase: z.enum(conversationalRectificationTelemetryPhases),
actionKind: z.enum(conversationalRectificationTelemetryActionKinds),
resultCategory: z.enum(conversationalRectificationTelemetryResultCategories),
latencyBucket: z.enum(conversationalRectificationTelemetryLatencyBuckets),
billingState: z.enum(conversationalRectificationTelemetryBillingStates),
errorCategory: z.enum(conversationalRectificationTelemetryErrorCategories),
deploymentSha: deploymentShaSchema,
}).strict().readonly();
export type ConversationalRectificationTelemetryPayload = z.infer<
typeof conversationalRectificationTelemetryPayloadSchema
>;
export type ConversationalRectificationTelemetrySink = (
payload: ConversationalRectificationTelemetryPayload,
) => void;
function conversationalRectificationConsoleSink(
payload: ConversationalRectificationTelemetryPayload,
): void {
console.info("[conversational-rectification]", JSON.stringify(payload));
}
export function createConversationalRectificationTelemetry(
sink: ConversationalRectificationTelemetrySink = conversationalRectificationConsoleSink,
): ConversationalRectificationTelemetrySink {
return (input) => {
const payload = conversationalRectificationTelemetryPayloadSchema.parse(input);
try {
sink(payload);
} catch { // no-excuse-ok: observability cannot break the product request
return;
}
};
}
export const recordConversationalRectificationTelemetry =
createConversationalRectificationTelemetry();
export function conversationalRectificationLatencyBucket(
latencyMs: number,
): ConversationalRectificationTelemetryPayload["latencyBucket"] {
if (!Number.isFinite(latencyMs) || latencyMs < 0) return "2s_plus";
if (latencyMs < 100) return "lt_100ms";
if (latencyMs < 500) return "100_499ms";
if (latencyMs < 2_000) return "500_1999ms";
return "2s_plus";
}
export function safeConversationalRectificationDeploymentSha(
value: string | undefined,
): ConversationalRectificationTelemetryPayload["deploymentSha"] {
const normalized = value?.trim().toLowerCase() ?? "";
return /^[0-9a-f]{7,64}$/.test(normalized) ? normalized : "unknown";
}
export const journeyMetricNames = [
"turn_advanced",
"draft_corrected",
@@ -71,6 +71,7 @@ export type ConversationalRectificationServicePorts = Readonly<{
& Partial<Pick<ConversationalRectificationStore, "importLegacy">>;
billing: Pick<ConversationalRectificationBilling, "reserve" | "complete" | "release">;
rectificationPriceCredits: number;
allowNewCaseCreation?: boolean;
loadDeclaredProfile(userId: string): Promise<ConversationalRectificationProfile>;
loadLegacyCase?(
userId: string,
@@ -518,6 +519,9 @@ export function createConversationalRectificationService(
&& current.billingState === "migration_waived") {
throw new ConversationalRectificationError("action_conflict");
}
if (ports.allowNewCaseCreation === false) {
throw new ConversationalRectificationError("service_unavailable");
}
const legacy = await loadLegacy(userId, legacyCaseId);
if (!legacy) throw new ConversationalRectificationError("case_not_found");
@@ -690,6 +694,9 @@ export function createConversationalRectificationService(
}
return publicTurn(existing);
}
if (ports.allowNewCaseCreation === false) {
throw new ConversationalRectificationError("service_unavailable");
}
let reserved = false;
try {
@@ -0,0 +1,722 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createBirthTimeConversationPostHandler } from "../src/app/api/birth-time-conversation/route.ts";
import {
clearBirthTimeConsultationConsent,
createBirthTimeConsultationConsentState,
grantBirthTimeConsultationConsent,
resolveBirthTimeConsultationRoute,
} from "../src/lib/birth-time-consultation-consent.ts";
import { isDeclaredBirthProfileComplete } from "../src/lib/birth-time-intake-model.ts";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
sendConversationalRectificationCommand,
} from "../src/lib/conversational-rectification/client.ts";
import {
conversationalRectificationTurnSchema,
type ConversationalRectificationTurn,
} from "../src/lib/conversational-rectification/contracts.ts";
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
import {
createConversationalRectificationService,
type ConversationalRectificationServicePorts,
} from "../src/lib/conversational-rectification/orchestrator.ts";
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
import type {
LoadedConversationalRectificationCase,
StoredConversationalRectificationCase,
} from "../src/lib/conversational-rectification/store.ts";
import { createRectificationQuestionHandoffCoordinator } from "../src/lib/rectification-question-handoff.ts";
import type { ConversationalRectificationTelemetryPayload } from "../src/lib/birth-time-journey-telemetry.ts";
import { createConversationalRectificationTelemetry } from "../src/lib/birth-time-journey-telemetry.ts";
const userId = "00000000-0000-4000-8000-000000009001";
const caseId = "00000000-0000-4000-8000-000000009002";
const originalQuestion = "我下一次适合换工作的时间是什么时候?";
const deploymentSha = "0123456789abcdef0123456789abcdef01234567";
const declaredBirthInput = {
source: "approximate" as const,
birthDate: "1990-01-01",
reportedTime: "05:30",
uncertaintyBeforeMinutes: 30 as const,
uncertaintyAfterMinutes: 30 as const,
birthTimeClue: "家人只记得天刚亮",
birthplace: {
countryCode: "CN",
provinceCode: "130000",
cityCode: "130400",
districtCode: "130406",
latitude: 36.420487,
longitude: 114.209936,
timezoneOffset: 8,
},
};
const onboardingDraft = {
date: "1990-01-01",
time: "05:30",
reportedTime: "05:30",
birthTimeStatus: "reported" as const,
birthTimeSource: "approximate" as const,
birthTimePeriod: "" as const,
birthTimeClue: "家人只记得天刚亮",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
countryCode: "CN",
provinceCode: "130000",
cityCode: "130400",
districtCode: "130406",
city: "合成测试城市",
latitude: 36.420487,
longitude: 114.209936,
timezoneOffset: 8,
};
function technicalPacket(ready: boolean): RectificationTechnicalPacket {
return {
calculationVersion: "synthetic-rectification-v3",
candidate: {
status: ready ? "ready_for_confirmation" : "pending_validation",
representativeTime: ready ? "05:18" : "05:30",
range: ready
? { startTime: "05:16", endTime: "05:20" }
: { startTime: "05:00", endTime: "06:00" },
},
useBoundary: ready
? "候选已达到确认门槛,但明确确认前仍使用旧的账户排盘时间。"
: "当前时间只是待验证候选,不能当作已经校正完成的出生分钟。",
candidateModelRefs: ["synthetic-model-ref"],
candidateDifferenceRefs: ["synthetic-d1", "synthetic-d9", "synthetic-d10", "synthetic-d24"],
candidateWeights: ready ? { "05:18": 0.75, "05:19": 0.25 } : { "05:30": 0.5 },
partitionIds: ["private-synthetic-partition"],
d1Stability: "stable",
boundaryDistanceMinutes: ready ? 2 : 30,
sensitivityScope: {
source: "time_linked_candidate_scan_samples",
rangeStart: ready ? "05:16" : "05:00",
rangeEnd: ready ? "05:20" : "06:00",
sampleTimes: ready ? ["05:16", "05:18", "05:20"] : ["05:00", "05:30", "06:00"],
},
stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["synthetic-d1"] }],
sensitiveLayers: [
{ layer: "D9", values: ["Aries", "Leo"], referenceIds: ["synthetic-d9"] },
{ layer: "D10", values: ["Taurus", "Libra"], referenceIds: ["synthetic-d10"] },
{ layer: "D24", values: ["Gemini", "Virgo"], referenceIds: ["synthetic-d24"] },
],
supportedSensitiveLayers: ["D9", "D10", "D24"],
scoredHistoricalEvidence: ready ? [{
evidenceId: "00000000-0000-4000-8000-000000009099",
domain: "career",
candidateTime: "05:18",
score: 8,
ruleRefs: ["synthetic-history-rule"],
}] : [],
suggestedDomains: [
{ domain: "career", layer: "D10", reason: "D10 在候选范围内变化,已发生的事业事件可以区分候选。" },
{ domain: "education", layer: "D24", reason: "D24 在候选范围内变化,已发生的学业事件可以区分候选。" },
{ domain: "relocation", layer: "D9", reason: "D9 在候选范围内变化,已发生的搬迁事件可以区分候选。" },
],
referenceIds: ["synthetic-d1", "synthetic-d9", "synthetic-d10", "synthetic-d24", "synthetic-history-rule"],
futureWindows: [{
label: "未来背景窗口",
startDate: "2027-01-01",
endDate: "2027-03-31",
scoreable: false,
}],
};
}
function narrativeGenerator() {
return {
modelId: "synthetic-grounded-narrator",
async generate(prompt: string) {
const request = JSON.parse(prompt) as {
phase: "first" | "intermediate" | "final";
packet: ReturnType<typeof technicalPacket> & {
candidate: ReturnType<typeof technicalPacket>["candidate"] & {
rangeStart: string;
rangeEnd: string;
};
};
};
const packet = request.packet;
const final = request.phase === "final";
return { text: JSON.stringify({
narrative: [
`${packet.candidate.representativeTime} 是待验证候选,范围为 ${packet.candidate.rangeStart}${packet.candidate.rangeEnd}`,
"D1 的 Cancer 在范围内保持稳定。",
"D9 的 Aries / Leo 存在分钟敏感差异,搬迁事件可以区分 D9。",
"D10 的 Taurus / Libra 存在分钟敏感差异,事业事件可以区分 D10。",
"D24 的 Gemini / Virgo 存在分钟敏感差异,学业事件可以区分 D24。",
final ? "现有已发生事件支持进入候选确认。" : "请写一件已经发生的真实事件,注明哪一年、哪一月以及发生了什么。",
packet.useBoundary,
].join(""),
candidateStatus: packet.candidate.status,
representativeTime: packet.candidate.representativeTime,
rangeStart: packet.candidate.rangeStart,
rangeEnd: packet.candidate.rangeEnd,
useBoundary: packet.useBoundary,
stableLayers: packet.stableLayers.map((item) => item.layer),
sensitiveLayers: packet.sensitiveLayers.map((item) => item.layer),
referenceIds: [],
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
evidenceRequest: final ? null : {
domains: packet.suggestedDomains.map((item) => item.domain),
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
},
}) };
},
};
}
type Receipt = Readonly<{
kind: "save_turn" | "pause" | "abandon" | "confirm";
expectedVersion: number;
fingerprint: string;
row: StoredConversationalRectificationCase;
}>;
function createSyntheticBackend(options: { legacy?: boolean; allowNewCaseCreation?: boolean } = {}) {
const cases = new Map<string, LoadedConversationalRectificationCase>();
const receipts = new Map<string, Receipt>();
let activeTime = "04:58";
let billingState: LoadedConversationalRectificationCase["billingState"] = null;
let reserveCount = 0;
let chargeCount = 0;
let releaseCount = 0;
const legacyCaseId = "00000000-0000-4000-8000-000000009080";
const withBilling = (state: LoadedConversationalRectificationCase["billingState"]) => {
billingState = state;
for (const [id, row] of cases) cases.set(id, { ...row, billingState: state });
};
const save = (input: {
row?: LoadedConversationalRectificationCase;
userId: string;
caseId: string;
turn: unknown;
privateCandidate: LoadedConversationalRectificationCase["privateCandidate"];
evidence?: LoadedConversationalRectificationCase["eventEvidence"];
receipt?: LoadedConversationalRectificationCase["validationReceipts"][number];
importedFromCaseId?: string | null;
}): LoadedConversationalRectificationCase => {
const prior = input.row;
const turn = conversationalRectificationTurnSchema.parse(input.turn);
const next = {
caseId: input.caseId,
userId: input.userId,
status: turn.status,
turnVersion: turn.turnVersion,
revisionOfCaseId: prior?.revisionOfCaseId ?? "00000000-0000-4000-8000-000000009000",
importedFromCaseId: input.importedFromCaseId ?? prior?.importedFromCaseId ?? null,
baselineActiveTime: "04:58",
pendingConsultationQuestion: turn.pendingConsultationQuestion,
billingState,
latestTurn: structuredClone(turn),
declaredBirthInput,
privateCandidate: structuredClone(input.privateCandidate),
eventEvidence: structuredClone(input.evidence ?? prior?.eventEvidence ?? []),
validationReceipts: [
...(prior?.validationReceipts ?? []),
...(input.receipt ? [structuredClone(input.receipt)] : []),
],
} satisfies LoadedConversationalRectificationCase;
cases.set(input.caseId, next);
return next;
};
const replay = (input: {
userId: string;
caseId: string;
expectedVersion: number;
actionId: string;
commandFingerprint: string;
}, kind: Receipt["kind"], mutate: () => LoadedConversationalRectificationCase) => {
const prior = receipts.get(input.actionId);
if (prior) {
if (prior.kind !== kind || prior.expectedVersion !== input.expectedVersion
|| prior.fingerprint !== input.commandFingerprint) throw new ConversationalRectificationError("action_conflict");
return prior.row;
}
const row = mutate();
receipts.set(input.actionId, { kind, expectedVersion: input.expectedVersion, fingerprint: input.commandFingerprint, row });
return row;
};
const store: ConversationalRectificationServicePorts["store"] = {
async loadActionReceipt(input) {
const prior = receipts.get(input.actionId);
if (!prior) return null;
if (prior.kind !== input.actionKind || prior.expectedVersion !== input.expectedVersion
|| prior.fingerprint !== input.commandFingerprint) throw new ConversationalRectificationError("action_conflict");
return prior.row;
},
async loadCase(input) {
const row = input.caseId ? cases.get(input.caseId) : [...cases.values()].at(-1);
return row?.userId === input.userId ? structuredClone(row) : null;
},
async createCaseWithFirstTurn(input) {
return save({
userId: input.userId,
caseId: input.caseId,
turn: input.firstTurn,
privateCandidate: input.privateCandidate,
receipt: input.validationReceipt,
});
},
async saveTurn(input) {
return replay(input, "save_turn", () => {
const row = cases.get(input.caseId);
if (!row || row.userId !== input.userId) throw new ConversationalRectificationError("case_not_found");
if (row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
return save({
row,
userId: input.userId,
caseId: input.caseId,
turn: input.turn,
privateCandidate: input.privateCandidate,
evidence: [...row.eventEvidence, ...input.evidence],
receipt: input.validationReceipt,
});
});
},
async pause(input) {
return replay(input, "pause", () => {
const row = cases.get(input.caseId);
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
privateCandidate: row.privateCandidate, receipt: input.validationReceipt });
});
},
async abandon(input) {
return replay(input, "abandon", () => {
const row = cases.get(input.caseId);
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
privateCandidate: row.privateCandidate, receipt: input.validationReceipt });
});
},
async confirm(input) {
return replay(input, "confirm", () => {
const row = cases.get(input.caseId);
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
if (row.privateCandidate.resultId !== input.resultId
|| row.privateCandidate.representativeTime !== input.time
|| row.privateCandidate.calculationVersion !== input.calculationVersion) {
throw new ConversationalRectificationError("candidate_changed");
}
activeTime = input.time;
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
privateCandidate: { ...row.privateCandidate, workingState: { phase: "confirmed", iteration: 4, notes: [] } },
receipt: input.validationReceipt });
});
},
async importLegacy(input) {
if (cases.has(input.caseId)) return cases.get(input.caseId)!;
withBilling("migration_waived");
return save({
userId: input.userId,
caseId: input.caseId,
turn: input.firstTurn,
privateCandidate: input.privateCandidate,
evidence: [...input.evidence],
receipt: input.validationReceipt,
importedFromCaseId: input.legacyCaseId,
});
},
};
const service = createConversationalRectificationService({
store,
billing: {
async reserve() { reserveCount += 1; withBilling("reserved"); return { success: true, credits: 97, billingState: "reserved" }; },
async complete() { chargeCount += 1; withBilling("charged"); return { success: true, credits: 97, billingState: "charged" }; },
async release() { releaseCount += 1; withBilling("released"); return { success: true, credits: 100, billingState: "released" }; },
},
rectificationPriceCredits: 3,
allowNewCaseCreation: options.allowNewCaseCreation,
async loadDeclaredProfile() {
return {
declaredBirthInput,
revisionOfCaseId: "00000000-0000-4000-8000-000000009000",
legacyCaseId: options.legacy ? legacyCaseId : null,
};
},
async loadLegacyCase(receivedUserId, receivedCaseId) {
if (!options.legacy || receivedUserId !== userId || receivedCaseId !== legacyCaseId) return null;
return {
caseId: legacyCaseId,
userId,
journeyProtocol: "dynamic-choice-v2",
status: "rectifying",
turnVersion: 4,
declaredBirthInput,
currentRange: { startTime: "05:10", endTime: "05:50" },
lifeEvents: [{
id: "00000000-0000-4000-8000-000000009081",
domain: "career",
precision: "month",
date: "2014-07",
}],
currentChoicePrompt: "2006-2011 还是 2011-2016",
choiceAnswers: ["A"],
};
},
async buildTechnicalPacket(input) {
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) {
return {
packet: {
...packet,
candidate: {
...packet.candidate,
status: "pending_validation",
range: { startTime: input.privateCandidate.rangeStart, endTime: input.privateCandidate.rangeEnd },
},
sensitivityScope: {
...packet.sensitivityScope,
rangeStart: input.privateCandidate.rangeStart,
rangeEnd: input.privateCandidate.rangeEnd,
},
},
resultId: null,
};
}
return { packet, resultId: ready ? "00000000-0000-4000-8000-000000009099" : null };
},
narrativeGenerator: narrativeGenerator(),
asOfDate: () => "2026-07-21",
});
return {
service,
cases,
billing: () => ({ reserveCount, chargeCount, releaseCount, state: billingState }),
activeTime: () => activeTime,
legacyCaseId,
};
}
async function post(
handler: (request: Request) => Promise<Response>,
command: Record<string, unknown>,
): Promise<ConversationalRectificationTurn> {
const response = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(command),
}));
const payload = await response.json();
assert.equal(response.status, 200, JSON.stringify(payload));
return payload as ConversationalRectificationTurn;
}
test("authenticated synthetic flow covers soft entry, rich evidence, resume, atomic confirmation, and handoff", async () => {
assert.equal(isDeclaredBirthProfileComplete(onboardingDraft), true, "onboarding may finish without rectification");
let consent = createBirthTimeConsultationConsentState();
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
kind: "choice", canUseUnverifiedTime: true,
});
consent = grantBirthTimeConsultationConsent(consent, "chat-a", "unverified_birth_time");
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
kind: "consult", mode: "unverified_birth_time", time: "05:30",
});
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-b").kind, "choice");
consent = clearBirthTimeConsultationConsent(consent, "chat-a");
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "choice");
const backend = createSyntheticBackend();
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
deploymentSha,
telemetry: (payload) => telemetry.push(payload),
now: (() => { let value = 0; return () => (value += 25); })(),
});
let turn = await post(handler, { type: "start", actionId: caseId, pendingConsultationQuestion: originalQuestion });
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
assert.equal(turn.status, "active");
assert.match(turn.narrative, /05:30.*待验证候选/);
assert.match(turn.narrative, /D1.*稳定/);
assert.match(turn.narrative, /D9.*敏感差异/);
assert.match(turn.narrative, /D10.*敏感差异/);
assert.match(turn.narrative, /哪一年、哪一月/);
assert.deepEqual(turn.evidenceRequest?.domains, ["career", "education", "relocation"]);
assert.equal(turn.evidenceRequest?.freeTextAllowed, true);
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(backend.activeTime(), "04:58", "revision must retain the old active minute");
turn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009003",
turnVersion: turn.turnVersion, domain: "career", answer: "都不符合,我想换一个方向",
});
assert.equal(turn.status, "active");
assert.match(turn.narrative, /不沿用不符合.*自由描述另一件已经发生/);
assert.equal(backend.billing().chargeCount, 1);
turn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009004",
turnVersion: turn.turnVersion, domain: "career", answer: "后来工作压力很大",
});
assert.match(turn.narrative, /还缺少.*明确时间/);
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
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");
turn = await post(handler, {
type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009006",
turnVersion: turn.turnVersion,
});
assert.equal(turn.status, "paused");
const secondDevice = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
deploymentSha,
telemetry: (payload) => telemetry.push(payload),
});
turn = await post(secondDevice, {
type: "resume", caseId, actionId: "00000000-0000-4000-8000-000000009007",
turnVersion: turn.turnVersion,
});
assert.equal(turn.status, "paused");
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
turn = await post(secondDevice, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009008",
turnVersion: turn.turnVersion, domain: "education", answer: "2011年6月大学毕业",
});
turn = await post(secondDevice, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009009",
turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
});
assert.equal(turn.status, "confirming");
assert.equal(turn.candidate.representativeTime, "05:18");
assert.equal(backend.activeTime(), "04:58");
const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009010",
turnVersion: turn.turnVersion, time: "05:17",
}),
}));
assert.equal(wrong.status, 409);
assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic");
turn = await post(secondDevice, {
type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011",
turnVersion: turn.turnVersion, time: "05:18",
});
assert.equal(turn.status, "completed");
assert.deepEqual(turn.actions, ["continue_original_question"]);
assert.equal(backend.activeTime(), "05:18");
assert.equal(backend.billing().chargeCount, 1);
let ordinaryReservations = 0;
let ordinaryAnswers = 0;
const handoff = createRectificationQuestionHandoffCoordinator<"timing">();
const continued = await handoff.continueOriginalQuestion(
turn.pendingConsultationQuestion ?? "",
{ sessionId: "new-device-chat", theme: "timing" },
async (context) => {
ordinaryReservations += 1;
ordinaryAnswers += 1;
assert.equal(context.question, originalQuestion);
assert.equal(backend.activeTime(), "05:18");
return true;
},
);
assert.equal(continued, true);
assert.equal(ordinaryReservations, 1);
assert.equal(ordinaryAnswers, 1);
const chats = new Set(["new-device-chat"]);
chats.delete("new-device-chat");
assert.equal(chats.size, 0);
const caseAfterChatDeletion = backend.cases.get(caseId);
assert.equal(caseAfterChatDeletion?.status, "completed", "chat deletion must not cascade to the account case");
const allowedTelemetryKeys = [
"protocol", "phase", "actionKind", "resultCategory", "latencyBucket",
"billingState", "errorCategory", "deploymentSha",
].sort();
assert.ok(telemetry.length >= 10);
for (const payload of telemetry) {
assert.deepEqual(Object.keys(payload).sort(), allowedTelemetryKeys);
assert.equal(payload.protocol, "conversational-evidence-v3");
assert.equal(payload.deploymentSha, deploymentSha);
const serialized = JSON.stringify(payload);
for (const forbidden of [originalQuestion, "1990-01-01", userId, caseId, "05:18", "05:30"]) {
assert.equal(serialized.includes(forbidden), false);
}
}
});
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 replay = await backend.service.start(userId, { type: "start", actionId: caseId });
assert.deepEqual(replay, first);
assert.equal(backend.cases.get(caseId)?.importedFromCaseId, backend.legacyCaseId);
assert.equal(backend.cases.get(caseId)?.billingState, "migration_waived");
assert.deepEqual(backend.billing(), { reserveCount: 0, chargeCount: 0, releaseCount: 0, state: "migration_waived" });
assert.deepEqual(first.candidate.rangeStart, "05:10");
assert.deepEqual(first.candidate.rangeEnd, "05:50");
assert.doesNotMatch(first.narrative, /2006-2011|2011-2016|哪个时间段/);
assert.equal(JSON.stringify(first).includes("choiceAnswers"), false);
});
test("v3 telemetry rejects every field outside the privacy-safe category contract", () => {
const emitted: unknown[] = [];
const record = createConversationalRectificationTelemetry((payload) => emitted.push(payload));
const valid = {
protocol: "conversational-evidence-v3",
phase: "collecting_evidence",
actionKind: "answer",
resultCategory: "success",
latencyBucket: "lt_100ms",
billingState: "unchanged",
errorCategory: "none",
deploymentSha,
} as const;
record(valid);
for (const forbidden of [
"narrative", "eventText", "birthDate", "birthTime", "email", "userId",
"accessToken", "refreshToken", "modelPrompt", "caseId", "actionId",
]) {
assert.throws(() => record({ ...valid, [forbidden]: "private" } as never));
}
assert.deepEqual(emitted, [valid]);
});
test("transient 502 replays the same command and terminal failures expose only stable Chinese copy", async () => {
const originalFetch = globalThis.fetch;
const bodies: string[] = [];
let attempts = 0;
globalThis.fetch = async (_input, init) => {
attempts += 1;
bodies.push(String(init?.body));
if (attempts === 1) return new Response("Bad gateway", { status: 502 });
return Response.json({
caseId,
journeyProtocol: "conversational-evidence-v3",
status: "active",
turnVersion: 0,
narrative: "合成安全响应。",
candidate: { status: "pending_validation", representativeTime: "05:30", rangeStart: "05:00", rangeEnd: "06:00" },
technicalReceipt: { calculationVersion: "synthetic-v1", stableLayers: ["D1"], sensitiveLayers: ["D9"], candidateDifferenceRefs: [] },
evidenceRequest: { domains: ["career", "education"], datePrecision: "month_preferred", freeTextAllowed: true },
evidenceRecap: [],
actions: ["answer", "pause", "abandon"],
pendingConsultationQuestion: null,
});
};
try {
const turn = await sendConversationalRectificationCommand({ type: "start", actionId: caseId });
assert.equal(turn.status, "active");
assert.equal(attempts, 2);
assert.equal(bodies[0], bodies[1]);
globalThis.fetch = async () => new Response("The string did not match the expected pattern", { status: 502 });
await assert.rejects(
sendConversationalRectificationCommand({ type: "start", actionId: caseId }),
(error: unknown) => error instanceof Error
&& error.message === CONVERSATIONAL_RECTIFICATION_UNAVAILABLE
&& !error.message.includes("expected pattern"),
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("health exposes deployment identity and explicit v3 rollout readiness without environment secrets", async () => {
const originalFetch = globalThis.fetch;
const prior = {
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,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
};
process.env.GITHUB_SHA = deploymentSha;
process.env.RECTIFICATION_V3_CREATE_ENABLED = "true";
process.env.RECTIFICATION_V3_MIGRATIONS_READY = "true";
process.env["SUPABASE_SERVICE_ROLE_KEY"] = "synthetic-runtime-secret-never-return";
globalThis.fetch = async () => Response.json({ status: "ok" });
try {
const { GET: healthGet } = await import(`../src/app/api/health/route.ts?e2e=${Date.now()}`);
const response = await healthGet();
const body = await response.json() as Record<string, unknown>;
assert.deepEqual(body.deployment, { gitCommit: deploymentSha });
assert.deepEqual(body.rollout, {
conversationalRectificationV3: {
protocol: "conversational-evidence-v3",
newCaseCreation: "enabled",
migrations: "ready",
syntheticSmoke: "required",
readyForNewCases: true,
},
});
assert.equal(JSON.stringify(body).includes("synthetic-runtime-secret-never-return"), false);
} finally {
globalThis.fetch = originalFetch;
for (const [key, value] of Object.entries(prior)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
test("rollback flag stops only new cases while existing v3 resume stays readable", async () => {
const enabled = createSyntheticBackend();
await enabled.service.start(userId, { type: "start", actionId: caseId });
const backend = createSyntheticBackend({ allowNewCaseCreation: false });
backend.cases.set(caseId, enabled.cases.get(caseId)!);
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: () => undefined,
});
const blockedStart = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: "00000000-0000-4000-8000-000000009050" }),
}));
assert.equal(blockedStart.status, 503);
const lostStartReplay = await post(handler, { type: "start", actionId: caseId });
assert.equal(lostStartReplay.caseId, caseId, "an existing start identity remains replayable");
const resumed = await post(handler, {
type: "resume", caseId, actionId: "00000000-0000-4000-8000-000000009051", turnVersion: 0,
});
assert.equal(resumed.caseId, caseId);
assert.equal(backend.activeTime(), "04:58");
});
test("a throwing injected telemetry sink cannot turn a committed request into failure", async () => {
const backend = createSyntheticBackend();
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: () => { throw new Error("synthetic telemetry outage"); },
});
const response = 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(response.status, 200);
assert.equal(backend.cases.get(caseId)?.status, "active");
assert.equal(backend.billing().chargeCount, 1);
});
@@ -284,7 +284,7 @@ test("known conflicts and unavailable failures use stable safe Chinese responses
assert.equal(response.status, status);
assert.equal(body.code, code);
assert.match(`${body.error}${body.message}`, /校正|服务|进度|稍后|重试/);
assert.deepEqual(logs, [{ requestId, actionId, caseId, code }]);
assert.deepEqual(logs, [{ code }]);
}
});
@@ -304,7 +304,7 @@ test("unknown SQL, model, and browser errors are never exposed or logged", async
assert.equal(response.status, 503);
assert.equal(serialized.includes(raw), false);
assert.equal(JSON.stringify(logs).includes(raw), false);
assert.deepEqual(logs, [{ requestId, actionId, caseId, code: "service_unavailable" }]);
assert.deepEqual(logs, [{ code: "service_unavailable" }]);
});
test("production profile conversion links terminal v3 revisions and owner-bound unfinished legacy imports", async () => {
@@ -0,0 +1,86 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MIGRATIONS = ROOT / "frontend" / "supabase" / "migrations"
V3_MIGRATIONS = tuple(sorted(MIGRATIONS.glob("2026072*conversational*.sql"))) + (
MIGRATIONS / "20260720040000_rectification_question_handoff.sql",
)
def _sql() -> str:
return "\n".join(path.read_text(encoding="utf-8") for path in V3_MIGRATIONS)
def test_v3_tables_and_mutation_rpcs_are_service_role_only() -> None:
sql = _sql().lower()
tables = (
"birth_time_rectification_turns",
"birth_time_rectification_event_evidence",
"birth_time_rectification_billing",
"birth_time_rectification_question_handoffs",
"birth_time_rectification_handoff_attach_receipts",
"birth_time_rectification_handoff_settlements",
)
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 = (
"create_conversational_rectification_case",
"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",
"claim_conversational_rectification_handoff",
"settle_conversational_rectification_handoff",
)
for function in mutation_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
def test_public_projection_excludes_private_weights_and_future_evidence_is_not_scored() -> 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:
fixture = (ROOT / "frontend/tests/conversational-rectification-e2e.test.ts").read_text(encoding="utf-8")
forbidden = (
r"sb-[a-z0-9]{16,}",
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"refresh_token",
r"auth-token",
)
for pattern in forbidden:
assert re.search(pattern, fixture, re.IGNORECASE) is None