From 4deb5c2378740baa304686f0ab4f7809487b605d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 12 Aug 2026 21:20:51 +0800 Subject: [PATCH] fix(rectification): start new case from homepage --- docs/BUG_HISTORY.md | 16 ++ .../cases/entry-summary/route.ts | 2 +- .../app/api/rectification/cases/open/route.ts | 2 +- frontend/src/app/page.tsx | 4 +- frontend/src/lib/rectification-entry.ts | 6 +- ...000_allow_parallel_rectification_cases.sql | 147 ++++++++++++++++++ .../tests/consultation-entrypoint.test.ts | 3 +- .../tests/rectification-agentic-entry.test.ts | 4 +- .../rectification-v9-case-service.test.ts | 16 +- .../tests/rectification-v9-database.test.ts | 35 +++-- .../rectification-v9-entry-routing.test.ts | 26 ++-- .../tests/rectification-v9-migration.test.ts | 57 +++++++ 12 files changed, 273 insertions(+), 45 deletions(-) create mode 100644 frontend/supabase/migrations/20260813040000_allow_parallel_rectification_cases.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 63c56b0b..739ca99c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3020,3 +3020,19 @@ - 防复发:持久化行与对话消息不是一对一时,恢复投影必须显式展开全部逻辑消息,不得用 `coalesce` 静默舍弃其中一侧。 - 相关记录:BUG-173、BUG-174、BUG-175 - 修复版本:本次提交(staging 精确 SHA 以发布记录为准) + +## BUG-177 | 首页生时校正被未完成 Session 强制劫持,无法新建独立校正 + +- 状态:resolved(staging 发布与真实环境验收以本次发布记录为准) +- 首次发现:2026-08-12 +- 最近更新:2026-08-12 +- 影响面:首页生时校正卡片、`POST /api/rectification/cases/open`、V9 Case 并发约束 +- 用户现象:账户存在任意未完成的生时校正时,从首页点击“生时校正”会直接回到旧 Session;用户无法保留旧记录并另开一段校正。 +- 触发条件:存在 `draft`、`collecting_evidence`、`candidate_ready`、`candidate_accepted`、`needs_rebaseline` 或 `paused` Case 后点击首页生时校正卡片。 +- 根因:V9 初始设计把首页 `homepage` intent 定义为 resume-or-create,并用 `agentic_rectification_cases_one_resumable_per_user` 部分唯一索引和 `active_case_conflict` 强制每用户最多一个 resumable Case;首页 UI 又据 entry summary 显示“继续上次校正”。该安全约束错误扩大成产品限制。 +- 修复:首页入口始终作为显式创建动作;新增向前迁移 `20260813040000_allow_parallel_rectification_cases.sql`,删除每用户单 resumable 唯一索引并重定义 open RPC,使 `homepage`/`new` 创建独立 Case + Session,`session` 仍按精确 Session 恢复;requestId 幂等与同用户 advisory lock 保留。首页有未完成记录时明确提示可从左侧历史继续,但主 CTA 仍是新建。 +- 验证:入口、Case service、迁移静态合同等聚焦测试共 119 项,113 通过、0 失败;6 项真实 PostgreSQL 测试因本机 Docker 不可用跳过(测试已覆盖不同 requestId 新建多个 resumable Case、同 requestId 幂等和精确 Session 恢复)。目标 ESLint 与 `git diff --check` 通过;全量 TypeScript 检查仅命中仓库既有的 `dayjs` 缺失及无关测试类型错误。 +- 防复发:首页“新建”和历史“继续”必须使用不同 intent;不得用“存在 resumable Case”改变首页主 CTA 或阻止新 Case;同一 requestId 重试只能返回同一 Case。 +- 相关记录:BUG-163 +- 复发自:BUG-163 +- 修复版本:本次提交(staging 精确 SHA 以发布记录为准) diff --git a/frontend/src/app/api/rectification/cases/entry-summary/route.ts b/frontend/src/app/api/rectification/cases/entry-summary/route.ts index cd792d05..ea4fc57e 100644 --- a/frontend/src/app/api/rectification/cases/entry-summary/route.ts +++ b/frontend/src/app/api/rectification/cases/entry-summary/route.ts @@ -12,7 +12,7 @@ export const runtime = "nodejs"; /** * GET /api/rectification/cases/entry-summary * - * Server-truth homepage CTA: "开始生时校正" / "继续上次校正" / "再次校正". + * Server-truth context for homepage copy; the primary homepage action always creates. */ export async function GET() { let supabase; diff --git a/frontend/src/app/api/rectification/cases/open/route.ts b/frontend/src/app/api/rectification/cases/open/route.ts index 6294e490..43804989 100644 --- a/frontend/src/app/api/rectification/cases/open/route.ts +++ b/frontend/src/app/api/rectification/cases/open/route.ts @@ -28,7 +28,7 @@ function serviceErrorResponse(error: unknown) { * * Browser sends only { intent, requestId } (+ sessionId for intent=session). * The server derives the user from the session, normalizes the profile and - * decides resume-or-create. Double-click / multi-tab are idempotent. + * creates for homepage/new or restores the exact requested session. Retries are idempotent. */ export async function POST(request: Request) { let supabase; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index cf49ca53..e51b8aa2 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3423,8 +3423,8 @@ export default function Home() {
出生资料

