diff --git a/deploy/README.md b/deploy/README.md index 7c37ff94..7a34c7c0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 diff --git a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md new file mode 100644 index 00000000..d8f6cab3 --- /dev/null +++ b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md @@ -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`. diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 751a5f33..a8f5af65 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -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; 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 { - 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}`); }, }); diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index e8cab627..5f22c0b3 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -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 }, diff --git a/frontend/src/lib/birth-time-journey-telemetry.ts b/frontend/src/lib/birth-time-journey-telemetry.ts index 34e5340a..43d73fc6 100644 --- a/frontend/src/lib/birth-time-journey-telemetry.ts +++ b/frontend/src/lib/birth-time-journey-telemetry.ts @@ -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", diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index e1a3ba02..64f85b8a 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -71,6 +71,7 @@ export type ConversationalRectificationServicePorts = Readonly<{ & Partial>; billing: Pick; rectificationPriceCredits: number; + allowNewCaseCreation?: boolean; loadDeclaredProfile(userId: string): Promise; 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 { diff --git a/frontend/tests/conversational-rectification-e2e.test.ts b/frontend/tests/conversational-rectification-e2e.test.ts new file mode 100644 index 00000000..75b58a5f --- /dev/null +++ b/frontend/tests/conversational-rectification-e2e.test.ts @@ -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 & { + candidate: ReturnType["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(); + const receipts = new Map(); + 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, + command: Record, +): Promise { + 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; + 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); +}); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index c3c3ddb2..0b999c64 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -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 () => { diff --git a/tests/test_conversational_rectification_security.py b/tests/test_conversational_rectification_security.py new file mode 100644 index 00000000..61965afb --- /dev/null +++ b/tests/test_conversational_rectification_security.py @@ -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