From 576ebf9bf2a2af0f9ad2a81ea180c86a645ee893 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 21 Jul 2026 15:19:03 +0800 Subject: [PATCH] fix: gate rectification rollout to canary users --- deploy/README.md | 27 +++-- ...orted_birth_time_flow_issues_2026_07_20.md | 5 +- .../app/api/birth-time-conversation/route.ts | 6 +- frontend/src/app/api/health/route.ts | 27 ++--- .../creation-policy.ts | 87 ++++++++++++++ ...onal-rectification-creation-policy.test.ts | 69 +++++++++++ .../conversational-rectification-e2e.test.ts | 109 +++++++++++++++++- frontend/tests/health-deployment.test.ts | 22 +++- 8 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 frontend/src/lib/conversational-rectification/creation-policy.ts create mode 100644 frontend/tests/conversational-rectification-creation-policy.test.ts diff --git a/deploy/README.md b/deploy/README.md index 69db3983..685cd131 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -79,6 +79,8 @@ RECTIFICATION_V3_CREATE_ENABLED=true RECTIFICATION_V3_MIGRATIONS_READY=false # Set only after the authenticated synthetic smoke passes on this exact image. RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA= +# During canary only: one canonical synthetic account UUID. Never print or log it. +RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS= # Recommended multi-model catalog. The JSON references server-only keys. LLM_DEFAULT_MODEL_ID=deepseek-pro @@ -209,25 +211,33 @@ 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`, leave -`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` empty, and deploy the tested Git revision. -Creation is available for the authorized smoke account, but the revision is not -ready for general rollout until that smoke is recorded. +`RECTIFICATION_V3_CREATE_ENABLED=true`, set +`RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS` to exactly one canonical UUID for +the synthetic account, leave `RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` empty, and +deploy the tested Git revision. Never print, log, copy into a ticket, or return +that UUID from health or telemetry. Creation is available only for the +allowlisted smoke account; ordinary authenticated users can still resume and +finish existing cases but cannot start a paid or legacy-imported case. Before the smoke, fetch `https://jyotisha.chat/api/health` and verify the full deployment SHA, healthy dependencies, enabled creation, ready migrations, -`syntheticSmoke: pending`, and `readyForNewCases: false`. A missing, abbreviated, -malformed, or previous-revision smoke SHA must remain pending. +`creationAudience: smoke_only`, `syntheticSmoke: pending`, and +`readyForNewCases: false`. A missing, abbreviated, malformed, or +previous-revision smoke SHA must remain pending. If the create flag, migration +flag, deployment SHA, or strict UUID allowlist is invalid, creation audience +must be `paused`, including for the smoke account. After the smoke sequence below passes, set `RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character -lowercase Git SHA and restart the web container. Then fetch health again and +lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, and +restart the web container. Then fetch health again and verify all of the following against the revision that passed validation: - `deployment.gitCommit` exactly equals the tested 40-character Git SHA; - `rollout.conversationalRectificationV3.protocol` is `conversational-evidence-v3`; - `newCaseCreation` and `migrations` are `enabled` and `ready`; +- `creationAudience` is `public`; - `syntheticSmoke` is `matched`; - `readyForNewCases` is `true`; - ordinary health checks remain healthy. The health response must never contain @@ -265,7 +275,8 @@ from telemetry. Rollback is forward-compatible and non-destructive. First set `RECTIFICATION_V3_CREATE_ENABLED=false`, clear -`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA`, and redeploy a revision that can still +`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` and +`RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, 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, 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 index 5af3bc96..873d5288 100644 --- 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 @@ -24,4 +24,7 @@ the tested deployment SHA. 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`. +non-destructive rollback are defined in `deploy/README.md`. The executable +creation policy keeps rollout `smoke_only` for one unlogged synthetic account +until the smoke SHA matches the exact deployed revision; ordinary users cannot +incur a new rectification charge during that canary window. diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 6b6f504a..6949651e 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -23,6 +23,7 @@ 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 { conversationalRectificationCreationPolicyFromEnvironment } from "../../../lib/conversational-rectification/creation-policy.ts"; import { conversationalRectificationLatencyBucket, createConversationalRectificationTelemetry, @@ -589,8 +590,9 @@ async function createProductionService( store: createSupabaseConversationalRectificationStore(admin), billing: createSupabaseConversationalRectificationBilling(admin), get rectificationPriceCredits() { return priceCredits(); }, - allowNewCaseCreation: - process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false", + allowNewCaseCreation: conversationalRectificationCreationPolicyFromEnvironment( + authenticated.userId, + ).allowNewCaseCreation, async loadDeclaredProfile(userId) { return loadProductionConversationalRectificationProfile({ async loadProfile(receivedUserId) { diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index 99ac02e8..25927299 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -1,4 +1,8 @@ import { NextResponse } from "next/server"; +import { + conversationalRectificationCreationPolicyFromEnvironment, + conversationalRectificationDeploymentShaFromEnvironment, +} from "../../../lib/conversational-rectification/creation-policy.ts"; type Check = { status: "ok" | "degraded" | "blocked"; @@ -7,12 +11,6 @@ type Check = { }; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -function deployedGitCommit(): string { - return process.env.GITHUB_SHA - ?? process.env.VERCEL_GIT_COMMIT_SHA - ?? process.env.NEXT_PUBLIC_GIT_COMMIT - ?? "unknown"; -} function envCheck(names: string[]): Check { const missing = names.filter((name) => !process.env[name]); @@ -59,12 +57,10 @@ function aggregate(checks: Record) { } export async function GET() { - const gitCommit = deployedGitCommit(); - const rectificationV3CreationEnabled = - process.env.RECTIFICATION_V3_CREATE_ENABLED?.trim().toLowerCase() !== "false"; + const gitCommit = conversationalRectificationDeploymentShaFromEnvironment(); const rectificationV3MigrationsReady = process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true"; - const smokeSha = process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA?.trim().toLowerCase() ?? ""; + const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment(); const checks = { web: { status: "ok" } satisfies Check, supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]), @@ -73,12 +69,8 @@ export async function GET() { jyotishApi: await jyotishApiCheck(), }; const status = aggregate(checks); - const deploymentHasFullSha = /^[0-9a-f]{40}$/.test(gitCommit); - const smokeMatchesDeployment = deploymentHasFullSha && smokeSha === gitCommit; const rectificationV3Ready = status === "ok" - && rectificationV3CreationEnabled - && rectificationV3MigrationsReady - && smokeMatchesDeployment; + && creationPolicy.audience === "public"; return NextResponse.json( { status, @@ -89,9 +81,10 @@ export async function GET() { rollout: { conversationalRectificationV3: { protocol: "conversational-evidence-v3", - newCaseCreation: rectificationV3CreationEnabled ? "enabled" : "paused", + newCaseCreation: creationPolicy.audience === "paused" ? "paused" : "enabled", + creationAudience: creationPolicy.audience, migrations: rectificationV3MigrationsReady ? "ready" : "unverified", - syntheticSmoke: smokeMatchesDeployment ? "matched" : "pending", + syntheticSmoke: creationPolicy.smokeMatchesDeployment ? "matched" : "pending", readyForNewCases: rectificationV3Ready, }, }, diff --git a/frontend/src/lib/conversational-rectification/creation-policy.ts b/frontend/src/lib/conversational-rectification/creation-policy.ts new file mode 100644 index 00000000..d6296617 --- /dev/null +++ b/frontend/src/lib/conversational-rectification/creation-policy.ts @@ -0,0 +1,87 @@ +export type ConversationalRectificationCreationAudience = + | "paused" + | "smoke_only" + | "public"; + +export type ConversationalRectificationCreationPolicyInput = Readonly<{ + userId?: string | null; + creationEnabled?: string; + migrationsReady?: string; + deploymentSha?: string; + smokeSha?: string; + syntheticSmokeUserIds?: string; +}>; + +export type ConversationalRectificationCreationPolicy = Readonly<{ + audience: ConversationalRectificationCreationAudience; + allowNewCaseCreation: boolean; + smokeMatchesDeployment: boolean; +}>; + +const fullDeploymentSha = /^[0-9a-f]{40}$/; +const canonicalUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +export function conversationalRectificationCreationPolicy( + input: ConversationalRectificationCreationPolicyInput, +): ConversationalRectificationCreationPolicy { + const deploymentSha = input.deploymentSha?.trim() ?? ""; + const smokeSha = input.smokeSha?.trim() ?? ""; + const baseGatesOpen = input.creationEnabled?.trim().toLowerCase() === "true" + && input.migrationsReady?.trim().toLowerCase() === "true" + && fullDeploymentSha.test(deploymentSha); + if (!baseGatesOpen) { + return { + audience: "paused", + allowNewCaseCreation: false, + smokeMatchesDeployment: false, + }; + } + + const smokeMatchesDeployment = smokeSha === deploymentSha; + if (smokeMatchesDeployment) { + return { + audience: "public", + allowNewCaseCreation: true, + smokeMatchesDeployment: true, + }; + } + + const smokeUsers = new Set( + (input.syntheticSmokeUserIds ?? "") + .split(",") + .map((value) => value.trim()) + .filter((value) => canonicalUuid.test(value)), + ); + if (smokeUsers.size === 0) { + return { + audience: "paused", + allowNewCaseCreation: false, + smokeMatchesDeployment: false, + }; + } + return { + audience: "smoke_only", + allowNewCaseCreation: input.userId != null && smokeUsers.has(input.userId), + smokeMatchesDeployment: false, + }; +} + +export function conversationalRectificationCreationPolicyFromEnvironment( + userId?: string | null, +): ConversationalRectificationCreationPolicy { + return conversationalRectificationCreationPolicy({ + userId, + creationEnabled: process.env.RECTIFICATION_V3_CREATE_ENABLED, + migrationsReady: process.env.RECTIFICATION_V3_MIGRATIONS_READY, + deploymentSha: conversationalRectificationDeploymentShaFromEnvironment(), + smokeSha: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA, + syntheticSmokeUserIds: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS, + }); +} + +export function conversationalRectificationDeploymentShaFromEnvironment(): string { + return process.env.GITHUB_SHA + ?? process.env.VERCEL_GIT_COMMIT_SHA + ?? process.env.NEXT_PUBLIC_GIT_COMMIT + ?? "unknown"; +} diff --git a/frontend/tests/conversational-rectification-creation-policy.test.ts b/frontend/tests/conversational-rectification-creation-policy.test.ts new file mode 100644 index 00000000..f7c61ca1 --- /dev/null +++ b/frontend/tests/conversational-rectification-creation-policy.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + conversationalRectificationCreationPolicy, +} from "../src/lib/conversational-rectification/creation-policy.ts"; + +const deploymentSha = "0123456789abcdef0123456789abcdef01234567"; +const smokeUser = "00000000-0000-4000-8000-000000009001"; +const ordinaryUser = "00000000-0000-4000-8000-0000000090ab"; + +function policy(overrides: Partial[0]> = {}) { + return conversationalRectificationCreationPolicy({ + userId: ordinaryUser, + creationEnabled: "true", + migrationsReady: "true", + deploymentSha, + smokeSha: "", + syntheticSmokeUserIds: smokeUser, + ...overrides, + }); +} + +test("creation pauses unless every base rollout gate is explicitly valid", () => { + for (const overrides of [ + { creationEnabled: "false" }, + { creationEnabled: undefined }, + { migrationsReady: "false" }, + { migrationsReady: undefined }, + { deploymentSha: "deadbee" }, + { deploymentSha: deploymentSha.toUpperCase() }, + ]) { + assert.deepEqual(policy(overrides), { + audience: "paused", + allowNewCaseCreation: false, + smokeMatchesDeployment: false, + }); + } +}); + +test("pending smoke admits only strictly allowlisted UUIDs and ignores malformed entries", () => { + const allowlist = `bad,${smokeUser},${ordinaryUser.toUpperCase()},not-a-uuid`; + assert.deepEqual(policy({ userId: smokeUser, syntheticSmokeUserIds: allowlist }), { + audience: "smoke_only", + allowNewCaseCreation: true, + smokeMatchesDeployment: false, + }); + assert.deepEqual(policy({ userId: ordinaryUser, syntheticSmokeUserIds: allowlist }), { + audience: "smoke_only", + allowNewCaseCreation: false, + smokeMatchesDeployment: false, + }); + assert.deepEqual(policy({ syntheticSmokeUserIds: "bad,not-a-uuid" }), { + audience: "paused", + allowNewCaseCreation: false, + smokeMatchesDeployment: false, + }); +}); + +test("matching smoke SHA opens creation to every authenticated user", () => { + assert.deepEqual(policy({ + userId: ordinaryUser, + smokeSha: deploymentSha, + syntheticSmokeUserIds: "", + }), { + audience: "public", + allowNewCaseCreation: true, + smokeMatchesDeployment: true, + }); +}); diff --git a/frontend/tests/conversational-rectification-e2e.test.ts b/frontend/tests/conversational-rectification-e2e.test.ts index 234cce4e..e6078b0a 100644 --- a/frontend/tests/conversational-rectification-e2e.test.ts +++ b/frontend/tests/conversational-rectification-e2e.test.ts @@ -29,6 +29,7 @@ import type { 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"; +import { conversationalRectificationCreationPolicy } from "../src/lib/conversational-rectification/creation-policy.ts"; const userId = "00000000-0000-4000-8000-000000009001"; const caseId = "00000000-0000-4000-8000-000000009002"; @@ -736,6 +737,7 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho RECTIFICATION_V3_CREATE_ENABLED: process.env.RECTIFICATION_V3_CREATE_ENABLED, RECTIFICATION_V3_MIGRATIONS_READY: process.env.RECTIFICATION_V3_MIGRATIONS_READY, RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA, + RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS, NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY, @@ -745,6 +747,7 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho process.env.RECTIFICATION_V3_CREATE_ENABLED = "true"; process.env.RECTIFICATION_V3_MIGRATIONS_READY = "true"; process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = deploymentSha; + process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS = userId; process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.invalid"; process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "synthetic-public-key"; process.env["SUPABASE_SERVICE_ROLE_KEY"] = "synthetic-runtime-secret-never-return"; @@ -759,12 +762,14 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho conversationalRectificationV3: { protocol: "conversational-evidence-v3", newCaseCreation: "enabled", + creationAudience: "public", migrations: "ready", syntheticSmoke: "matched", readyForNewCases: true, }, }); assert.equal(JSON.stringify(body).includes("synthetic-runtime-secret-never-return"), false); + assert.equal(JSON.stringify(body).includes(userId), false); } finally { globalThis.fetch = originalFetch; for (const [key, value] of Object.entries(prior)) { @@ -774,10 +779,87 @@ test("health exposes deployment identity and explicit v3 rollout readiness witho } }); +test("pending smoke policy blocks ordinary paid and legacy starts before billing", async () => { + const ordinaryUser = "00000000-0000-4000-8000-000000009099"; + const policyInput = { + creationEnabled: "true", + migrationsReady: "true", + deploymentSha, + smokeSha: "", + syntheticSmokeUserIds: userId, + } as const; + const ordinaryPolicy = conversationalRectificationCreationPolicy({ + ...policyInput, + userId: ordinaryUser, + }); + assert.equal(ordinaryPolicy.audience, "smoke_only"); + assert.equal(ordinaryPolicy.allowNewCaseCreation, false); + + for (const legacy of [false, true]) { + const backend = createSyntheticBackend({ + legacy, + allowNewCaseCreation: ordinaryPolicy.allowNewCaseCreation, + }); + const handler = createBirthTimeConversationPostHandler({ + authenticate: async () => ({ userId: ordinaryUser, context: {} }), + createService: async () => backend.service, + telemetry: () => undefined, + }); + 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, 503); + assert.deepEqual(backend.billing(), { + reserveCount: 0, + chargeCount: 0, + releaseCount: 0, + state: null, + }); + assert.equal(backend.cases.size, 0); + } + + const smokePolicy = conversationalRectificationCreationPolicy({ ...policyInput, userId }); + const smokeBackend = createSyntheticBackend({ + allowNewCaseCreation: smokePolicy.allowNewCaseCreation, + }); + await smokeBackend.service.start(userId, { type: "start", actionId: caseId }); + assert.deepEqual(smokeBackend.billing(), { + reserveCount: 1, + chargeCount: 1, + releaseCount: 0, + state: "charged", + }); + + const publicPolicy = conversationalRectificationCreationPolicy({ + ...policyInput, + userId: ordinaryUser, + smokeSha: deploymentSha, + syntheticSmokeUserIds: "", + }); + const publicBackend = createSyntheticBackend({ + allowNewCaseCreation: publicPolicy.allowNewCaseCreation, + }); + await publicBackend.service.start(ordinaryUser, { type: "start", actionId: caseId }); + assert.equal(publicBackend.billing().chargeCount, 1); +}); + 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 }); + const pendingPolicy = conversationalRectificationCreationPolicy({ + userId, + creationEnabled: "true", + migrationsReady: "true", + deploymentSha, + smokeSha: "", + syntheticSmokeUserIds: "00000000-0000-4000-8000-000000009099", + }); + assert.equal(pendingPolicy.audience, "smoke_only"); + const backend = createSyntheticBackend({ + allowNewCaseCreation: pendingPolicy.allowNewCaseCreation, + }); backend.cases.set(caseId, enabled.cases.get(caseId)!); const handler = createBirthTimeConversationPostHandler({ authenticate: async () => ({ userId, context: {} }), @@ -798,7 +880,30 @@ test("rollback flag stops only new cases while existing v3 resume stays readable type: "resume", caseId, actionId: "00000000-0000-4000-8000-000000009051", turnVersion: 0, }); assert.equal(resumed.caseId, caseId); - assert.equal(backend.activeTime(), "04:58"); + let existingTurn = await post(handler, { + type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009052", + turnVersion: resumed.turnVersion, domain: "career", answer: "2014年7月第一次正式入职", + }); + existingTurn = await post(handler, { + type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009053", + turnVersion: existingTurn.turnVersion, + }); + assert.equal(existingTurn.status, "paused"); + existingTurn = await post(handler, { + type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009054", + turnVersion: existingTurn.turnVersion, domain: "education", answer: "2011年6月大学毕业", + }); + existingTurn = await post(handler, { + type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009055", + turnVersion: existingTurn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活", + }); + assert.equal(existingTurn.status, "confirming"); + existingTurn = await post(handler, { + type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009056", + turnVersion: existingTurn.turnVersion, time: "05:18", + }); + assert.equal(existingTurn.status, "completed"); + assert.equal(backend.activeTime(), "05:18"); }); test("a throwing injected telemetry sink cannot turn a committed request into failure", async () => { diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 334ba3c1..09dfc949 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -42,7 +42,10 @@ function assertWorkflowUsesTestedSha(workflow: string) { } test("health endpoint exposes deployment identity for production verification", () => { - const source = readFileSync(new URL("../src/app/api/health/route.ts", import.meta.url), "utf8"); + const source = [ + readFileSync(new URL("../src/app/api/health/route.ts", import.meta.url), "utf8"), + readFileSync(new URL("../src/lib/conversational-rectification/creation-policy.ts", import.meta.url), "utf8"), + ].join("\n"); assert.match(source, /deployment:/); assert.match(source, /GITHUB_SHA/); @@ -90,7 +93,7 @@ test("v3 readiness requires healthy dependencies and smoke proof for the exact f "GITHUB_SHA", "NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY", "RECTIFICATION_V3_CREATE_ENABLED", "RECTIFICATION_V3_MIGRATIONS_READY", - "RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA", + "RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA", "RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS", ] as const; const prior = Object.fromEntries(keys.map((key) => [key, process.env[key]])); const originalFetch = globalThis.fetch; @@ -104,6 +107,7 @@ test("v3 readiness requires healthy dependencies and smoke proof for the exact f OPENAI_API_KEY: "synthetic-model-key", RECTIFICATION_V3_CREATE_ENABLED: "true", RECTIFICATION_V3_MIGRATIONS_READY: "true", + RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS: "00000000-0000-4000-8000-000000009001", }); globalThis.fetch = async () => Response.json({ status: "ok" }); @@ -113,6 +117,7 @@ test("v3 readiness requires healthy dependencies and smoke proof for the exact f status: string; rollout: { conversationalRectificationV3: { syntheticSmoke: string; + creationAudience: string; readyForNewCases: boolean; } }; }; @@ -124,6 +129,7 @@ test("v3 readiness requires healthy dependencies and smoke proof for the exact f assert.deepEqual((await readiness()).rollout.conversationalRectificationV3, { protocol: "conversational-evidence-v3", newCaseCreation: "enabled", + creationAudience: "smoke_only", migrations: "ready", syntheticSmoke: "pending", readyForNewCases: false, @@ -131,11 +137,18 @@ test("v3 readiness requires healthy dependencies and smoke proof for the exact f process.env.GITHUB_SHA = "deadbee"; process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = "deadbee"; - assert.equal((await readiness()).rollout.conversationalRectificationV3.readyForNewCases, false); + assert.deepEqual((await readiness()).rollout.conversationalRectificationV3, { + protocol: "conversational-evidence-v3", + newCaseCreation: "paused", + creationAudience: "paused", + migrations: "ready", + syntheticSmoke: "pending", + readyForNewCases: false, + }); process.env.GITHUB_SHA = currentSha; process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = oldSha; - assert.equal((await readiness()).rollout.conversationalRectificationV3.syntheticSmoke, "pending"); + assert.equal((await readiness()).rollout.conversationalRectificationV3.creationAudience, "smoke_only"); process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = currentSha; const ready = await readiness(); @@ -143,6 +156,7 @@ test("v3 readiness requires healthy dependencies and smoke proof for the exact f assert.deepEqual(ready.rollout.conversationalRectificationV3, { protocol: "conversational-evidence-v3", newCaseCreation: "enabled", + creationAudience: "public", migrations: "ready", syntheticSmoke: "matched", readyForNewCases: true,