生时校正

-

{rectificationCardAction === "resume" - ? "你有一段未完成的校正记录,可以从上次的位置继续。" +

{rectificationEntrySummary?.hasResumableCase + ? "新建一段独立校正;未完成的记录仍可从左侧历史会话继续。" : rectificationCardAction === "restart" ? "上一次校正已经完成,可以基于最新资料再次校正。" : "不确定准确出生时间时,可通过已经发生的人生事件逐步缩小范围。"}

diff --git a/frontend/src/lib/rectification-entry.ts b/frontend/src/lib/rectification-entry.ts index 082d7fad..1b6cbc9f 100644 --- a/frontend/src/lib/rectification-entry.ts +++ b/frontend/src/lib/rectification-entry.ts @@ -20,18 +20,16 @@ export type RectificationEntrySummary = Readonly<{ }> | null; }>; -export type RectificationCardAction = "start" | "resume" | "restart"; +export type RectificationCardAction = "start" | "restart"; export const rectificationEntryLabels: Readonly> = { - start: "开始生时校正", - resume: "继续上次校正", + start: "开始新的生时校正", restart: "再次校正", }; export function resolveRectificationEntryAction( summary: RectificationEntrySummary, ): RectificationCardAction { - if (summary.hasResumableCase) return "resume"; if (summary.hasTerminalCaseWithTime) return "restart"; return "start"; } diff --git a/frontend/supabase/migrations/20260813040000_allow_parallel_rectification_cases.sql b/frontend/supabase/migrations/20260813040000_allow_parallel_rectification_cases.sql new file mode 100644 index 00000000..2bb01b8d --- /dev/null +++ b/frontend/supabase/migrations/20260813040000_allow_parallel_rectification_cases.sql @@ -0,0 +1,147 @@ +begin; + +-- BUG-177: starting birth-time rectification from the homepage is an explicit +-- create action. Existing unfinished cases remain resumable through their +-- exact history/session entry, but no longer block a separate new case. +drop index if exists public.agentic_rectification_cases_one_resumable_per_user; + +create or replace function public.open_agentic_rectification_case( + p_user_id uuid, + p_request_id uuid, + p_intent text, + p_session_id uuid, + p_skill_name text, + p_skill_version text, + p_baseline_profile_fingerprint text, + p_baseline_birth_snapshot jsonb, + p_candidate_range jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_session public.chat_sessions%rowtype; + v_session_id uuid; +begin + if p_user_id is null or p_request_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + if p_intent not in ('homepage', 'session', 'new') then + raise exception 'agentic_rectification_invalid_intent' using errcode = 'P0001'; + end if; + if length(btrim(p_skill_name)) = 0 or length(btrim(p_skill_version)) = 0 + or length(btrim(coalesce(p_baseline_profile_fingerprint, ''))) = 0 then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + -- Serialize same-user opens so a repeated requestId is deterministic. A + -- separate click carries a separate requestId and is allowed to create. + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtext('agentic_rectification_open:' || p_user_id::text) + ); + + select c.* into v_case + from public.agentic_rectification_cases c + join public.agentic_rectification_open_ledger l + on l.case_id = c.id and l.session_id = c.session_id + where l.user_id = p_user_id and l.request_id = p_request_id + limit 1; + + if found then + return jsonb_build_object( + 'disposition', case + when v_case.status = any (public.agentic_rectification_resumable_statuses()) then 'resumed' + else 'readonly' + end, + 'case_id', v_case.id, + 'session_id', v_case.session_id, + 'status', v_case.status, + 'should_start_opening', false, + 'skill_version', v_case.skill_version + ); + end if; + + if p_intent = 'session' then + if p_session_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_session + from public.chat_sessions + where id = p_session_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_session_not_found' using errcode = 'P0001'; + end if; + if v_session.session_type <> 'birth_time_rectification' then + raise exception 'agentic_rectification_session_not_rectification' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where session_id = p_session_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + return jsonb_build_object( + 'disposition', case + when v_case.status = any (public.agentic_rectification_resumable_statuses()) then 'resumed' + else 'readonly' + end, + 'case_id', v_case.id, + 'session_id', v_case.session_id, + 'status', v_case.status, + 'should_start_opening', false, + 'skill_version', v_case.skill_version + ); + end if; + + if p_baseline_birth_snapshot is null or jsonb_typeof(p_baseline_birth_snapshot) <> 'object' + or p_baseline_birth_snapshot ->> 'birth_date' is null + or p_baseline_birth_snapshot ->> 'latitude' is null + or p_baseline_birth_snapshot ->> 'longitude' is null + or p_baseline_birth_snapshot ->> 'timezone_offset' is null + or length(btrim(coalesce(p_baseline_birth_snapshot ->> 'birth_time_source', ''))) = 0 then + raise exception 'agentic_rectification_profile_incomplete' using errcode = 'P0001'; + end if; + if p_candidate_range is null or jsonb_typeof(p_candidate_range) <> 'object' + or not (p_candidate_range ->> 'start_time') ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + or not (p_candidate_range ->> 'end_time') ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then + raise exception 'agentic_rectification_invalid_range' using errcode = 'P0001'; + end if; + + insert into public.chat_sessions (user_id, title, theme, session_type, messages) + values (p_user_id, '生时校正', 'general', 'birth_time_rectification', '[]'::jsonb) + returning id into v_session_id; + + insert into public.agentic_rectification_cases ( + user_id, session_id, status, skill_name, skill_version, + baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range + ) values ( + p_user_id, v_session_id, 'draft', p_skill_name, p_skill_version, + p_baseline_profile_fingerprint, p_baseline_birth_snapshot, p_candidate_range + ) returning * into v_case; + + insert into public.agentic_rectification_open_ledger ( + request_id, user_id, case_id, session_id, intent + ) values ( + p_request_id, p_user_id, v_case.id, v_session_id, p_intent + ); + + return jsonb_build_object( + 'disposition', 'created', + 'case_id', v_case.id, + 'session_id', v_session_id, + 'status', v_case.status, + 'should_start_opening', true, + 'skill_version', v_case.skill_version + ); +end; +$$; + +revoke all on function public.open_agentic_rectification_case(uuid, uuid, text, uuid, text, text, text, jsonb, jsonb) + from public, anon, authenticated; +grant execute on function public.open_agentic_rectification_case(uuid, uuid, text, uuid, text, text, text, jsonb, jsonb) + to service_role; + +commit; diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 4b848790..859c3e5d 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -154,11 +154,12 @@ test("selecting a rectification session resumes it through the exact-session ope assert.match(source, / { +test("homepage creation and sidebar selection resolve through distinct server intents", () => { const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); assert.match(page, /openRectificationCase\(\"session\", exactSessionId, null\)/); + assert.match(page, /openRectificationCase\(\"homepage\", null, pendingConsultationQuestion\)/); assert.match(page, /openRectificationCase\(\"new\", null, null\)/); assert.doesNotMatch(page, /sourceSession\.sessionType === "birth_time_rectification"/); assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 12eff426..27b26222 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -278,10 +278,8 @@ test("homepage CTA is server-driven from the entry summary, not the session list assert.doesNotMatch(page, /hasRectificationSession/); assert.doesNotMatch(page, /sessions\.some\([\s\S]{0,120}birth_time_rectification/); assert.match(page, /resolveRectificationEntryAction/); - assert.match(page, /rectificationCardAction === "resume"/); assert.match(page, /rectificationCardAction === "restart"/); - assert.match(entryLib, /开始生时校正/); - assert.match(entryLib, /继续上次校正/); + assert.match(entryLib, /开始新的生时校正/); assert.match(entryLib, /再次校正/); }); diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index b09fac31..e15345a5 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -146,15 +146,15 @@ test("homepage open with no history creates one case and one session", async () assert.equal(response.shouldStartOpening, true); }); -test("homepage open with a resumable case resumes without creating a session", async () => { +test("homepage open may create a new case even when resumable history exists", async () => { const accounting = fakeAccounting({ profile: completeProfile, rpc: openRpc({ - disposition: "resumed", + disposition: "created", case_id: caseId, session_id: sessionId, - status: "collecting_evidence", - should_start_opening: false, + status: "draft", + should_start_opening: true, skill_version: "9.0.0", }), }); @@ -162,8 +162,8 @@ test("homepage open with a resumable case resumes without creating a session", a intent: "homepage", requestId, }); - assert.equal(response.disposition, "resumed"); - assert.equal(response.shouldStartOpening, false); + assert.equal(response.disposition, "created"); + assert.equal(response.shouldStartOpening, true); }); test("session intent passes the exact sessionId and never the profile snapshot", async () => { @@ -212,7 +212,7 @@ test("non-owner session opens surface a safe not-found error", async () => { ); }); -test("intent new with an active case surfaces a safe conflict, never silent abandon", async () => { +test("legacy active-case conflicts still map to a safe public error", async () => { const accounting = fakeAccounting({ profile: completeProfile, rpc: async () => ({ data: null, error: { message: "agentic_rectification_active_case_conflict" } }), @@ -224,7 +224,7 @@ test("intent new with an active case surfaces a safe conflict, never silent aban ); }); -test("entry summary projects resume-or-create truth for the homepage card", async () => { +test("entry summary projects resumable history for homepage context", async () => { const accounting = fakeAccounting({ rpc: async (fn) => { assert.equal(fn, "get_agentic_rectification_entry_summary"); diff --git a/frontend/tests/rectification-v9-database.test.ts b/frontend/tests/rectification-v9-database.test.ts index 6b2f13e1..6e903f4b 100644 --- a/frontend/tests/rectification-v9-database.test.ts +++ b/frontend/tests/rectification-v9-database.test.ts @@ -66,7 +66,7 @@ test("v9 migration applies on a fresh database and re-applies idempotently", { s } }); -test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip: skipWithoutDocker }, async () => { +test("v9 open is atomic, idempotent and allows separate homepage cases", { skip: skipWithoutDocker }, async () => { const fixture = startPostgresFixture(); const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); try { @@ -188,8 +188,9 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip: "1", ); - // A second request with a fresh requestId resumes the same case. - const resume = await service.rpc("open_agentic_rectification_case", { + // A fresh homepage click creates a separate case and session while the + // previous unfinished case remains available through exact-session history. + const secondOpen = await service.rpc("open_agentic_rectification_case", { p_user_id: userId, p_request_id: "22222222-2222-4222-8222-222222222222", p_intent: "homepage", @@ -200,17 +201,24 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip: p_baseline_birth_snapshot: snapshot, p_candidate_range: range, }); - assert.equal(resume.error, null, rpcError(resume.error)); - assert.equal((resume.data as Record).disposition, "resumed"); - assert.equal((resume.data as Record).should_start_opening, false); - assert.equal((resume.data as Record).case_id, caseId); + assert.equal(secondOpen.error, null, rpcError(secondOpen.error)); + assert.equal((secondOpen.data as Record).disposition, "created"); + assert.equal((secondOpen.data as Record).should_start_opening, true); + const secondCaseId = String((secondOpen.data as Record).case_id); + const secondSessionId = String((secondOpen.data as Record).session_id); + assert.notEqual(secondCaseId, caseId); + assert.notEqual(secondSessionId, sessionId); + assert.equal( + fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${userId}' and status = any (public.agentic_rectification_resumable_statuses())`), + "2", + ); assert.equal( fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`), - "1", + "2", ); - // intent new while an active case exists must fail safely. - const conflict = await service.rpc("open_agentic_rectification_case", { + // The explicit new intent has the same create semantics. + const thirdOpen = await service.rpc("open_agentic_rectification_case", { p_user_id: userId, p_request_id: "33333333-3333-4333-8333-333333333333", p_intent: "new", @@ -221,10 +229,11 @@ test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip: p_baseline_birth_snapshot: snapshot, p_candidate_range: range, }); - assert.match(rpcError(conflict.error), /agentic_rectification_active_case_conflict/); + assert.equal(thirdOpen.error, null, rpcError(thirdOpen.error)); + assert.equal((thirdOpen.data as Record).disposition, "created"); assert.equal( - fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`), - "1", + fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${userId}' and status = any (public.agentic_rectification_resumable_statuses())`), + "3", ); // Session intent opens the exact session. diff --git a/frontend/tests/rectification-v9-entry-routing.test.ts b/frontend/tests/rectification-v9-entry-routing.test.ts index 1cb690e8..508347ec 100644 --- a/frontend/tests/rectification-v9-entry-routing.test.ts +++ b/frontend/tests/rectification-v9-entry-routing.test.ts @@ -28,7 +28,7 @@ import { const uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; -test("homepage entry with no history requests a create and shows 开始生时校正", () => { +test("homepage entry requests a new case and shows 开始新的生时校正", () => { const body = openRectificationRequestBody("homepage", null); assert.equal(body.intent, "homepage"); assert.ok(typeof body.requestId === "string" && body.requestId.length > 0); @@ -41,10 +41,10 @@ test("homepage entry with no history requests a create and shows 开始生时校 latest_terminal: null, }); assert.equal(resolveRectificationEntryAction(summary), "start"); - assert.equal(rectificationEntryLabels.start, "开始生时校正"); + assert.equal(rectificationEntryLabels.start, "开始新的生时校正"); }); -test("homepage entry with a resumable case resumes and shows 继续上次校正", () => { +test("homepage entry with a resumable case still offers a separate new correction", () => { const summary = entrySummaryFromResponse({ has_resumable_case: true, has_terminal_case_with_time: false, @@ -55,18 +55,19 @@ test("homepage entry with a resumable case resumes and shows 继续上次校正" }, latest_terminal: null, }); - assert.equal(resolveRectificationEntryAction(summary), "resume"); - assert.equal(rectificationEntryLabels.resume, "继续上次校正"); + assert.equal(resolveRectificationEntryAction(summary), "start"); + assert.equal(rectificationEntryLabels.start, "开始新的生时校正"); const opened = openResponseFromPayload({ - disposition: "resumed", + disposition: "created", caseId: CASE_ID, sessionId: SESSION_ID, - status: "collecting_evidence", - shouldStartOpening: false, + status: "draft", + shouldStartOpening: true, skillVersion: "9.0.0", }); - assert.equal(opened?.shouldStartOpening, false); + assert.equal(opened?.disposition, "created"); + assert.equal(opened?.shouldStartOpening, true); assert.equal(opened?.caseId, CASE_ID); assert.equal(opened?.sessionId, SESSION_ID); }); @@ -184,9 +185,9 @@ test("shouldStartOpening is server-owned: only freshly created never-started cas }); test("empty client message cache never repeats opening when the case has turns", () => { - // The client messages array may be empty after refresh, but the server - // returned a resumed case: shouldStartOpening stays false, so the opening - // turn is not re-emitted. + // The client messages array may be empty after refresh, but exact-session + // recovery stays resumed: shouldStartOpening is false, so the opening turn + // is not re-emitted. const opened = openResponseFromPayload({ disposition: "resumed", caseId: CASE_ID, @@ -195,6 +196,7 @@ test("empty client message cache never repeats opening when the case has turns", shouldStartOpening: false, skillVersion: "9.0.0", }); + assert.equal(opened?.disposition, "resumed"); assert.equal(opened?.shouldStartOpening, false); }); diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index 023faefc..1692b0ed 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -449,3 +449,60 @@ test("rectification turn projection migration stays out of the identity migratio "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", ); }); + +// --------------------------------------------------------------------------- +// 20260813040000_allow_parallel_rectification_cases.sql +// --------------------------------------------------------------------------- + +const parallelCasesMigration = readFileSync( + new URL( + "../supabase/migrations/20260813040000_allow_parallel_rectification_cases.sql", + import.meta.url, + ), + "utf8", +); + +const parallelCasesMigrationCopy = fileURLToPath( + new URL( + "../db/migrations/20260813040000_allow_parallel_rectification_cases.sql", + import.meta.url, + ), +); + +test("parallel rectification migration follows the turn projection and stays transactional", () => { + assert.ok( + "20260813040000_allow_parallel_rectification_cases.sql" > + "20260813030000_rectification_turn_message_projection.sql", + ); + assert.match(parallelCasesMigration, /^begin;[\s\S]*^commit;$/m); +}); + +test("parallel rectification migration removes the single-active-case restriction", () => { + assert.match( + parallelCasesMigration, + /drop index if exists public\.agentic_rectification_cases_one_resumable_per_user/, + ); + assert.doesNotMatch(parallelCasesMigration, /agentic_rectification_active_case_conflict/); + assert.doesNotMatch( + parallelCasesMigration, + /if p_intent = 'homepage'[\s\S]*?'disposition', 'resumed'/, + ); +}); + +test("parallel rectification open keeps request idempotency and exact-session recovery", () => { + assert.match(parallelCasesMigration, /pg_catalog\.pg_advisory_xact_lock\(/); + assert.match(parallelCasesMigration, /l\.request_id = p_request_id/); + assert.match(parallelCasesMigration, /if p_intent = 'session' then/); + assert.match(parallelCasesMigration, /where id = p_session_id and user_id = p_user_id/); + assert.match(parallelCasesMigration, /insert into public\.chat_sessions/); + assert.match(parallelCasesMigration, /insert into public\.agentic_rectification_cases/); + assert.match(parallelCasesMigration, /insert into public\.agentic_rectification_open_ledger/); +}); + +test("parallel rectification migration stays out of the identity migration tree", () => { + assert.equal( + existsSync(parallelCasesMigrationCopy), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); +}); -- 2.52.0