From d394dd058530ca8cfaaba78c08e76c2d1880cbcb Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 11 Aug 2026 16:18:53 +0800 Subject: [PATCH] feat(rectification): add durable case and evidence runtime --- docs/BUG_HISTORY.md | 27 + .../cases/[caseId]/close/route.ts | 80 + .../api/rectification/cases/[caseId]/route.ts | 62 + .../cases/[caseId]/upgrade-skill/route.ts | 81 + .../cases/entry-summary/route.ts | 48 + .../app/api/rectification/cases/open/route.ts | 66 + .../rectification-agentic/v9/case-service.ts | 428 +++++ ...10000_agentic_rectification_v9_runtime.sql | 1483 +++++++++++++++++ .../tests/database-local-business.test.ts | 6 + .../rectification-v9-case-service.test.ts | 325 ++++ .../tests/rectification-v9-contracts.test.ts | 211 +++ .../tests/rectification-v9-database.test.ts | 761 +++++++++ .../tests/rectification-v9-migration.test.ts | 216 +++ 13 files changed, 3794 insertions(+) create mode 100644 frontend/src/app/api/rectification/cases/[caseId]/close/route.ts create mode 100644 frontend/src/app/api/rectification/cases/[caseId]/route.ts create mode 100644 frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts create mode 100644 frontend/src/app/api/rectification/cases/entry-summary/route.ts create mode 100644 frontend/src/app/api/rectification/cases/open/route.ts create mode 100644 frontend/src/lib/rectification-agentic/v9/case-service.ts create mode 100644 frontend/supabase/migrations/20260812010000_agentic_rectification_v9_runtime.sql create mode 100644 frontend/tests/rectification-v9-case-service.test.ts create mode 100644 frontend/tests/rectification-v9-contracts.test.ts create mode 100644 frontend/tests/rectification-v9-database.test.ts create mode 100644 frontend/tests/rectification-v9-migration.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 634e12fa..76e30fa9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2744,3 +2744,30 @@ - 相关记录:BUG-159、ERR-020、ERR-021、ERR-022、ERR-024、ERR-025、ERR-026、ERR-104 - 复发自:无 - 修复版本:本地 staging 候选(未 push / deploy) + +## BUG-162 | 生时校正入口没有服务端 Case 状态,复用第一条校正 Session 且并发会重复创建 + +- 状态:resolved(local candidate;真实 PostgreSQL fixture 待远端 runner 执行) +- 首次发现:2026-08-12 +- 最近更新:2026-08-12 +- 影响面:生时校正首页入口、侧边栏校正 Session 恢复、Case/Session 绑定、并发打开、旧 Direct Agentic 数据映射 +- 用户现象:点击首页“生时校正”时,只要历史存在任意校正 Session 就进入“继续”,不管它是进行中、已采用、已确认还是已关闭;侧边栏点击某条校正 Session 可能被复用逻辑带向另一条记录;双击或多标签并发点击可能创建多组 Case/Session;已完成校正 Session 还会阻止用户重新开始。 +- 触发条件:使用 `hasRectificationSession` + `sessions.find(sessionType === "birth_time_rectification")` 的客户端猜测路径;历史 Direct Agentic Session 没有 V9 Case 状态。 +- 根因:旧入口把“存在任意校正 Session”当作“可继续”,把客户端数组第一条校正 Session 当作权威;没有服务端 Case 状态机(resumable/terminal 由消息数、是否有候选推断),Session 与 Case 没有数据库级双向绑定与精确恢复,open/create 没有 requestId 幂等账本和同用户串行化,旧 Agentic 数据也没有一次性的 Case 回填与结果映射。 +- 修复(V9 Agentic Domain lane): + 1. 新增独立 Skill `skills/jyotish-birth-time-rectification`(SKILL.md ≤200 行 + 5 个 references),方法源只在 Skill,系统提示词不再复制完整方法。 + 2. 新增 V9 领域 contracts:Case 状态机(10 状态 + resumable/terminal 谓词 + 单向终态)、Evidence kind/date precision/append-only lineage、public receipt/activity allowlist、open request/response schemas;`supersedeActive=true` 被 schema 与 RPC 双重禁止。 + 3. 新增向前业务迁移 `20260812010000_agentic_rectification_v9_runtime.sql`(只进 `frontend/supabase/migrations`,不进 `frontend/db/migrations`):`agentic_rectification_cases`(每用户最多一个 resumable 由 partial unique index 强制;Case/Session 双向一致由触发器+RPC+唯一索引保证)、`agentic_rectification_evidence`(原文 grounding、日期精度、revision lineage、confirmed 只能由服务器确认路径产生)、`agentic_rectification_turns`(pending/completed/failed/retryable,禁止 reasoning)、`agentic_rectification_tool_receipts`(只存 fingerprint/phase/tool/status)、`agentic_rectification_open_ledger`(requestId 幂等);`agentic_rectification_results` 前向扩展 `case_id/evidence_ledger_fingerprint/candidate_range_fingerprint/skill_version`;所有新表 RLS + 仅 service_role 授权,全部 RPC security definer + `search_path=''` 且仅 service_role 可执行;浏览器不传 userId、出生快照、range 或权限决定。 + 4. 一次性幂等 legacy backfill(迁移内执行,应用启动不隐式批量跑):engine_confirmed→confirmed、user_accepted only→candidate_accepted、有消息无选择→collecting_evidence/candidate_ready、重复空/旧→abandoned/superseded、每用户只保留最新实际 active(窗口函数+部分唯一索引),旧文本绝不生成 confirmed evidence;提供 `backfill_agentic_rectification_legacy_cases()` 与可复跑的 `verify_agentic_rectification_backfill()`。 + 5. 新增 Case Service + 五个 API:`POST /api/rectification/cases/open`(homepage resume-or-create / session 精确恢复 / new 安全冲突)、`GET entry-summary`(首页 CTA 真值)、`GET cases/[caseId]`(脱敏投影,绝不返回 baseline_birth_snapshot)、`POST close`、`POST upgrade-skill`;profile 不完整不建案;错误不泄露身份、出生资料或 DB 原文。 +- 验证:新增 `rectification-v9-contracts.test.ts`(12)、`rectification-v9-case-service.test.ts`(14)、`rectification-v9-migration.test.ts`(17 静态合同)本地共 43 项全部通过;`rectification-v9-database.test.ts`(真实 PostgreSQL 迁移 apply/re-apply、open 原子+幂等+resume、profile 门禁、所有权、terminal 只读、evidence 生命周期、backfill 状态分布/单 active/结果映射/幂等)在本机因无 Docker 按环境 skip(5 项),待远端完整 runner 执行;既有生时校正相关测试 60/60 通过;`tsc --noEmit` 对本 lane 文件零错误(全量 6 个既有错误全部位于未触碰文件);目标 ESLint 0 error 0 warning;`git diff --check` 通过;`database-local-business.test.ts` 精确 public 表清单同步新增 5 张 v9 表并断言新迁移 applied(BUG-127/144 防复发)。 +- 防复发:Case 状态、resumable/terminal、shouldStartOpening 必须只由服务端决定;侧边栏必须以精确 sessionId 恢复并校验所有权;open/create 必须走 requestId 幂等账本 + 同用户 advisory 串行化;新增 self-hosted 业务表必须同时更新全迁移 applied ledger 与精确 public 表集合,且只进 `frontend/supabase/migrations`;新页面/新 API 必须同步能力审计;浏览器任何入参都不允许携带 userId、出生资料、range 或权限决定。 +- 相关记录:BUG-004、BUG-027、BUG-033、BUG-067、BUG-068、BUG-069、BUG-072、BUG-074、BUG-085、BUG-086、BUG-095、BUG-112、BUG-113、BUG-114、BUG-115、BUG-116、BUG-117、BUG-118、BUG-119、BUG-120、BUG-121、BUG-127、BUG-143、BUG-144 +- 复发自:无(新 Agentic Domain 主链;旧防线缺失的直接原因见下) +- 修复版本:本地 staging 候选(未 push / deploy) + +### 旧防线为何没拦住(hasRectificationSession + sessions.find()) + +- `hasRectificationSession` 只断言“存在任意校正 Session”,不区分 draft / collecting / candidate_ready / candidate_accepted / confirmed / closed,因此已完成会话始终被当作可继续,且没有服务端 Case 状态可被测试断言。 +- `sessions.find(sessionType === "birth_time_rectification")` 取客户端数组第一条,既不保证精确 sessionId,也不校验 Case 绑定;排序、缓存或刷新差异都会改变打开哪条记录。 +- 旧测试只覆盖“能找到一条校正 Session”的客户端行为,没有服务端 Case 状态机、没有“点击指定 Session 必须精确恢复”的契约、没有并发/双击/多标签幂等断言,也没有 legacy→V9 一次性回填的数据库级验证,因此这些缺陷在回归中被遗漏。 diff --git a/frontend/src/app/api/rectification/cases/[caseId]/close/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/close/route.ts new file mode 100644 index 00000000..4ea41e90 --- /dev/null +++ b/frontend/src/app/api/rectification/cases/[caseId]/close/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + RectificationCaseServiceError, + closeRectificationCase, + mapRectificationRpcError, +} from "@/lib/rectification-agentic/v9/case-service"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ caseId: string }> }; + +const closeRequestSchema = z + .object({ + reason: z.enum(["completed_by_user", "abandoned_by_user", "other"]), + }) + .strict(); + +/** + * POST /api/rectification/cases/[caseId]/close + * + * Explicit business close only; never wraps as engine confirmed. + */ +export async function POST(request: Request, context: RouteContext) { + let supabase; + let accounting; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const { caseId } = await context.params; + if (!z.string().uuid().safeParse(caseId).success) { + return NextResponse.json( + { error: "请求内容不正确", code: "invalid_case_id" }, + { status: 400 }, + ); + } + const parsed = closeRequestSchema.safeParse( + await request.json().catch(() => null), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "请求内容不正确", code: "invalid_close_request" }, + { status: 400 }, + ); + } + + try { + const result = await closeRectificationCase( + accounting, + user.id, + caseId, + parsed.data.reason, + ); + return NextResponse.json(result); + } catch (error) { + if (error instanceof RectificationCaseServiceError) { + const view = mapRectificationRpcError( + new Error(`agentic_rectification_${error.code}`), + ); + return NextResponse.json({ error: view.message, code: view.code }, { status: view.status }); + } + return NextResponse.json( + { error: "校正服务暂时不可用", code: "rectification_service_failed" }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts new file mode 100644 index 00000000..6a742331 --- /dev/null +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + RectificationCaseServiceError, + getRectificationCase, + mapRectificationRpcError, +} from "@/lib/rectification-agentic/v9/case-service"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ caseId: string }> }; + +/** + * GET /api/rectification/cases/[caseId] + * + * Sanitized case projection (never the baseline birth snapshot). Terminal + * cases are served read-only. + */ +export async function GET(_request: Request, context: RouteContext) { + let supabase; + let accounting; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const { caseId } = await context.params; + if (!z.string().uuid().safeParse(caseId).success) { + return NextResponse.json( + { error: "请求内容不正确", code: "invalid_case_id" }, + { status: 400 }, + ); + } + + try { + return NextResponse.json( + await getRectificationCase(accounting, user.id, caseId), + ); + } catch (error) { + if (error instanceof RectificationCaseServiceError) { + const view = mapRectificationRpcError( + new Error(`agentic_rectification_${error.code}`), + ); + return NextResponse.json({ error: view.message, code: view.code }, { status: view.status }); + } + return NextResponse.json( + { error: "校正服务暂时不可用", code: "rectification_service_failed" }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts new file mode 100644 index 00000000..e6f16bfb --- /dev/null +++ b/frontend/src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + RectificationCaseServiceError, + mapRectificationRpcError, + upgradeRectificationSkill, +} from "@/lib/rectification-agentic/v9/case-service"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ caseId: string }> }; + +const upgradeRequestSchema = z + .object({ + skillVersion: z.string().trim().min(1).max(64), + }) + .strict(); + +/** + * POST /api/rectification/cases/[caseId]/upgrade-skill + * + * Explicit skill-version migration for a running case; terminal cases are + * rejected and new cases pin the current default version. + */ +export async function POST(request: Request, context: RouteContext) { + let supabase; + let accounting; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const { caseId } = await context.params; + if (!z.string().uuid().safeParse(caseId).success) { + return NextResponse.json( + { error: "请求内容不正确", code: "invalid_case_id" }, + { status: 400 }, + ); + } + const parsed = upgradeRequestSchema.safeParse( + await request.json().catch(() => null), + ); + if (!parsed.success) { + return NextResponse.json( + { error: "请求内容不正确", code: "invalid_upgrade_request" }, + { status: 400 }, + ); + } + + try { + const result = await upgradeRectificationSkill( + accounting, + user.id, + caseId, + parsed.data.skillVersion, + ); + return NextResponse.json(result); + } catch (error) { + if (error instanceof RectificationCaseServiceError) { + const view = mapRectificationRpcError( + new Error(`agentic_rectification_${error.code}`), + ); + return NextResponse.json({ error: view.message, code: view.code }, { status: view.status }); + } + return NextResponse.json( + { error: "校正服务暂时不可用", code: "rectification_service_failed" }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/rectification/cases/entry-summary/route.ts b/frontend/src/app/api/rectification/cases/entry-summary/route.ts new file mode 100644 index 00000000..cd792d05 --- /dev/null +++ b/frontend/src/app/api/rectification/cases/entry-summary/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + RectificationCaseServiceError, + getRectificationEntrySummary, + mapRectificationRpcError, +} from "@/lib/rectification-agentic/v9/case-service"; + +export const runtime = "nodejs"; + +/** + * GET /api/rectification/cases/entry-summary + * + * Server-truth homepage CTA: "开始生时校正" / "继续上次校正" / "再次校正". + */ +export async function GET() { + let supabase; + let accounting; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + try { + return NextResponse.json(await getRectificationEntrySummary(accounting, user.id)); + } catch (error) { + if (error instanceof RectificationCaseServiceError) { + const view = mapRectificationRpcError( + new Error(`agentic_rectification_${error.code}`), + ); + return NextResponse.json({ error: view.message, code: view.code }, { status: view.status }); + } + return NextResponse.json( + { error: "校正服务暂时不可用", code: "rectification_service_failed" }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/rectification/cases/open/route.ts b/frontend/src/app/api/rectification/cases/open/route.ts new file mode 100644 index 00000000..6294e490 --- /dev/null +++ b/frontend/src/app/api/rectification/cases/open/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { + RectificationCaseServiceError, + mapRectificationRpcError, + openRectificationCase, +} from "@/lib/rectification-agentic/v9/case-service"; +import { parseOpenRectificationCaseRequest } from "@/lib/rectification-agentic/v9/open-request"; + +export const runtime = "nodejs"; + +function serviceErrorResponse(error: unknown) { + if (error instanceof RectificationCaseServiceError) { + const view = mapRectificationRpcError( + new Error(`agentic_rectification_${error.code}`), + ); + return NextResponse.json({ error: view.message, code: view.code }, { status: view.status }); + } + return NextResponse.json( + { error: "校正服务暂时不可用", code: "rectification_service_failed" }, + { status: 500 }, + ); +} + +/** + * POST /api/rectification/cases/open + * + * 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. + */ +export async function POST(request: Request) { + let supabase; + let accounting; + try { + supabase = await createServerSupabaseClient(); + accounting = createAdminSupabaseClient(); + } catch { + return NextResponse.json({ error: "服务尚未配置" }, { status: 503 }); + } + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: "请先登录" }, { status: 401 }); + } + + const parsed = parseOpenRectificationCaseRequest( + await request.json().catch(() => null), + ); + if (!parsed) { + return NextResponse.json( + { error: "请求内容不正确", code: "invalid_open_request" }, + { status: 400 }, + ); + } + + try { + const response = await openRectificationCase(accounting, user.id, parsed); + return NextResponse.json(response); + } catch (error) { + return serviceErrorResponse(error); + } +} diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts new file mode 100644 index 00000000..959e9624 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -0,0 +1,428 @@ +/** + * V9 rectification Case Service. + * + * Server-owned facts: profile normalization, baseline snapshot/fingerprint, + * candidate range derivation and every disposition decision. The browser only + * ever supplies an intent + requestId (+ exact sessionId); it never passes + * userId, birth data, candidate range or permission decisions. + */ +import { createHash } from "node:crypto"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts"; +import { + isRectificationCaseStatus, + RECTIFICATION_SKILL_NAME, + RECTIFICATION_SKILL_VERSION, + type RectificationCaseStatus, +} from "./case-status.ts"; +import { + openResponse, + type OpenRectificationCaseRequest, + type OpenRectificationCaseResponse, + type RectificationEntrySummary, +} from "./open-request.ts"; + +type AccountingClient = SupabaseClient; + +export type V9BaselineSnapshot = Readonly<{ + birth_date: string; + latitude: number; + longitude: number; + timezone_offset: number; + birth_time_source: string; + birth_time_period: string | null; + reported_birth_time: string | null; + active_birth_time: string | null; + uncertainty_before_minutes: number | null; + uncertainty_after_minutes: number | null; +}>; + +export type V9RectificationProfile = Readonly<{ + userId: string; + baseline: V9BaselineSnapshot; + baselineFingerprint: string; + candidateRange: { start_time: string; end_time: string }; +}>; + +export type RectificationCaseView = Readonly<{ + caseId: string; + sessionId: string; + status: string; + skillName: string; + skillVersion: string; + candidateRange: { start_time: string; end_time: string } | null; + acceptedTime: string | null; + confirmedTime: string | null; + createdAt: string; + lastActivityAt: string; + completedAt: string | null; + closedReason: string | null; + evidenceCount: number; + turnCount: number; + latestResult: unknown; +}>; + +export class RectificationCaseServiceError extends Error { + readonly code: string; + + constructor(code: string) { + super(`Rectification case service error: ${code}`); + this.name = "RectificationCaseServiceError"; + this.code = code; + } +} + +const clockTime = /^([01]\d|2[0-3]):[0-5]\d$/; + +function timeValue(value: unknown): string | null { + const time = typeof value === "string" ? value.slice(0, 5) : ""; + return clockTime.test(time) ? time : null; +} + +function numberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +const periodRanges: Readonly> = { + early_morning: { start_time: "04:00", end_time: "07:59" }, + morning: { start_time: "08:00", end_time: "11:59" }, + afternoon: { start_time: "12:00", end_time: "17:59" }, + evening: { start_time: "18:00", end_time: "22:59" }, + late_night: { start_time: "23:00", end_time: "03:59" }, +}; + +function shiftedTime(time: string, offsetMinutes: number): string { + const [hour = 0, minute = 0] = time.split(":").map(Number); + const normalized = ((hour * 60 + minute + offsetMinutes) % 1_440 + 1_440) % 1_440; + return `${String(Math.floor(normalized / 60)).padStart(2, "0")}:${String(normalized % 60).padStart(2, "0")}`; +} + +function fallbackUncertainty(source: string): number { + if (source === "hospital_record" || source === "hospital") return 2; + if (source === "family_exact" || source === "family_clear") return 15; + if (source === "approximate" || source === "family_vague") return 60; + return 2; +} + +function deriveCandidateRange(input: { + activeTime: string | null; + reportedTime: string | null; + source: string; + period: string | null; + uncertaintyBefore: number | null; + uncertaintyAfter: number | null; +}): { start_time: string; end_time: string } { + const referenceTime = input.activeTime ?? input.reportedTime; + if (referenceTime) { + const fallback = fallbackUncertainty(input.source); + return { + start_time: shiftedTime(referenceTime, -(input.uncertaintyBefore ?? fallback)), + end_time: shiftedTime(referenceTime, input.uncertaintyAfter ?? fallback), + }; + } + if (input.source === "period_only" || input.source === "legacy_import") { + const period = input.period ? periodRanges[input.period] : undefined; + if (period) return period; + if (input.source === "period_only") { + throw new RectificationCaseServiceError("profile_incomplete"); + } + } + if (input.source === "unknown" || input.source === "legacy_import") { + return { start_time: "00:00", end_time: "23:59" }; + } + throw new RectificationCaseServiceError("profile_incomplete"); +} + +function baselineFingerprint(baseline: V9BaselineSnapshot): string { + const canonical = [ + baseline.birth_date, + String(baseline.latitude), + String(baseline.longitude), + String(baseline.timezone_offset), + baseline.birth_time_source, + baseline.birth_time_period ?? "", + baseline.reported_birth_time ?? "", + baseline.active_birth_time ?? "", + String(baseline.uncertainty_before_minutes ?? ""), + String(baseline.uncertainty_after_minutes ?? ""), + ].join("|"); + return createHash("sha256").update(canonical).digest("hex"); +} + +export async function loadV9RectificationProfile( + accounting: AccountingClient, + userId: string, +): Promise { + const { data, error } = await accounting + .from("profiles") + .select( + "birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_offset", + ) + .eq("id", userId) + .single(); + if (error || !data) throw new RectificationCaseServiceError("profile_unavailable"); + + const row = data as Record; + const birthDate = normalizePersistedBirthDate(row.birth_date); + const latitude = numberOrNull(row.latitude); + const longitude = numberOrNull(row.longitude); + const timezoneOffset = numberOrNull(row.timezone_offset); + const source = typeof row.birth_time_source === "string" ? row.birth_time_source.trim() : ""; + const reportedTime = timeValue(row.reported_birth_time); + const activeTime = timeValue(row.active_birth_time); + const period = typeof row.birth_time_period === "string" ? row.birth_time_period : null; + const uncertaintyBefore = numberOrNull(row.uncertainty_before_minutes); + const uncertaintyAfter = numberOrNull(row.uncertainty_after_minutes); + if (!birthDate || latitude === null || longitude === null || timezoneOffset === null || !source) { + throw new RectificationCaseServiceError("profile_incomplete"); + } + + const baseline: V9BaselineSnapshot = { + birth_date: birthDate, + latitude, + longitude, + timezone_offset: timezoneOffset, + birth_time_source: source, + birth_time_period: period, + reported_birth_time: reportedTime, + active_birth_time: activeTime, + uncertainty_before_minutes: uncertaintyBefore, + uncertainty_after_minutes: uncertaintyAfter, + }; + + return { + userId, + baseline, + baselineFingerprint: baselineFingerprint(baseline), + candidateRange: deriveCandidateRange({ + activeTime, + reportedTime, + source, + period, + uncertaintyBefore, + uncertaintyAfter, + }), + }; +} + +function readRpcData(value: unknown): unknown { + if (Array.isArray(value)) return value[0] ?? null; + if (value && typeof value === "object" && "value" in value) { + return (value as { value?: unknown }).value; + } + return value; +} + +const KNOWN_RPC_ERROR_CODES = new Map([ + ["agentic_rectification_profile_incomplete", { status: 422, code: "profile_incomplete", message: "出生资料不完整" }], + ["agentic_rectification_active_case_conflict", { status: 409, code: "active_case_conflict", message: "仍有未完成的校正,请先继续或明确结束当前校正" }], + ["agentic_rectification_session_not_found", { status: 404, code: "case_session_not_found", message: "校正会话不存在或无权访问" }], + ["agentic_rectification_session_not_rectification", { status: 400, code: "session_not_rectification", message: "该会话不是生时校正会话" }], + ["agentic_rectification_case_not_found", { status: 404, code: "case_not_found", message: "校正记录不存在或无权访问" }], + ["agentic_rectification_case_terminal", { status: 409, code: "case_terminal", message: "该校正已结束,不能继续修改" }], + ["agentic_rectification_case_session_mismatch", { status: 409, code: "case_session_mismatch", message: "校正记录与会话绑定不一致" }], + ["agentic_rectification_case_owner_mismatch", { status: 403, code: "case_owner_mismatch", message: "无权访问该校正记录" }], + ["agentic_rectification_invalid_input", { status: 400, code: "invalid_input", message: "请求内容不正确" }], + ["agentic_rectification_invalid_intent", { status: 400, code: "invalid_intent", message: "请求内容不正确" }], + ["agentic_rectification_invalid_range", { status: 400, code: "invalid_range", message: "候选范围不正确" }], + ["agentic_rectification_turn_incomplete", { status: 400, code: "turn_incomplete", message: "回合内容不完整" }], + ["agentic_rectification_quote_not_grounded", { status: 422, code: "quote_not_grounded", message: "事件引用未能在本轮消息中找到" }], + ["agentic_rectification_evidence_not_found", { status: 404, code: "evidence_not_found", message: "事件记录不存在或无权访问" }], + ["agentic_rectification_evidence_not_confirmable", { status: 409, code: "evidence_not_confirmable", message: "该事件当前不能确认" }], + ["agentic_rectification_evidence_not_revisable", { status: 409, code: "evidence_not_revisable", message: "该事件当前不能修订" }], +]); + +export type RectificationServiceErrorView = { + status: number; + code: string; + message: string; +}; + +export function mapRectificationRpcError(error: unknown): RectificationServiceErrorView { + const message = + error instanceof Error + ? error.message + : typeof error === "object" && error !== null && "message" in error + ? String((error as { message?: unknown }).message ?? "") + : ""; + for (const [code, view] of KNOWN_RPC_ERROR_CODES) { + if (message.includes(code)) return view; + } + return { status: 500, code: "rectification_service_failed", message: "校正服务暂时不可用" }; +} + +export async function openRectificationCase( + accounting: AccountingClient, + userId: string, + request: OpenRectificationCaseRequest, +): Promise { + let profile: V9RectificationProfile | null = null; + if (request.intent === "homepage" || request.intent === "new") { + profile = await loadV9RectificationProfile(accounting, userId); + } + + const { data, error } = await accounting.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: request.requestId, + p_intent: request.intent, + p_session_id: request.intent === "session" ? request.sessionId : null, + p_skill_name: RECTIFICATION_SKILL_NAME, + p_skill_version: RECTIFICATION_SKILL_VERSION, + p_baseline_profile_fingerprint: profile?.baselineFingerprint ?? "session-view", + p_baseline_birth_snapshot: profile?.baseline ?? {}, + p_candidate_range: profile?.candidateRange ?? { start_time: "00:00", end_time: "23:59" }, + }); + if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); + const response = openResponse(readRpcData(data)); + if (!response) throw new RectificationCaseServiceError("invalid_open_response"); + return response; +} + +export async function getRectificationEntrySummary( + accounting: AccountingClient, + userId: string, +): Promise { + const { data, error } = await accounting.rpc("get_agentic_rectification_entry_summary", { + p_user_id: userId, + }); + if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); + const row = readRpcData(data); + if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_entry_summary"); + const value = row as Record; + const latestResumable = + value.latest_resumable && typeof value.latest_resumable === "object" + ? (value.latest_resumable as Record) + : null; + const latestTerminal = + value.latest_terminal && typeof value.latest_terminal === "object" + ? (value.latest_terminal as Record) + : null; + return { + hasResumableCase: value.has_resumable_case === true, + hasTerminalCaseWithTime: value.has_terminal_case_with_time === true, + latestResumable: + latestResumable && typeof latestResumable.case_id === "string" + ? { + caseId: latestResumable.case_id, + status: isRectificationCaseStatus(latestResumable.status) + ? latestResumable.status + : "draft", + lastActivityAt: + typeof latestResumable.last_activity_at === "string" ? latestResumable.last_activity_at : "", + } + : null, + latestTerminal: + latestTerminal && typeof latestTerminal.case_id === "string" + ? { + caseId: latestTerminal.case_id, + status: isRectificationCaseStatus(latestTerminal.status) + ? latestTerminal.status + : ("closed" as RectificationCaseStatus), + hasUsableTime: latestTerminal.has_usable_time === true, + } + : null, + }; +} + +function caseView(row: Record): RectificationCaseView { + const range = row.candidate_range && typeof row.candidate_range === "object" + ? (row.candidate_range as { start_time?: unknown; end_time?: unknown }) + : null; + return { + caseId: String(row.case_id ?? ""), + sessionId: String(row.session_id ?? ""), + status: String(row.status ?? ""), + skillName: String(row.skill_name ?? ""), + skillVersion: String(row.skill_version ?? ""), + candidateRange: + range && typeof range.start_time === "string" && typeof range.end_time === "string" + ? { start_time: range.start_time, end_time: range.end_time } + : null, + acceptedTime: typeof row.accepted_time === "string" ? row.accepted_time : null, + confirmedTime: typeof row.confirmed_time === "string" ? row.confirmed_time : null, + createdAt: String(row.created_at ?? ""), + lastActivityAt: String(row.last_activity_at ?? ""), + completedAt: typeof row.completed_at === "string" ? row.completed_at : null, + closedReason: typeof row.closed_reason === "string" ? row.closed_reason : null, + evidenceCount: typeof row.evidence_count === "number" ? row.evidence_count : 0, + turnCount: typeof row.turn_count === "number" ? row.turn_count : 0, + latestResult: row.latest_result ?? null, + }; +} + +export async function getRectificationCase( + accounting: AccountingClient, + userId: string, + caseId: string, +): Promise { + const { data, error } = await accounting.rpc("get_agentic_rectification_case", { + p_user_id: userId, + p_case_id: caseId, + }); + if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); + const row = readRpcData(data); + if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_case_view"); + return caseView(row as Record); +} + +export type RectificationCloseResult = Readonly<{ + success: boolean; + caseId: string; + status: string; + idempotent: boolean; +}>; + +export async function closeRectificationCase( + accounting: AccountingClient, + userId: string, + caseId: string, + reason: "completed_by_user" | "abandoned_by_user" | "other", +): Promise { + const { data, error } = await accounting.rpc("close_agentic_rectification_case", { + p_user_id: userId, + p_case_id: caseId, + p_reason: reason, + }); + if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); + const row = readRpcData(data); + if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_close_result"); + const value = row as Record; + return { + success: value.success === true, + caseId: String(value.case_id ?? caseId), + status: String(value.status ?? "closed"), + idempotent: value.idempotent === true, + }; +} + +export type RectificationUpgradeResult = Readonly<{ + success: boolean; + caseId: string; + previousSkillVersion: string; + skillVersion: string; + idempotent: boolean; +}>; + +export async function upgradeRectificationSkill( + accounting: AccountingClient, + userId: string, + caseId: string, + skillVersion: string, +): Promise { + const { data, error } = await accounting.rpc("upgrade_agentic_rectification_skill", { + p_user_id: userId, + p_case_id: caseId, + p_skill_version: skillVersion, + }); + if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); + const row = readRpcData(data); + if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_upgrade_result"); + const value = row as Record; + return { + success: value.success === true, + caseId: String(value.case_id ?? caseId), + previousSkillVersion: String(value.previous_skill_version ?? ""), + skillVersion: String(value.skill_version ?? skillVersion), + idempotent: value.idempotent === true, + }; +} diff --git a/frontend/supabase/migrations/20260812010000_agentic_rectification_v9_runtime.sql b/frontend/supabase/migrations/20260812010000_agentic_rectification_v9_runtime.sql new file mode 100644 index 00000000..540f3685 --- /dev/null +++ b/frontend/supabase/migrations/20260812010000_agentic_rectification_v9_runtime.sql @@ -0,0 +1,1483 @@ +-- V9 Agentic Rectification runtime: durable Case / Evidence / Turn / Receipt +-- state plus safe open/entry-summary/get/close/upgrade RPCs and the one-time +-- idempotent legacy Agentic backfill. +-- +-- Business schema only. This migration belongs in frontend/supabase/migrations +-- and MUST NOT be duplicated into frontend/db/migrations (identity foundation; +-- see BUG-127 / BUG-144). +-- +-- Security model (same as the existing agentic_rectification_results): +-- * all new tables enable RLS and grant table access to service_role only; +-- * all RPCs are SECURITY DEFINER with search_path = '', granted to +-- service_role only; the browser never passes userId, birth snapshot, +-- candidate range or permission decisions -- the server derives them. + +begin; + +-- --------------------------------------------------------------------------- +-- 1. Cases +-- --------------------------------------------------------------------------- + +create table if not exists public.agentic_rectification_cases ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + session_id uuid not null unique references public.chat_sessions(id) on delete cascade, + status text not null default 'draft' check ( + status in ( + 'draft', + 'collecting_evidence', + 'candidate_ready', + 'candidate_accepted', + 'needs_rebaseline', + 'paused', + 'confirmed', + 'closed', + 'abandoned', + 'superseded' + ) + ), + skill_name text not null default 'jyotish-birth-time-rectification' + check (length(btrim(skill_name)) > 0), + skill_version text not null check (length(btrim(skill_version)) > 0), + baseline_profile_fingerprint text not null check (length(btrim(baseline_profile_fingerprint)) > 0), + baseline_birth_snapshot jsonb not null check (jsonb_typeof(baseline_birth_snapshot) = 'object'), + candidate_range jsonb not null check (jsonb_typeof(candidate_range) = 'object'), + accepted_time time without time zone, + confirmed_time time without time zone, + created_at timestamptz not null default pg_catalog.now(), + updated_at timestamptz not null default pg_catalog.now(), + last_activity_at timestamptz not null default pg_catalog.now(), + completed_at timestamptz, + closed_reason text check (closed_reason is null or closed_reason in ('completed_by_user', 'abandoned_by_user', 'other')), + check (status not in ('confirmed', 'closed') or completed_at is not null) +); + +-- Resumable statuses: draft, collecting_evidence, candidate_ready, +-- candidate_accepted, needs_rebaseline, paused. +-- Terminal statuses: confirmed, closed, abandoned, superseded. +-- At most ONE resumable case per user. This partial unique index is the +-- database enforcement point; the service layer must never bypass it. +create unique index if not exists agentic_rectification_cases_one_resumable_per_user + on public.agentic_rectification_cases (user_id) + where status in ( + 'draft', 'collecting_evidence', 'candidate_ready', 'candidate_accepted', + 'needs_rebaseline', 'paused' + ); + +create index if not exists agentic_rectification_cases_user_activity_idx + on public.agentic_rectification_cases (user_id, last_activity_at desc); + +-- --------------------------------------------------------------------------- +-- 2. Turns (raw user/assistant text + model version; reasoning is forbidden) +-- --------------------------------------------------------------------------- + +create table if not exists public.agentic_rectification_turns ( + id uuid primary key default gen_random_uuid(), + case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade, + user_message text, + assistant_message text, + status text not null check (status in ('pending', 'completed', 'failed', 'retryable')), + model_name text not null check (length(btrim(model_name)) > 0), + model_version text, + created_at timestamptz not null default pg_catalog.now(), + completed_at timestamptz, + updated_at timestamptz not null default pg_catalog.now(), + check (status <> 'completed' or (assistant_message is not null and length(btrim(assistant_message)) > 0)) +); + +create index if not exists agentic_rectification_turns_case_idx + on public.agentic_rectification_turns (case_id, created_at); + +-- --------------------------------------------------------------------------- +-- 3. Evidence (append-only revision lineage, server-owned IDs) +-- --------------------------------------------------------------------------- + +create table if not exists public.agentic_rectification_evidence ( + id uuid primary key default gen_random_uuid(), + case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade, + source_turn_id uuid not null references public.agentic_rectification_turns(id) on delete cascade, + source_message_id uuid, + user_quote text not null check (length(btrim(user_quote)) > 0), + subject text not null check (subject in ('self', 'family', 'other')), + event_kind text not null check ( + event_kind in ( + 'education_start', 'education_completion', 'education_interruption', + 'career_entry', 'career_change', 'promotion', 'career_pressure', 'career_exit', + 'relationship_start', 'relationship_commitment', 'relationship_separation', + 'relocation', 'finance_gain', 'finance_loss', + 'self_health_event', 'family_event', 'other' + ) + ), + domain text not null check ( + domain in ('education', 'career', 'relationship', 'relocation', 'finance', 'health', 'family', 'other') + ), + occurred_from date, + occurred_to date, + date_precision text not null check (date_precision in ('year', 'month', 'day', 'range', 'unknown')), + summary text not null check (length(btrim(summary)) > 0), + status text not null default 'draft' check ( + status in ('draft', 'pending_confirmation', 'confirmed', 'superseded', 'rejected') + ), + supersedes_evidence_id uuid references public.agentic_rectification_evidence(id) on delete set null, + created_at timestamptz not null default pg_catalog.now(), + confirmed_at timestamptz, + updated_at timestamptz not null default pg_catalog.now(), + check (status <> 'confirmed' or confirmed_at is not null), + check (supersedes_evidence_id is null or supersedes_evidence_id <> id) +); + +create index if not exists agentic_rectification_evidence_case_idx + on public.agentic_rectification_evidence (case_id, created_at); + +create index if not exists agentic_rectification_evidence_lineage_idx + on public.agentic_rectification_evidence (supersedes_evidence_id) + where supersedes_evidence_id is not null; + +-- --------------------------------------------------------------------------- +-- 4. Tool receipts (fingerprints/phase/tool/status only; no payload/reasoning) +-- --------------------------------------------------------------------------- + +create table if not exists public.agentic_rectification_tool_receipts ( + id uuid primary key default gen_random_uuid(), + case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade, + turn_id uuid not null references public.agentic_rectification_turns(id) on delete cascade, + tool_name text not null check ( + tool_name in ( + 'rectification-read-case', + 'rectification-propose-evidence', + 'rectification-confirm-evidence', + 'rectification-revise-evidence', + 'rectification-compare-candidates', + 'rectification-read-diagnostics', + 'rectification-offer-candidates', + 'rectification-accept-candidate', + 'rectification-confirm-birth-time', + 'rectification-close-case' + ) + ), + public_phase text not null check ( + public_phase in ( + 'run.started', 'skill.started', 'skill.loaded', 'case.loaded', + 'evidence.proposed', 'evidence.confirmed', 'candidates.comparing', 'candidates.updated', + 'diagnostics.completed', 'candidate.accepted', 'birth_time.confirmed', + 'answer.delta', 'run.completed', 'run.failed' + ) + ), + started_at timestamptz not null default pg_catalog.now(), + completed_at timestamptz, + status text not null check (status in ('started', 'completed', 'failed', 'skipped')), + input_fingerprint text, + result_fingerprint text, + engine_version text, + safe_error_code text +); + +create index if not exists agentic_rectification_tool_receipts_turn_idx + on public.agentic_rectification_tool_receipts (case_id, turn_id); + +-- --------------------------------------------------------------------------- +-- 5. Open request idempotency ledger (double-click / multi-tab safe) +-- --------------------------------------------------------------------------- + +create table if not exists public.agentic_rectification_open_ledger ( + request_id uuid not null, + user_id uuid not null references auth.users(id) on delete cascade, + case_id uuid not null references public.agentic_rectification_cases(id) on delete cascade, + session_id uuid not null references public.chat_sessions(id) on delete cascade, + intent text not null check (intent in ('homepage', 'session', 'new')), + created_at timestamptz not null default pg_catalog.now(), + primary key (user_id, request_id) +); + +-- --------------------------------------------------------------------------- +-- 6. Forward extension of agentic_rectification_results to case_id semantics +-- --------------------------------------------------------------------------- + +alter table public.agentic_rectification_results + add column if not exists case_id uuid + references public.agentic_rectification_cases(id) on delete set null, + add column if not exists evidence_ledger_fingerprint text, + add column if not exists candidate_range_fingerprint text, + add column if not exists skill_version text; + +create index if not exists agentic_rectification_results_case_idx + on public.agentic_rectification_results (case_id) + where case_id is not null; + +-- --------------------------------------------------------------------------- +-- 7. Chat session reverse pointer (one-to-one, bidirectional consistency) +-- --------------------------------------------------------------------------- + +alter table public.chat_sessions + add column if not exists agentic_rectification_case_id uuid + references public.agentic_rectification_cases(id) on delete set null; + +create unique index if not exists chat_sessions_agentic_rectification_case_unique + on public.chat_sessions (agentic_rectification_case_id) + where agentic_rectification_case_id is not null; + +-- --------------------------------------------------------------------------- +-- 8. RLS + grants (service-role only; browser never touches these tables) +-- --------------------------------------------------------------------------- + +alter table public.agentic_rectification_cases enable row level security; +alter table public.agentic_rectification_turns enable row level security; +alter table public.agentic_rectification_evidence enable row level security; +alter table public.agentic_rectification_tool_receipts enable row level security; +alter table public.agentic_rectification_open_ledger enable row level security; + +revoke all on table public.agentic_rectification_cases from public, anon, authenticated, service_role; +revoke all on table public.agentic_rectification_turns from public, anon, authenticated, service_role; +revoke all on table public.agentic_rectification_evidence from public, anon, authenticated, service_role; +revoke all on table public.agentic_rectification_tool_receipts from public, anon, authenticated, service_role; +revoke all on table public.agentic_rectification_open_ledger from public, anon, authenticated, service_role; +grant all on table public.agentic_rectification_cases to service_role; +grant all on table public.agentic_rectification_turns to service_role; +grant all on table public.agentic_rectification_evidence to service_role; +grant all on table public.agentic_rectification_tool_receipts to service_role; +grant all on table public.agentic_rectification_open_ledger to service_role; + +-- --------------------------------------------------------------------------- +-- 9. Bidirectional Case <-> Session consistency triggers +-- --------------------------------------------------------------------------- + +create or replace function public.agentic_rectification_cases_sync_session() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if new.session_id is distinct from old.session_id then + update public.chat_sessions + set agentic_rectification_case_id = null + where agentic_rectification_case_id = old.id; + end if; + update public.chat_sessions + set agentic_rectification_case_id = new.id, + session_type = 'birth_time_rectification' + where id = new.session_id; + return new; +end; +$$; + +revoke all on function public.agentic_rectification_cases_sync_session() + from public, anon, authenticated; + +drop trigger if exists agentic_rectification_cases_sync_session_trigger + on public.agentic_rectification_cases; +create trigger agentic_rectification_cases_sync_session_trigger +after insert or update of session_id on public.agentic_rectification_cases +for each row execute function public.agentic_rectification_cases_sync_session(); + +create or replace function public.agentic_rectification_chat_sessions_case_guard() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; +begin + if new.agentic_rectification_case_id is not null then + select * into v_case + from public.agentic_rectification_cases + where id = new.agentic_rectification_case_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.session_id is distinct from new.id then + raise exception 'agentic_rectification_case_session_mismatch' using errcode = 'P0001'; + end if; + if v_case.user_id is distinct from new.user_id then + raise exception 'agentic_rectification_case_owner_mismatch' using errcode = 'P0001'; + end if; + end if; + return new; +end; +$$; + +revoke all on function public.agentic_rectification_chat_sessions_case_guard() + from public, anon, authenticated; + +drop trigger if exists agentic_rectification_chat_sessions_case_guard_trigger + on public.chat_sessions; +create trigger agentic_rectification_chat_sessions_case_guard_trigger +before update of agentic_rectification_case_id on public.chat_sessions +for each row execute function public.agentic_rectification_chat_sessions_case_guard(); + +-- --------------------------------------------------------------------------- +-- 10. Shared helpers used by RPCs +-- --------------------------------------------------------------------------- + +create or replace function public.agentic_rectification_normalize_quote(p_value text) +returns text +language sql +immutable +as $$ + select regexp_replace( + lower(coalesce(p_value, '')), + '[\s\u3000,。!?、;:“”‘’()《》·—…]', + '', + 'g' + ) +$$; + +revoke all on function public.agentic_rectification_normalize_quote(text) + from public, anon, authenticated; + +create or replace function public.agentic_rectification_resumable_statuses() +returns text[] +language sql +immutable +as $$ + select array[ + 'draft', 'collecting_evidence', 'candidate_ready', 'candidate_accepted', + 'needs_rebaseline', 'paused' + ]::text[] +$$; + +revoke all on function public.agentic_rectification_resumable_statuses() + from public, anon, authenticated; + +create or replace function public.agentic_rectification_legacy_fingerprint( + p_user_id uuid, + p_session_id uuid, + p_status text +) +returns text +language sql +immutable +as $$ + select encode( + public.digest( + convert_to( + p_user_id::text || ':' || p_session_id::text || ':' || coalesce(p_status, ''), + 'utf8' + ), + 'sha256' + ), + 'hex' + ) +$$; + +revoke all on function public.agentic_rectification_legacy_fingerprint(uuid, uuid, text) + from public, anon, authenticated; + +-- --------------------------------------------------------------------------- +-- 11. Open Case RPC (idempotent, same-user serialized, atomic case+session) +-- --------------------------------------------------------------------------- + +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; + v_turn_count bigint; +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 concurrent open/create for the same user (double-click, two + -- tabs, retries). The advisory lock is released when this transaction ends. + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtext('agentic_rectification_open:' || p_user_id::text) + ); + + -- Idempotency: an earlier open with the same requestId wins. + 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_intent = 'homepage' then + select * into v_case + from public.agentic_rectification_cases + where user_id = p_user_id + and status = any (public.agentic_rectification_resumable_statuses()) + order by last_activity_at desc, id + limit 1; + if found then + select count(*) into v_turn_count + from public.agentic_rectification_turns + where case_id = v_case.id; + return jsonb_build_object( + 'disposition', 'resumed', + '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; + end if; + + if p_intent = 'new' then + select * into v_case + from public.agentic_rectification_cases + where user_id = p_user_id + and status = any (public.agentic_rectification_resumable_statuses()) + order by last_activity_at desc, id + limit 1; + if found then + -- supersedeActive=true is forbidden: restarting with an active case + -- must surface a safe conflict, never silently abandon. + raise exception 'agentic_rectification_active_case_conflict' using errcode = 'P0001'; + end if; + end if; + + -- Create a new case + a new session atomically. The inner exception block + -- rolls back both inserts together if a concurrent request already created + -- a resumable case for this user. Profile snapshot and candidate range are + -- validated here because only this path persists them. + begin + 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 id into v_case.id; + + 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 + ); + exception when unique_violation then + select * into v_case + from public.agentic_rectification_cases + where user_id = p_user_id + and status = any (public.agentic_rectification_resumable_statuses()) + order by last_activity_at desc, id + limit 1; + if not found then + raise; + end if; + 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_case.session_id, p_intent + ); + return jsonb_build_object( + 'disposition', 'resumed', + '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; + + return jsonb_build_object( + 'disposition', 'created', + 'case_id', v_case.id, + 'session_id', v_session_id, + 'status', 'draft', + 'should_start_opening', true, + 'skill_version', p_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; + +-- --------------------------------------------------------------------------- +-- 12. Entry summary RPC (homepage CTA truth) +-- --------------------------------------------------------------------------- + +create or replace function public.get_agentic_rectification_entry_summary( + p_user_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_resumable public.agentic_rectification_cases%rowtype; + v_terminal public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + select * into v_resumable + from public.agentic_rectification_cases + where user_id = p_user_id + and status = any (public.agentic_rectification_resumable_statuses()) + order by last_activity_at desc, id + limit 1; + + select * into v_terminal + from public.agentic_rectification_cases + where user_id = p_user_id + and status in ('confirmed', 'closed', 'abandoned', 'superseded') + order by last_activity_at desc, id + limit 1; + + return jsonb_build_object( + 'has_resumable_case', v_resumable.id is not null, + 'has_terminal_case_with_time', v_terminal.id is not null + and (v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null), + 'latest_resumable', case + when v_resumable.id is null then null + else jsonb_build_object( + 'case_id', v_resumable.id, + 'status', v_resumable.status, + 'last_activity_at', v_resumable.last_activity_at + ) + end, + 'latest_terminal', case + when v_terminal.id is null then null + else jsonb_build_object( + 'case_id', v_terminal.id, + 'status', v_terminal.status, + 'has_usable_time', v_terminal.accepted_time is not null or v_terminal.confirmed_time is not null + ) + end + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_entry_summary(uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_entry_summary(uuid) + to service_role; + +-- --------------------------------------------------------------------------- +-- 13. Case read RPC (sanitized projection; never the birth snapshot) +-- --------------------------------------------------------------------------- + +create or replace function public.get_agentic_rectification_case( + p_user_id uuid, + p_case_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_evidence_count bigint; + v_turn_count bigint; +begin + if p_user_id is null or p_case_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + select count(*) into v_evidence_count + from public.agentic_rectification_evidence + where case_id = v_case.id; + + select count(*) into v_turn_count + from public.agentic_rectification_turns + where case_id = v_case.id; + + select * into v_result + from public.agentic_rectification_results + where case_id = v_case.id + and invalidated_at is null + order by created_at desc + limit 1; + + return jsonb_build_object( + 'case_id', v_case.id, + 'session_id', v_case.session_id, + 'status', v_case.status, + 'skill_name', v_case.skill_name, + 'skill_version', v_case.skill_version, + 'candidate_range', v_case.candidate_range, + 'accepted_time', v_case.accepted_time, + 'confirmed_time', v_case.confirmed_time, + 'created_at', v_case.created_at, + 'last_activity_at', v_case.last_activity_at, + 'completed_at', v_case.completed_at, + 'closed_reason', v_case.closed_reason, + 'evidence_count', v_evidence_count, + 'turn_count', v_turn_count, + 'latest_result', case + when v_result.id is null then null + else jsonb_build_object( + 'result_id', v_result.id, + 'candidates', v_result.candidates, + 'overall_confidence', v_result.overall_confidence, + 'selection_allowed', v_result.selection_allowed, + 'confirmation_allowed', v_result.confirmation_allowed, + 'representative_time', v_result.representative_time, + 'selected_time', v_result.selected_time, + 'selection_kind', v_result.selection_kind, + 'created_at', v_result.created_at + ) + end + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_case(uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_case(uuid, uuid) + to service_role; + +-- --------------------------------------------------------------------------- +-- 14. Close Case RPC (terminal; never wraps as engine confirmed) +-- --------------------------------------------------------------------------- + +create or replace function public.close_agentic_rectification_case( + p_user_id uuid, + p_case_id uuid, + p_reason text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; +begin + if p_user_id is null or p_case_id is null + or coalesce(p_reason, '') not in ('completed_by_user', 'abandoned_by_user', 'other') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + if v_case.status <> 'closed' then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + return jsonb_build_object( + 'success', true, 'idempotent', true, + 'case_id', v_case.id, 'status', v_case.status + ); + end if; + + update public.agentic_rectification_cases + set status = 'closed', + closed_reason = p_reason, + completed_at = pg_catalog.now(), + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + return jsonb_build_object( + 'success', true, 'idempotent', false, + 'case_id', v_case.id, 'status', 'closed' + ); +end; +$$; + +revoke all on function public.close_agentic_rectification_case(uuid, uuid, text) + from public, anon, authenticated; +grant execute on function public.close_agentic_rectification_case(uuid, uuid, text) + to service_role; + +-- --------------------------------------------------------------------------- +-- 15. Upgrade Skill RPC (explicit migration; running cases pin their version) +-- --------------------------------------------------------------------------- + +create or replace function public.upgrade_agentic_rectification_skill( + p_user_id uuid, + p_case_id uuid, + p_skill_version text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_previous text; +begin + if p_user_id is null or p_case_id is null + or length(btrim(coalesce(p_skill_version, ''))) = 0 then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + v_previous := v_case.skill_version; + update public.agentic_rectification_cases + set skill_version = p_skill_version, + updated_at = pg_catalog.now() + where id = v_case.id; + + return jsonb_build_object( + 'success', true, + 'case_id', v_case.id, + 'previous_skill_version', v_previous, + 'skill_version', p_skill_version, + 'idempotent', v_previous = p_skill_version + ); +end; +$$; + +revoke all on function public.upgrade_agentic_rectification_skill(uuid, uuid, text) + from public, anon, authenticated; +grant execute on function public.upgrade_agentic_rectification_skill(uuid, uuid, text) + to service_role; + +-- --------------------------------------------------------------------------- +-- 16. Turn append RPC (server-owned; no reasoning ever persisted) +-- --------------------------------------------------------------------------- + +create or replace function public.append_agentic_rectification_turn( + p_user_id uuid, + p_case_id uuid, + p_user_message text, + p_assistant_message text, + p_model_name text, + p_model_version text, + p_status text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_turn_id uuid; +begin + if p_user_id is null or p_case_id is null + or length(btrim(coalesce(p_model_name, ''))) = 0 + or p_status not in ('pending', 'completed', 'failed', 'retryable') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + if p_status = 'completed' and (p_assistant_message is null or length(btrim(p_assistant_message)) = 0) then + raise exception 'agentic_rectification_turn_incomplete' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + insert into public.agentic_rectification_turns ( + case_id, user_message, assistant_message, status, model_name, model_version, completed_at + ) values ( + p_case_id, p_user_message, p_assistant_message, p_status, p_model_name, p_model_version, + case when p_status = 'completed' then pg_catalog.now() else null end + ) returning id into v_turn_id; + + update public.agentic_rectification_cases + set last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = p_case_id; + + return jsonb_build_object('turn_id', v_turn_id); +end; +$$; + +revoke all on function public.append_agentic_rectification_turn(uuid, uuid, text, text, text, text, text) + from public, anon, authenticated; +grant execute on function public.append_agentic_rectification_turn(uuid, uuid, text, text, text, text, text) + to service_role; + +-- --------------------------------------------------------------------------- +-- 17. Tool receipt insert RPC (fingerprints only) +-- --------------------------------------------------------------------------- + +create or replace function public.insert_agentic_rectification_tool_receipt( + p_user_id uuid, + p_case_id uuid, + p_turn_id uuid, + p_tool_name text, + p_public_phase text, + p_status text, + p_input_fingerprint text, + p_result_fingerprint text, + p_engine_version text, + p_safe_error_code text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_receipt_id uuid; + v_turn_count bigint; +begin + if p_user_id is null or p_case_id is null or p_turn_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + if not exists ( + select 1 from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + ) then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + select count(*) into v_turn_count + from public.agentic_rectification_turns + where id = p_turn_id and case_id = p_case_id; + if v_turn_count = 0 then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + + insert into public.agentic_rectification_tool_receipts ( + case_id, turn_id, tool_name, public_phase, status, + input_fingerprint, result_fingerprint, engine_version, safe_error_code, + completed_at + ) values ( + p_case_id, p_turn_id, p_tool_name, p_public_phase, p_status, + p_input_fingerprint, p_result_fingerprint, p_engine_version, p_safe_error_code, + case when p_status = 'completed' then pg_catalog.now() else null end + ) returning id into v_receipt_id; + + return jsonb_build_object('receipt_id', v_receipt_id); +end; +$$; + +revoke all on function public.insert_agentic_rectification_tool_receipt(uuid, uuid, uuid, text, text, text, text, text, text, text) + from public, anon, authenticated; +grant execute on function public.insert_agentic_rectification_tool_receipt(uuid, uuid, uuid, text, text, text, text, text, text, text) + to service_role; + +-- --------------------------------------------------------------------------- +-- 18. Evidence proposal / confirm / revise RPCs +-- --------------------------------------------------------------------------- + +create or replace function public.propose_agentic_rectification_evidence( + p_user_id uuid, + p_case_id uuid, + p_source_turn_id uuid, + p_user_quote text, + p_subject text, + p_event_kind text, + p_domain text, + p_occurred_from date, + p_occurred_to date, + p_date_precision text, + p_summary text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_turn public.agentic_rectification_turns%rowtype; + v_evidence_id uuid; + v_existing_id uuid; +begin + if p_user_id is null or p_case_id is null or p_source_turn_id is null + or length(btrim(coalesce(p_user_quote, ''))) = 0 + or length(btrim(coalesce(p_summary, ''))) = 0 + or p_subject not in ('self', 'family', 'other') + or p_date_precision not in ('year', 'month', 'day', 'range', 'unknown') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_turn + from public.agentic_rectification_turns + where id = p_source_turn_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + if v_turn.user_message is null + or position( + public.agentic_rectification_normalize_quote(p_user_quote) + in public.agentic_rectification_normalize_quote(v_turn.user_message) + ) = 0 then + raise exception 'agentic_rectification_quote_not_grounded' using errcode = 'P0001'; + end if; + + -- Idempotency: replaying the same proposal returns the existing draft. + select id into v_existing_id + from public.agentic_rectification_evidence + where case_id = p_case_id + and source_turn_id = p_source_turn_id + and user_quote = p_user_quote + and event_kind = p_event_kind + and summary = p_summary + and status in ('draft', 'pending_confirmation') + limit 1; + if v_existing_id is not null then + return jsonb_build_object('evidence_id', v_existing_id, 'idempotent', true); + end if; + + insert into public.agentic_rectification_evidence ( + case_id, source_turn_id, user_quote, subject, event_kind, domain, + occurred_from, occurred_to, date_precision, summary, status + ) values ( + p_case_id, p_source_turn_id, p_user_quote, p_subject, p_event_kind, p_domain, + p_occurred_from, p_occurred_to, p_date_precision, p_summary, 'draft' + ) returning id into v_evidence_id; + + update public.agentic_rectification_cases + set last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = p_case_id; + + return jsonb_build_object('evidence_id', v_evidence_id, 'idempotent', false); +end; +$$; + +revoke all on function public.propose_agentic_rectification_evidence(uuid, uuid, uuid, text, text, text, text, date, date, text, text) + from public, anon, authenticated; +grant execute on function public.propose_agentic_rectification_evidence(uuid, uuid, uuid, text, text, text, text, date, date, text, text) + to service_role; + +create or replace function public.confirm_agentic_rectification_evidence( + p_user_id uuid, + p_case_id uuid, + p_evidence_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_evidence public.agentic_rectification_evidence%rowtype; +begin + if p_user_id is null or p_case_id is null or p_evidence_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_evidence + from public.agentic_rectification_evidence + where id = p_evidence_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001'; + end if; + if v_evidence.status = 'confirmed' then + return jsonb_build_object('evidence_id', v_evidence.id, 'status', 'confirmed', 'idempotent', true); + end if; + if v_evidence.status not in ('draft', 'pending_confirmation') then + raise exception 'agentic_rectification_evidence_not_confirmable' using errcode = 'P0001'; + end if; + + update public.agentic_rectification_evidence + set status = 'confirmed', + confirmed_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_evidence.id; + + return jsonb_build_object('evidence_id', v_evidence.id, 'status', 'confirmed', 'idempotent', false); +end; +$$; + +revoke all on function public.confirm_agentic_rectification_evidence(uuid, uuid, uuid) + from public, anon, authenticated; +grant execute on function public.confirm_agentic_rectification_evidence(uuid, uuid, uuid) + to service_role; + +create or replace function public.revise_agentic_rectification_evidence( + p_user_id uuid, + p_case_id uuid, + p_evidence_id uuid, + p_user_quote text, + p_occurred_from date, + p_occurred_to date, + p_date_precision text, + p_summary text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_target public.agentic_rectification_evidence%rowtype; + v_new_id uuid; +begin + if p_user_id is null or p_case_id is null or p_evidence_id is null + or length(btrim(coalesce(p_user_quote, ''))) = 0 + or length(btrim(coalesce(p_summary, ''))) = 0 + or p_date_precision not in ('year', 'month', 'day', 'range', 'unknown') then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_target + from public.agentic_rectification_evidence + where id = p_evidence_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_evidence_not_found' using errcode = 'P0001'; + end if; + if v_target.status not in ('confirmed', 'pending_confirmation') then + raise exception 'agentic_rectification_evidence_not_revisable' using errcode = 'P0001'; + end if; + + -- Append-only lineage: the old row is superseded, never overwritten. + update public.agentic_rectification_evidence + set status = 'superseded', + updated_at = pg_catalog.now() + where id = v_target.id; + + insert into public.agentic_rectification_evidence ( + case_id, source_turn_id, user_quote, subject, event_kind, domain, + occurred_from, occurred_to, date_precision, summary, status, supersedes_evidence_id + ) values ( + v_target.case_id, v_target.source_turn_id, p_user_quote, v_target.subject, + v_target.event_kind, v_target.domain, + p_occurred_from, p_occurred_to, p_date_precision, p_summary, + 'pending_confirmation', v_target.id + ) returning id into v_new_id; + + update public.agentic_rectification_cases + set last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = p_case_id; + + return jsonb_build_object( + 'evidence_id', v_new_id, + 'supersedes_evidence_id', v_target.id, + 'idempotent', false + ); +end; +$$; + +revoke all on function public.revise_agentic_rectification_evidence(uuid, uuid, uuid, text, date, date, text, text) + from public, anon, authenticated; +grant execute on function public.revise_agentic_rectification_evidence(uuid, uuid, uuid, text, date, date, text, text) + to service_role; + +-- --------------------------------------------------------------------------- +-- 19. Legacy Agentic backfill (one-time, idempotent) +-- +-- Mapping rules: +-- * engine_confirmed result -> confirmed (terminal) +-- * user_accepted result only -> candidate_accepted (resumable) +-- * messages present, no selection -> collecting_evidence, or +-- candidate_ready when results exist +-- * empty session -> draft for the newest empty per user +-- only; older empties -> abandoned +-- * per user, at most one resumable -> latest activity wins; the rest of +-- the resumable candidates are +-- superseded (read-only history kept) +-- * no confirmed evidence is ever generated from chat text +-- * agentic_rectification_results rows are mapped to the backfilled case_id +-- --------------------------------------------------------------------------- + +create or replace function public.backfill_agentic_rectification_legacy_cases() +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_row record; + v_case_id uuid; + v_fingerprint text; + v_snapshot jsonb; + v_range jsonb; + v_profile public.profiles%rowtype; + v_selected_time time without time zone; + v_result_range jsonb; + v_base_birth_date date; + v_base_lat double precision; + v_base_lon double precision; + v_base_tz double precision; + v_base_source text; + v_accepted_time time without time zone; + v_confirmed_time time without time zone; + v_legacy_sessions integer := 0; + v_created integer := 0; + v_mapped_results integer := 0; + v_confirmed_count integer := 0; + v_candidate_accepted_count integer := 0; + v_candidate_ready_count integer := 0; + v_collecting_count integer := 0; + v_draft_count integer := 0; + v_abandoned_count integer := 0; + v_superseded_count integer := 0; +begin + for v_row in + with candidate as ( + select + s.id as session_id, + s.user_id, + s.updated_at, + (jsonb_typeof(coalesce(s.messages, '[]'::jsonb)) = 'array' + and jsonb_array_length(coalesce(s.messages, '[]'::jsonb)) > 0) as has_messages, + exists ( + select 1 from public.agentic_rectification_results r where r.session_id = s.id + ) as has_results, + (select r.selection_kind + from public.agentic_rectification_results r + where r.session_id = s.id + and r.selection_kind in ('engine_confirmed', 'user_accepted') + order by r.created_at desc + limit 1) as result_kind, + (select count(*) from public.agentic_rectification_results r where r.session_id = s.id) as result_count + from public.chat_sessions s + where s.session_type = 'birth_time_rectification' + and not exists ( + select 1 from public.agentic_rectification_cases c where c.session_id = s.id + ) + ), + based as ( + select candidate.*, + case + when result_kind = 'engine_confirmed' then 'confirmed' + when result_kind = 'user_accepted' then 'candidate_accepted' + when has_messages then case when has_results then 'candidate_ready' else 'collecting_evidence' end + else 'abandoned' + end as base_status + from candidate + ) + select ranked.*, + case + when base_status = any (public.agentic_rectification_resumable_statuses()) + and resumable_rank > 1 then 'superseded' + when base_status = 'abandoned' + and resumable_rank = 1 and activity_rank = 1 then 'draft' + else base_status + end as final_status + from ( + select based.*, + row_number() over ( + partition by user_id + order by (base_status = any (public.agentic_rectification_resumable_statuses())) desc, + updated_at desc, session_id + ) as resumable_rank, + row_number() over ( + partition by user_id order by updated_at desc, session_id + ) as activity_rank + from based + ) ranked + order by user_id, updated_at desc, session_id + loop + v_legacy_sessions := v_legacy_sessions + 1; + v_range := null; + v_accepted_time := null; + v_confirmed_time := null; + v_selected_time := null; + v_result_range := null; + v_base_birth_date := null; + v_base_lat := null; + v_base_lon := null; + v_base_tz := null; + v_base_source := null; + + select r.selected_time, r.candidate_range, + r.baseline_birth_date, r.baseline_latitude, r.baseline_longitude, + r.baseline_timezone_offset, r.baseline_birth_time_source + into v_selected_time, v_result_range, + v_base_birth_date, v_base_lat, v_base_lon, v_base_tz, v_base_source + from public.agentic_rectification_results r + where r.session_id = v_row.session_id + order by r.created_at desc + limit 1; + + if v_result_range is not null and jsonb_typeof(v_result_range) = 'object' + and (v_result_range ->> 'start_time') ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' + and (v_result_range ->> 'end_time') ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$' then + v_range := v_result_range; + end if; + if v_row.final_status = 'confirmed' then + v_confirmed_time := v_selected_time; + elsif v_row.final_status = 'candidate_accepted' then + v_accepted_time := v_selected_time; + end if; + + select * into v_profile from public.profiles where id = v_row.user_id; + + if v_range is null then + if v_profile.id is not null + and (v_profile.active_birth_time is not null or v_profile.reported_birth_time is not null) then + v_range := jsonb_build_object( + 'start_time', coalesce(to_char(v_profile.active_birth_time, 'HH24:MI'), to_char(v_profile.reported_birth_time, 'HH24:MI')), + 'end_time', coalesce(to_char(v_profile.active_birth_time, 'HH24:MI'), to_char(v_profile.reported_birth_time, 'HH24:MI')) + ); + else + v_range := jsonb_build_object('start_time', '00:00', 'end_time', '23:59'); + end if; + end if; + + v_snapshot := jsonb_build_object( + 'birth_date', to_char(coalesce(v_profile.birth_date, v_base_birth_date), 'YYYY-MM-DD'), + 'latitude', coalesce(v_profile.latitude, v_base_lat), + 'longitude', coalesce(v_profile.longitude, v_base_lon), + 'timezone_offset', coalesce(v_profile.timezone_offset, v_base_tz), + 'birth_time_source', coalesce(v_profile.birth_time_source, v_base_source, 'legacy_import') + ); + v_fingerprint := public.agentic_rectification_legacy_fingerprint( + v_row.user_id, v_row.session_id, v_row.final_status + ); + + insert into public.agentic_rectification_cases ( + user_id, session_id, status, skill_name, skill_version, + baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range, + accepted_time, confirmed_time, last_activity_at, completed_at + ) values ( + v_row.user_id, v_row.session_id, v_row.final_status, + 'jyotish-birth-time-rectification', '9.0.0', + v_fingerprint, v_snapshot, v_range, + v_accepted_time, v_confirmed_time, v_row.updated_at, + case when v_row.final_status in ('confirmed', 'closed') then v_row.updated_at else null end + ) returning id into v_case_id; + + update public.agentic_rectification_results + set case_id = v_case_id + where session_id = v_row.session_id and case_id is null; + v_mapped_results := v_mapped_results + v_row.result_count; + + v_created := v_created + 1; + if v_row.final_status = 'confirmed' then + v_confirmed_count := v_confirmed_count + 1; + elsif v_row.final_status = 'candidate_accepted' then + v_candidate_accepted_count := v_candidate_accepted_count + 1; + elsif v_row.final_status = 'candidate_ready' then + v_candidate_ready_count := v_candidate_ready_count + 1; + elsif v_row.final_status = 'collecting_evidence' then + v_collecting_count := v_collecting_count + 1; + elsif v_row.final_status = 'draft' then + v_draft_count := v_draft_count + 1; + elsif v_row.final_status = 'abandoned' then + v_abandoned_count := v_abandoned_count + 1; + elsif v_row.final_status = 'superseded' then + v_superseded_count := v_superseded_count + 1; + end if; + end loop; + + return jsonb_build_object( + 'legacy_sessions_scanned', v_legacy_sessions, + 'cases_created', v_created, + 'results_mapped', v_mapped_results, + 'confirmed', v_confirmed_count, + 'candidate_accepted', v_candidate_accepted_count, + 'candidate_ready', v_candidate_ready_count, + 'collecting_evidence', v_collecting_count, + 'draft', v_draft_count, + 'abandoned', v_abandoned_count, + 'superseded', v_superseded_count + ); +end; +$$; + +revoke all on function public.backfill_agentic_rectification_legacy_cases() + from public, anon, authenticated; +grant execute on function public.backfill_agentic_rectification_legacy_cases() + to service_role; + +-- --------------------------------------------------------------------------- +-- 20. Backfill verification (idempotent; can be re-run anytime) +-- --------------------------------------------------------------------------- + +create or replace function public.verify_agentic_rectification_backfill() +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_total_cases bigint; + v_resumable bigint; + v_terminal bigint; + v_unmapped_results bigint; + v_legacy_without_case bigint; + v_orphan_cases bigint; + v_conflicts bigint; +begin + select count(*) into v_total_cases from public.agentic_rectification_cases; + select count(*) into v_resumable + from public.agentic_rectification_cases + where status = any (public.agentic_rectification_resumable_statuses()); + select count(*) into v_terminal + from public.agentic_rectification_cases + where status in ('confirmed', 'closed', 'abandoned', 'superseded'); + select count(*) into v_unmapped_results + from public.agentic_rectification_results + where case_id is null; + select count(*) into v_legacy_without_case + from public.chat_sessions s + where s.session_type = 'birth_time_rectification' + and not exists ( + select 1 from public.agentic_rectification_cases c where c.session_id = s.id + ); + select count(*) into v_orphan_cases + from public.agentic_rectification_cases c + left join public.chat_sessions s on s.id = c.session_id + where s.id is null; + select count(*) into v_conflicts + from ( + select user_id + from public.agentic_rectification_cases + where status = any (public.agentic_rectification_resumable_statuses()) + group by user_id + having count(*) > 1 + ) conflicts; + + return jsonb_build_object( + 'total_cases', v_total_cases, + 'resumable_cases', v_resumable, + 'terminal_cases', v_terminal, + 'unmapped_results', v_unmapped_results, + 'legacy_sessions_without_case', v_legacy_without_case, + 'orphan_cases', v_orphan_cases, + 'resumable_conflicts', v_conflicts + ); +end; +$$; + +revoke all on function public.verify_agentic_rectification_backfill() + from public, anon, authenticated; +grant execute on function public.verify_agentic_rectification_backfill() + to service_role; + +-- --------------------------------------------------------------------------- +-- 21. Run the one-time backfill inside this migration (apply). The app never +-- runs bulk legacy migration at startup. +-- --------------------------------------------------------------------------- + +do $$ +declare + v_result jsonb; +begin + select public.backfill_agentic_rectification_legacy_cases() into v_result; + raise notice 'agentic_rectification_v9 backfill: %', v_result; +end; +$$; + +commit; diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index d0561774..834caf79 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -48,6 +48,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260806040000_model_configuration\.sql/); assert.match(migration.stdout, /applied 20260806050000_operations_feature_flags\.sql/); assert.match(migration.stdout, /applied 20260811010000_consultation_status_service_role_read\.sql/); + assert.match(migration.stdout, /applied 20260812010000_agentic_rectification_v9_runtime\.sql/); assert.equal( fixture.psql(` @@ -108,7 +109,12 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic "admin_session_revocations", "admin_user_roles", "admin_users", + "agentic_rectification_cases", + "agentic_rectification_evidence", + "agentic_rectification_open_ledger", "agentic_rectification_results", + "agentic_rectification_tool_receipts", + "agentic_rectification_turns", "billing_products", "birth_time_rectification_action_receipts", "birth_time_rectification_agent_runs", diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts new file mode 100644 index 00000000..47d675c2 --- /dev/null +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -0,0 +1,325 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RectificationCaseServiceError, + closeRectificationCase, + getRectificationCase, + getRectificationEntrySummary, + loadV9RectificationProfile, + mapRectificationRpcError, + openRectificationCase, + upgradeRectificationSkill, +} from "../src/lib/rectification-agentic/v9/case-service.ts"; +import type { OpenRectificationCaseRequest } from "../src/lib/rectification-agentic/v9/open-request.ts"; + +type RpcHandler = (fn: string, args: Record) => Promise<{ + data: unknown; + error: { message: string } | null; +}>; + +function fakeAccounting(overrides: { + profile?: Record | null; + rpc?: RpcHandler; +}) { + const rpc = + overrides.rpc ?? + (async () => ({ data: null, error: { message: "agentic_rectification_case_not_found" } })); + return { + from: (table: string) => { + if (table !== "profiles") throw new Error(`unexpected table ${table}`); + const builder = { + select: () => builder, + eq: () => builder, + single: async () => { + if (!overrides.profile) return { data: null, error: { message: "not found" } }; + return { data: overrides.profile, error: null }; + }, + }; + return builder; + }, + rpc, + } as never; +} + +const completeProfile = { + birth_date: "1997-08-08", + reported_birth_time: "05:00", + active_birth_time: null, + birth_time_source: "family_exact", + birth_time_period: null, + uncertainty_before_minutes: 10, + uncertainty_after_minutes: 10, + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, +}; + +const requestId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; +const caseId = "11111111-1111-4111-8111-111111111111"; +const sessionId = "22222222-2222-4222-8222-222222222222"; + +function openRpc(data: unknown): RpcHandler { + return async () => ({ data, error: null }); +} + +test("loadV9RectificationProfile derives baseline snapshot, fingerprint and range server-side", async () => { + const accounting = fakeAccounting({ profile: completeProfile }); + const profile = await loadV9RectificationProfile(accounting, "user-1"); + assert.equal(profile.userId, "user-1"); + assert.equal(profile.baseline.birth_date, "1997-08-08"); + assert.equal(profile.baselineFingerprint.length, 64); + assert.deepEqual(profile.candidateRange, { start_time: "04:50", end_time: "05:10" }); + assert.ok(!("password" in profile.baseline)); +}); + +test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async () => { + const dateProfile = { + ...completeProfile, + birth_date: new Date("1997-08-08T00:00:00.000Z"), + }; + const accounting = fakeAccounting({ profile: dateProfile }); + const profile = await loadV9RectificationProfile(accounting, "user-1"); + assert.equal(profile.baseline.birth_date, "1997-08-08"); +}); + +test("loadV9RectificationProfile rejects incomplete profiles without creating a case", async () => { + const incomplete = { + ...completeProfile, + latitude: null, + longitude: null, + timezone_offset: null, + }; + const accounting = fakeAccounting({ profile: incomplete }); + await assert.rejects( + () => loadV9RectificationProfile(accounting, "user-1"), + (error: unknown) => + error instanceof RectificationCaseServiceError && error.code === "profile_incomplete", + ); +}); + +test("period-only profiles derive a period range; unknown derives the full day", async () => { + const period = fakeAccounting({ + profile: { + ...completeProfile, + reported_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "morning", + }, + }); + const periodProfile = await loadV9RectificationProfile(period, "user-1"); + assert.deepEqual(periodProfile.candidateRange, { start_time: "08:00", end_time: "11:59" }); + + const unknown = fakeAccounting({ + profile: { + ...completeProfile, + reported_birth_time: null, + birth_time_source: "unknown", + birth_time_period: null, + }, + }); + const unknownProfile = await loadV9RectificationProfile(unknown, "user-1"); + assert.deepEqual(unknownProfile.candidateRange, { start_time: "00:00", end_time: "23:59" }); +}); + +test("homepage open with no history creates one case and one session", async () => { + const accounting = fakeAccounting({ + profile: completeProfile, + rpc: openRpc({ + disposition: "created", + case_id: caseId, + session_id: sessionId, + status: "draft", + should_start_opening: true, + skill_version: "9.0.0", + }), + }); + const request: OpenRectificationCaseRequest = { intent: "homepage", requestId }; + const response = await openRectificationCase(accounting, "user-1", request); + assert.equal(response.disposition, "created"); + assert.equal(response.caseId, caseId); + assert.equal(response.sessionId, sessionId); + assert.equal(response.shouldStartOpening, true); +}); + +test("homepage open with a resumable case resumes without creating a session", async () => { + const accounting = fakeAccounting({ + profile: completeProfile, + rpc: openRpc({ + disposition: "resumed", + case_id: caseId, + session_id: sessionId, + status: "collecting_evidence", + should_start_opening: false, + skill_version: "9.0.0", + }), + }); + const response = await openRectificationCase(accounting, "user-1", { + intent: "homepage", + requestId, + }); + assert.equal(response.disposition, "resumed"); + assert.equal(response.shouldStartOpening, false); +}); + +test("session intent passes the exact sessionId and never the profile snapshot", async () => { + const capturedArgs: { value: Record | null } = { value: null }; + const accounting = fakeAccounting({ + profile: null, + rpc: async (fn, args) => { + capturedArgs.value = args; + return { + data: { + disposition: "readonly", + case_id: caseId, + session_id: sessionId, + status: "closed", + should_start_opening: false, + skill_version: "9.0.0", + }, + error: null, + }; + }, + }); + const response = await openRectificationCase(accounting, "user-1", { + intent: "session", + requestId, + sessionId, + }); + assert.equal(response.disposition, "readonly"); + assert.equal(capturedArgs.value?.p_session_id, sessionId); + assert.deepEqual(capturedArgs.value?.p_baseline_birth_snapshot, {}); +}); + +test("non-owner session opens surface a safe not-found error", async () => { + const accounting = fakeAccounting({ + profile: null, + rpc: async () => ({ data: null, error: { message: "agentic_rectification_session_not_found" } }), + }); + await assert.rejects( + () => + openRectificationCase(accounting, "user-1", { + intent: "session", + requestId, + sessionId: "99999999-9999-4999-8999-999999999999", + }), + (error: unknown) => + error instanceof RectificationCaseServiceError && error.code === "case_session_not_found", + ); +}); + +test("intent new with an active case surfaces a safe conflict, never silent abandon", async () => { + const accounting = fakeAccounting({ + profile: completeProfile, + rpc: async () => ({ data: null, error: { message: "agentic_rectification_active_case_conflict" } }), + }); + await assert.rejects( + () => openRectificationCase(accounting, "user-1", { intent: "new", requestId }), + (error: unknown) => + error instanceof RectificationCaseServiceError && error.code === "active_case_conflict", + ); +}); + +test("entry summary projects resume-or-create truth for the homepage card", async () => { + const accounting = fakeAccounting({ + rpc: async (fn) => { + assert.equal(fn, "get_agentic_rectification_entry_summary"); + return { + data: { + has_resumable_case: true, + has_terminal_case_with_time: false, + latest_resumable: { + case_id: caseId, + status: "collecting_evidence", + last_activity_at: "2026-08-12T00:00:00.000Z", + }, + latest_terminal: null, + }, + error: null, + }; + }, + }); + const summary = await getRectificationEntrySummary(accounting, "user-1"); + assert.equal(summary.hasResumableCase, true); + assert.equal(summary.hasTerminalCaseWithTime, false); + assert.equal(summary.latestResumable?.caseId, caseId); +}); + +test("get case returns a sanitized projection without the birth snapshot", async () => { + const accounting = fakeAccounting({ + rpc: async () => ({ + data: { + case_id: caseId, + session_id: sessionId, + status: "candidate_ready", + skill_name: "jyotish-birth-time-rectification", + skill_version: "9.0.0", + candidate_range: { start_time: "04:50", end_time: "05:10" }, + accepted_time: null, + confirmed_time: null, + created_at: "2026-08-12T00:00:00.000Z", + last_activity_at: "2026-08-12T00:00:00.000Z", + completed_at: null, + closed_reason: null, + evidence_count: 3, + turn_count: 4, + latest_result: null, + }, + error: null, + }), + }); + const view = await getRectificationCase(accounting, "user-1", caseId); + assert.equal(view.caseId, caseId); + assert.equal(view.evidenceCount, 3); + assert.deepEqual(view.candidateRange, { start_time: "04:50", end_time: "05:10" }); + assert.ok(!("baseline_birth_snapshot" in view)); + assert.ok(!("birth_date" in view)); +}); + +test("close is idempotent and never wraps as engine confirmed", async () => { + const accounting = fakeAccounting({ + rpc: async (fn, args) => { + assert.equal(args.p_reason, "completed_by_user"); + return { data: { success: true, case_id: caseId, status: "closed", idempotent: false }, error: null }; + }, + }); + const result = await closeRectificationCase(accounting, "user-1", caseId, "completed_by_user"); + assert.equal(result.status, "closed"); + assert.equal(result.idempotent, false); +}); + +test("upgrade skill migrates a running case to a pinned version", async () => { + const accounting = fakeAccounting({ + rpc: async (fn, args) => { + assert.equal(args.p_skill_version, "9.1.0"); + return { + data: { + success: true, + case_id: caseId, + previous_skill_version: "9.0.0", + skill_version: "9.1.0", + idempotent: false, + }, + error: null, + }; + }, + }); + const result = await upgradeRectificationSkill(accounting, "user-1", caseId, "9.1.0"); + assert.equal(result.skillVersion, "9.1.0"); +}); + +test("rpc errors map to safe public views without leaking database text", async () => { + const generic = mapRectificationRpcError(new Error("syntax error at or near \"profiles\"")); + assert.equal(generic.status, 500); + assert.equal(generic.code, "rectification_service_failed"); + assert.doesNotMatch(generic.message, /syntax error/); + + const conflict = mapRectificationRpcError(new Error("agentic_rectification_active_case_conflict")); + assert.equal(conflict.status, 409); + assert.equal(conflict.code, "active_case_conflict"); + assert.doesNotMatch(conflict.message, /agentic_rectification/); + + const notFound = mapRectificationRpcError(new Error("agentic_rectification_case_not_found")); + assert.equal(notFound.status, 404); + assert.equal(notFound.code, "case_not_found"); +}); diff --git a/frontend/tests/rectification-v9-contracts.test.ts b/frontend/tests/rectification-v9-contracts.test.ts new file mode 100644 index 00000000..9f95064b --- /dev/null +++ b/frontend/tests/rectification-v9-contracts.test.ts @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + MAX_RESUMABLE_CASES_PER_USER, + RECTIFICATION_CASE_STATUSES, + RECTIFICATION_SKILL_NAME, + RECTIFICATION_SKILL_VERSION, + RESUMABLE_CASE_STATUSES, + TERMINAL_CASE_STATUSES, + canTransitToTerminal, + evidenceWritesAllowed, + isRectificationCaseStatus, + isResumableStatus, + isTerminalStatus, +} from "../src/lib/rectification-agentic/v9/case-status.ts"; +import { + BACKGROUND_ONLY_KINDS, + DISTINCT_KIND_GROUPS, + EVIDENCE_KINDS, + canTransitEvidenceStatus, + isBackgroundEvidenceKind, + isDatePrecision, + isEvidenceDomain, + isEvidenceKind, + isEvidenceStatus, + normalizeQuote, + quoteIsGroundedInMessage, +} from "../src/lib/rectification-agentic/v9/evidence-model.ts"; +import { + PUBLIC_ACTIVITY_EVENTS, + PUBLIC_RECTIFICATION_PHASES, + PUBLIC_RECTIFICATION_TOOLS, + safeActivityEvent, +} from "../src/lib/rectification-agentic/v9/public-receipt.ts"; +import { + openRectificationCaseRequestSchema, + shouldStartOpening, +} from "../src/lib/rectification-agentic/v9/open-request.ts"; + +const skillDirectory = fileURLToPath( + new URL("../../skills/jyotish-birth-time-rectification", import.meta.url), +); +const skill = readFileSync(`${skillDirectory}/SKILL.md`, "utf8"); +const references = [ + "evidence-model.md", + "conversation-strategy.md", + "candidate-comparison.md", + "technique-routing.md", + "truth-consent-boundaries.md", +]; + +test("v9 case status machine separates resumable from terminal statuses", () => { + assert.deepEqual([...RESUMABLE_CASE_STATUSES], [ + "draft", + "collecting_evidence", + "candidate_ready", + "candidate_accepted", + "needs_rebaseline", + "paused", + ]); + assert.deepEqual([...TERMINAL_CASE_STATUSES], [ + "confirmed", + "closed", + "abandoned", + "superseded", + ]); + for (const status of RECTIFICATION_CASE_STATUSES) { + assert.equal(isRectificationCaseStatus(status), true); + assert.equal(isResumableStatus(status), RESUMABLE_CASE_STATUSES.includes(status)); + assert.equal(isTerminalStatus(status), TERMINAL_CASE_STATUSES.includes(status)); + } + assert.equal(isRectificationCaseStatus("mystery"), false); +}); + +test("terminal transitions are one-way and evidence writes stop at terminal", () => { + assert.equal(canTransitToTerminal("candidate_accepted", "confirmed"), true); + assert.equal(canTransitToTerminal("collecting_evidence", "closed"), true); + assert.equal(canTransitToTerminal("confirmed", "closed"), false); + assert.equal(canTransitToTerminal("closed", "candidate_ready"), false); + assert.equal(evidenceWritesAllowed("collecting_evidence"), true); + assert.equal(evidenceWritesAllowed("candidate_accepted"), true); + assert.equal(evidenceWritesAllowed("needs_rebaseline"), true); + assert.equal(evidenceWritesAllowed("confirmed"), false); + assert.equal(evidenceWritesAllowed("closed"), false); + assert.equal(evidenceWritesAllowed("superseded"), false); + assert.equal(MAX_RESUMABLE_CASES_PER_USER, 1); +}); + +test("the v9 skill pins its name and version and lives in the right directory", () => { + assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification"); + assert.equal(RECTIFICATION_SKILL_VERSION, "9.0.0"); + assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m); + for (const reference of references) { + const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8"); + assert.ok(content.length > 0, `${reference} must be non-empty`); + } + assert.ok(skill.split("\n").length <= 200, "SKILL.md must stay within 200 lines"); +}); + +test("skill keeps the method in the skill, not a hard-coded questionnaire", () => { + // The skill may only mention the old questionnaire as an explicit + // prohibition, never as a requirement. + assert.match(skill, /不再有固定 10[–-]15 个事件/); + assert.match(skill, /固定 80%\/60% 匹配率/); + assert.match(skill, /固定 A\/B\/C\/D 问卷/); + assert.match(skill, /不得在文本中伪造出生分钟/); + assert.match(skill, /日期精度真实保留/); + assert.match(skill, /candidate[\s\S]*accepted[\s\S]*confirmed/); +}); + +test("evidence model exposes the full kind/domain/precision/status sets", () => { + for (const kind of EVIDENCE_KINDS) assert.equal(isEvidenceKind(kind), true); + assert.equal(isEvidenceKind("career"), false); + assert.equal(isEvidenceDomain("career"), true); + assert.equal(isEvidenceDomain("nope"), false); + for (const precision of ["year", "month", "day", "range", "unknown"]) { + assert.equal(isDatePrecision(precision), true); + } + assert.equal(isDatePrecision("exact_minute"), false); + for (const status of ["draft", "pending_confirmation", "confirmed", "superseded", "rejected"]) { + assert.equal(isEvidenceStatus(status), true); + } + assert.equal(isEvidenceStatus("collected"), false); +}); + +test("semantically distinct kinds are never folded together", () => { + const flat = DISTINCT_KIND_GROUPS.flat(); + assert.ok(flat.includes("career_entry") && flat.includes("career_pressure") && flat.includes("career_exit")); + assert.ok(flat.includes("relationship_start") && flat.includes("relationship_commitment") && flat.includes("relationship_separation")); + assert.equal(new Set(flat).size, flat.length); +}); + +test("only the server confirmation path may produce confirmed evidence", () => { + assert.equal(canTransitEvidenceStatus("draft", "pending_confirmation"), true); + assert.equal(canTransitEvidenceStatus("pending_confirmation", "confirmed"), true); + assert.equal(canTransitEvidenceStatus("confirmed", "superseded"), true); + assert.equal(canTransitEvidenceStatus("superseded", "confirmed"), false); + assert.equal(canTransitEvidenceStatus("rejected", "confirmed"), false); + assert.equal(canTransitEvidenceStatus("draft", "confirmed"), false); +}); + +test("quote grounding normalizes whitespace and punctuation", () => { + assert.equal( + normalizeQuote("2016 年 9 月,我离开家去北京开始工作。"), + "2016年9月,我离开家去北京开始工作。".replace(/[\s\u3000,。!?、;:“”‘’()《》·—…]/g, ""), + ); + assert.equal(quoteIsGroundedInMessage("2016年9月离开家去北京工作", "离开家去北京"), true); + assert.equal(quoteIsGroundedInMessage("我去了上海", "去了北京"), false); + assert.equal(quoteIsGroundedInMessage("", "任意"), false); +}); + +test("family/other are background-only kinds that never advance scoring", () => { + assert.equal(isBackgroundEvidenceKind("family_event"), true); + assert.equal(isBackgroundEvidenceKind("other"), true); + assert.equal(isBackgroundEvidenceKind("career_entry"), false); + assert.equal(BACKGROUND_ONLY_KINDS.size, 2); +}); + +test("public receipt allowlists are exact and deny unknown values", () => { + for (const phase of PUBLIC_RECTIFICATION_PHASES) { + assert.equal(safeActivityEvent(phase), phase); + } + assert.equal(safeActivityEvent("provider.reasoning"), null); + assert.equal(safeActivityEvent("tool.payload"), null); + assert.equal(PUBLIC_ACTIVITY_EVENTS.length, PUBLIC_RECTIFICATION_PHASES.length); + assert.ok(PUBLIC_RECTIFICATION_TOOLS.includes("rectification-read-case")); + assert.ok(!(PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes("rectification-scan")); +}); + +test("open request schemas reject userId, birth data and range from the browser", () => { + const homepage = openRectificationCaseRequestSchema.safeParse({ + intent: "homepage", + requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + }); + assert.equal(homepage.success, true); + const withUserId = openRectificationCaseRequestSchema.safeParse({ + intent: "homepage", + requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + userId: "ffffffff-0000-4aaa-9bbb-cccccccccccc", + }); + assert.equal(withUserId.success, false); + const withBirthData = openRectificationCaseRequestSchema.safeParse({ + intent: "homepage", + requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + birth_date: "1997-08-08", + }); + assert.equal(withBirthData.success, false); + const withRange = openRectificationCaseRequestSchema.safeParse({ + intent: "homepage", + requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + candidate_range: { start_time: "04:00", end_time: "06:00" }, + }); + assert.equal(withRange.success, false); + const supersedeTrue = openRectificationCaseRequestSchema.safeParse({ + intent: "new", + requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", + supersedeActive: true, + }); + assert.equal(supersedeTrue.success, false); +}); + +test("shouldStartOpening is server-owned: only freshly created never-started cases", () => { + assert.equal(shouldStartOpening("created", 0), true); + assert.equal(shouldStartOpening("created", 1), false); + assert.equal(shouldStartOpening("resumed", 0), false); + assert.equal(shouldStartOpening("resumed", 5), false); + assert.equal(shouldStartOpening("readonly", 0), false); +}); diff --git a/frontend/tests/rectification-v9-database.test.ts b/frontend/tests/rectification-v9-database.test.ts new file mode 100644 index 00000000..24183d5b --- /dev/null +++ b/frontend/tests/rectification-v9-database.test.ts @@ -0,0 +1,761 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); + +function dockerAvailable(): boolean { + const probe = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { + encoding: "utf8", + stdio: "ignore", + }); + return probe.status === 0; +} + +const skipWithoutDocker = dockerAvailable() ? false : "docker unavailable on this host"; + +const snapshot = { + birth_date: "1997-08-08", + latitude: 36.420487, + longitude: 114.209936, + timezone_offset: 8, + birth_time_source: "family_exact", + reported_birth_time: "05:00", + active_birth_time: null, + birth_time_period: null, + uncertainty_before_minutes: 10, + uncertainty_after_minutes: 10, +}; +const fingerprint = "a".repeat(64); +const range = { start_time: "04:50", end_time: "05:10" }; +const skillName = "jyotish-birth-time-rectification"; +const skillVersion = "9.0.0"; + +function rpcError(error: unknown): string { + if (!error || typeof error !== "object") return ""; + const value = error as { message?: unknown }; + return typeof value.message === "string" ? value.message : ""; +} + +test("v9 migration applies on a fresh database and re-applies idempotently", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + const migrate = () => + spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + try { + const firstRun = migrate(); + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.match(firstRun.stdout, /applied 20260812010000_agentic_rectification_v9_runtime\.sql/); + const secondRun = migrate(); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.match( + secondRun.stdout, + /already applied 20260812010000_agentic_rectification_v9_runtime\.sql/, + ); + } finally { + fixture.stop(); + } +}); + +test("v9 open is atomic, idempotent and resumes instead of duplicating", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email, email_verified, email_verified_at) + values ('V9 User', 'v9-user@example.com', true, now()) + `, + ); + const userId = fixture.psql( + "select id from identity.users where email = 'v9-user@example.com'", + ); + fixture.psql(` + update public.profiles + set birth_date = '1997-08-08', + reported_birth_time = '05:00', + birth_time_source = 'family_exact', + uncertainty_before_minutes = 10, + uncertainty_after_minutes = 10, + latitude = 36.420487, + longitude = 114.209936, + timezone_offset = 8, + birth_time_status = 'reported' + where id = '${userId}'; + `); + + const service = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + + const first = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: "11111111-1111-4111-8111-111111111111", + p_intent: "homepage", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + p_baseline_birth_snapshot: snapshot, + p_candidate_range: range, + }); + assert.equal(first.error, null, rpcError(first.error)); + const created = first.data as Record; + assert.equal(created.disposition, "created"); + assert.equal(created.should_start_opening, true); + assert.equal(created.status, "draft"); + const caseId = String(created.case_id); + const sessionId = String(created.session_id); + + assert.equal( + fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${userId}'`), + "1", + ); + assert.equal( + fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`), + "1", + ); + assert.equal( + fixture.psql( + `select count(*) from public.agentic_rectification_open_ledger where user_id = '${userId}'`, + ), + "1", + ); + assert.equal( + fixture.psql( + `select agentic_rectification_case_id from public.chat_sessions where id = '${sessionId}'`, + ), + caseId, + ); + + // Replaying the same requestId must not create anything new. + const replay = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: "11111111-1111-4111-8111-111111111111", + p_intent: "homepage", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + p_baseline_birth_snapshot: snapshot, + p_candidate_range: range, + }); + assert.equal(replay.error, null, rpcError(replay.error)); + assert.equal((replay.data as Record).case_id, caseId); + assert.equal((replay.data as Record).session_id, sessionId); + assert.equal( + fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`), + "1", + ); + + // A second request with a fresh requestId resumes the same case. + const resume = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: "22222222-2222-4222-8222-222222222222", + p_intent: "homepage", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + 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( + fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`), + "1", + ); + + // intent new while an active case exists must fail safely. + const conflict = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: "33333333-3333-4333-8333-333333333333", + p_intent: "new", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + p_baseline_birth_snapshot: snapshot, + p_candidate_range: range, + }); + assert.match(rpcError(conflict.error), /agentic_rectification_active_case_conflict/); + assert.equal( + fixture.psql(`select count(*) from public.chat_sessions where user_id = '${userId}'`), + "1", + ); + + // Session intent opens the exact session. + const sessionOpen = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: "44444444-4444-4444-8444-444444444444", + p_intent: "session", + p_session_id: sessionId, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: "session-view", + p_baseline_birth_snapshot: {}, + p_candidate_range: { start_time: "00:00", end_time: "23:59" }, + }); + assert.equal(sessionOpen.error, null, rpcError(sessionOpen.error)); + assert.equal((sessionOpen.data as Record).case_id, caseId); + assert.equal((sessionOpen.data as Record).session_id, sessionId); + } finally { + fixture.stop(); + } +}); + +test("v9 enforces profile gating, ownership and terminal read-only", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email, email_verified, email_verified_at) values + ('Incomplete', 'incomplete@example.com', true, now()), + ('Owner', 'owner@example.com', true, now()); + `, + ); + const incompleteId = fixture.psql( + "select id from identity.users where email = 'incomplete@example.com'", + ); + const ownerId = fixture.psql( + "select id from identity.users where email = 'owner@example.com'", + ); + fixture.psql(` + update public.profiles set birth_date = '1997-08-08' where id = '${incompleteId}'; + update public.profiles + set birth_date = '1997-08-08', + reported_birth_time = '05:00', + birth_time_source = 'family_exact', + latitude = 36.420487, longitude = 114.209936, timezone_offset = 8, + birth_time_status = 'reported' + where id = '${ownerId}'; + `); + + const service = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + + // Incomplete profile: no case is created. + const incomplete = await service.rpc("open_agentic_rectification_case", { + p_user_id: incompleteId, + p_request_id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + p_intent: "homepage", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + p_baseline_birth_snapshot: { birth_date: "1997-08-08" }, + p_candidate_range: range, + }); + assert.match(rpcError(incomplete.error), /agentic_rectification_profile_incomplete/); + assert.equal( + fixture.psql(`select count(*) from public.agentic_rectification_cases where user_id = '${incompleteId}'`), + "0", + ); + + // Owner opens a case, closes it, then reopens it read-only. + const opened = await service.rpc("open_agentic_rectification_case", { + p_user_id: ownerId, + p_request_id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + p_intent: "homepage", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + p_baseline_birth_snapshot: snapshot, + p_candidate_range: range, + }); + assert.equal(opened.error, null, rpcError(opened.error)); + const ownerCaseId = String((opened.data as Record).case_id); + const ownerSessionId = String((opened.data as Record).session_id); + + const closed = await service.rpc("close_agentic_rectification_case", { + p_user_id: ownerId, + p_case_id: ownerCaseId, + p_reason: "completed_by_user", + }); + assert.equal(closed.error, null, rpcError(closed.error)); + assert.equal((closed.data as Record).status, "closed"); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where id = '${ownerCaseId}'`), + "closed", + ); + assert.equal( + fixture.psql(`select completed_at is not null from public.agentic_rectification_cases where id = '${ownerCaseId}'`), + "t", + ); + + const readonly = await service.rpc("open_agentic_rectification_case", { + p_user_id: ownerId, + p_request_id: "cccccccc-cccc-4ccc-8ccc-cccccccccccc", + p_intent: "session", + p_session_id: ownerSessionId, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: "session-view", + p_baseline_birth_snapshot: {}, + p_candidate_range: { start_time: "00:00", end_time: "23:59" }, + }); + assert.equal(readonly.error, null, rpcError(readonly.error)); + assert.equal((readonly.data as Record).disposition, "readonly"); + assert.equal((readonly.data as Record).should_start_opening, false); + + // A different user cannot open the owner's session. + const foreign = await service.rpc("open_agentic_rectification_case", { + p_user_id: incompleteId, + p_request_id: "dddddddd-dddd-4ddd-8ddd-dddddddddddd", + p_intent: "session", + p_session_id: ownerSessionId, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: "session-view", + p_baseline_birth_snapshot: {}, + p_candidate_range: { start_time: "00:00", end_time: "23:59" }, + }); + assert.match(rpcError(foreign.error), /agentic_rectification_session_not_found/); + + // Terminal cases reject evidence proposals and turn appends. + const turn = await service.rpc("append_agentic_rectification_turn", { + p_user_id: ownerId, + p_case_id: ownerCaseId, + p_user_message: "我2016年9月离开家去北京工作", + p_assistant_message: "已记录这段经历。", + p_model_name: "test-model", + p_model_version: "1", + p_status: "completed", + }); + assert.match(rpcError(turn.error), /agentic_rectification_case_terminal/); + + const proposal = await service.rpc("propose_agentic_rectification_evidence", { + p_user_id: ownerId, + p_case_id: ownerCaseId, + p_source_turn_id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", + p_user_quote: "离开家去北京", + p_subject: "self", + p_event_kind: "relocation", + p_domain: "relocation", + p_occurred_from: "2016-09-01", + p_occurred_to: null, + p_date_precision: "month", + p_summary: "2016年9月离开家去北京", + }); + assert.match(rpcError(proposal.error), /agentic_rectification_case_terminal/); + } finally { + fixture.stop(); + } +}); + +test("v9 evidence lifecycle: quote grounding, idempotency, confirm and revision lineage", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email, email_verified, email_verified_at) + values ('Evidence', 'evidence@example.com', true, now()) + `, + ); + const userId = fixture.psql( + "select id from identity.users where email = 'evidence@example.com'", + ); + fixture.psql(` + update public.profiles + set birth_date = '1997-08-08', + reported_birth_time = '05:00', + birth_time_source = 'family_exact', + latitude = 36.420487, longitude = 114.209936, timezone_offset = 8, + birth_time_status = 'reported' + where id = '${userId}'; + `); + + const service = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + + const opened = await service.rpc("open_agentic_rectification_case", { + p_user_id: userId, + p_request_id: "ffffffff-ffff-4fff-8fff-ffffffffffff", + p_intent: "homepage", + p_session_id: null, + p_skill_name: skillName, + p_skill_version: skillVersion, + p_baseline_profile_fingerprint: fingerprint, + p_baseline_birth_snapshot: snapshot, + p_candidate_range: range, + }); + assert.equal(opened.error, null, rpcError(opened.error)); + const caseId = String((opened.data as Record).case_id); + + const turn = await service.rpc("append_agentic_rectification_turn", { + p_user_id: userId, + p_case_id: caseId, + p_user_message: "2016年9月我离开家去北京开始工作", + p_assistant_message: "好的,已记录。", + p_model_name: "test-model", + p_model_version: "1", + p_status: "completed", + }); + assert.equal(turn.error, null, rpcError(turn.error)); + const turnId = String((turn.data as Record).turn_id); + + const proposed = await service.rpc("propose_agentic_rectification_evidence", { + p_user_id: userId, + p_case_id: caseId, + p_source_turn_id: turnId, + p_user_quote: "离开家去北京", + p_subject: "self", + p_event_kind: "relocation", + p_domain: "relocation", + p_occurred_from: "2016-09-01", + p_occurred_to: null, + p_date_precision: "month", + p_summary: "2016年9月离开家去北京", + }); + assert.equal(proposed.error, null, rpcError(proposed.error)); + const evidenceId = String((proposed.data as Record).evidence_id); + assert.equal((proposed.data as Record).idempotent, false); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_evidence where id = '${evidenceId}'`), + "draft", + ); + + // Replay must not create a second evidence row. + const replay = await service.rpc("propose_agentic_rectification_evidence", { + p_user_id: userId, + p_case_id: caseId, + p_source_turn_id: turnId, + p_user_quote: "离开家去北京", + p_subject: "self", + p_event_kind: "relocation", + p_domain: "relocation", + p_occurred_from: "2016-09-01", + p_occurred_to: null, + p_date_precision: "month", + p_summary: "2016年9月离开家去北京", + }); + assert.equal(replay.error, null, rpcError(replay.error)); + assert.equal((replay.data as Record).evidence_id, evidenceId); + assert.equal((replay.data as Record).idempotent, true); + assert.equal( + fixture.psql(`select count(*) from public.agentic_rectification_evidence where case_id = '${caseId}'`), + "1", + ); + + // Quotes not present in the source turn are rejected. + const ungrounded = await service.rpc("propose_agentic_rectification_evidence", { + p_user_id: userId, + p_case_id: caseId, + p_source_turn_id: turnId, + p_user_quote: "去了上海", + p_subject: "self", + p_event_kind: "relocation", + p_domain: "relocation", + p_occurred_from: "2020-01-01", + p_occurred_to: null, + p_date_precision: "year", + p_summary: "2020年去了上海", + }); + assert.match(rpcError(ungrounded.error), /agentic_rectification_quote_not_grounded/); + + // Confirm transitions draft -> confirmed with a timestamp. + const confirmed = await service.rpc("confirm_agentic_rectification_evidence", { + p_user_id: userId, + p_case_id: caseId, + p_evidence_id: evidenceId, + }); + assert.equal(confirmed.error, null, rpcError(confirmed.error)); + assert.equal((confirmed.data as Record).status, "confirmed"); + assert.equal( + fixture.psql(`select confirmed_at is not null from public.agentic_rectification_evidence where id = '${evidenceId}'`), + "t", + ); + + // Revision creates a superseding row and never overwrites history. + const revised = await service.rpc("revise_agentic_rectification_evidence", { + p_user_id: userId, + p_case_id: caseId, + p_evidence_id: evidenceId, + p_user_quote: "离开家去北京", + p_occurred_from: "2016-10-01", + p_occurred_to: null, + p_date_precision: "month", + p_summary: "2016年10月离开家去北京", + }); + assert.equal(revised.error, null, rpcError(revised.error)); + const revisionId = String((revised.data as Record).evidence_id); + assert.equal((revised.data as Record).supersedes_evidence_id, evidenceId); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_evidence where id = '${evidenceId}'`), + "superseded", + ); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_evidence where id = '${revisionId}'`), + "pending_confirmation", + ); + assert.equal( + fixture.psql( + `select count(*) from public.agentic_rectification_evidence where case_id = '${caseId}'`, + ), + "2", + ); + } finally { + fixture.stop(); + } +}); + +test("v9 legacy backfill maps statuses, keeps one resumable per user and is idempotent", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email, email_verified, email_verified_at) values + ('Legacy', 'legacy@example.com', true, now()), + ('LegacyConfirmed', 'legacy-confirmed@example.com', true, now()), + ('LegacyCollect', 'legacy-collect@example.com', true, now()), + ('LegacyDraft', 'legacy-draft@example.com', true, now()), + ('LegacyDup', 'legacy-dup@example.com', true, now()); + `, + ); + const legacyId = fixture.psql("select id from identity.users where email = 'legacy@example.com'"); + const confirmedId = fixture.psql("select id from identity.users where email = 'legacy-confirmed@example.com'"); + const collectId = fixture.psql("select id from identity.users where email = 'legacy-collect@example.com'"); + const draftId = fixture.psql("select id from identity.users where email = 'legacy-draft@example.com'"); + const dupId = fixture.psql("select id from identity.users where email = 'legacy-dup@example.com'"); + fixture.psql(` + update public.profiles + set birth_date = '1997-08-08', reported_birth_time = '05:00', + birth_time_source = 'family_exact', + latitude = 36.420487, longitude = 114.209936, timezone_offset = 8, + birth_time_status = 'reported' + where id in ('${legacyId}', '${confirmedId}', '${collectId}', '${draftId}', '${dupId}'); + + -- Legacy session with activity + a user-accepted result (newest -> active). + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at) + values ( + 'aaaa1111-1111-4111-8111-111111111111', '${legacyId}', '旧校正A', 'general', + 'birth_time_rectification', + '[{"role":"user","text":"2016年9月我离开家去北京工作"},{"role":"assistant","text":"已记录"}]', + now() - interval '1 day' + ); + -- Legacy session with activity but no selection (older -> superseded). + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at) + values ( + 'bbbb1111-1111-4111-8111-111111111111', '${legacyId}', '旧校正B', 'general', + 'birth_time_rectification', + '[{"role":"user","text":"2020年我开始担任管理职责"}]', + now() - interval '3 days' + ); + -- Repeated empty legacy session (old -> abandoned). + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at) + values ( + 'cccc1111-1111-4111-8111-111111111111', '${legacyId}', '空校正', 'general', + 'birth_time_rectification', '[]', now() - interval '10 days' + ); + -- Engine-confirmed legacy session for the second user. + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at) + values ( + 'dddd1111-1111-4111-8111-111111111111', '${confirmedId}', '已确认校正', 'general', + 'birth_time_rectification', + '[{"role":"user","text":"2015年我进入大学"}]', + now() - interval '2 days' + ); + -- Messages but no results -> collecting_evidence (active). + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at) + values ( + 'eeee1111-1111-4111-8111-111111111111', '${collectId}', '收集校正', 'general', + 'birth_time_rectification', + '[{"role":"user","text":"2018年我换了城市"}]', + now() - interval '1 day' + ); + -- Single empty session -> draft. + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at) + values ( + 'ffff1111-1111-4111-8111-111111111111', '${draftId}', '新空校正', 'general', + 'birth_time_rectification', '[]', now() - interval '1 day' + ); + -- Duplicate empty sessions: newest -> draft, older -> abandoned. + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, updated_at) + values + ('10111111-1111-4111-8111-111111111111', '${dupId}', '重复空1', 'general', + 'birth_time_rectification', '[]', now() - interval '1 day'), + ('20222222-2222-4222-8222-222222222222', '${dupId}', '重复空2', 'general', + 'birth_time_rectification', '[]', now() - interval '2 days'); + + insert into public.agentic_rectification_results ( + user_id, session_id, engine_result_id, canonical_input_hash, algorithm_version, + candidate_range, candidates, overall_confidence, selection_allowed, confirmation_allowed, + representative_time, selected_time, selection_kind, selected_at, + baseline_birth_date, baseline_reported_birth_time, baseline_birth_time_source, + baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes, + baseline_latitude, baseline_longitude, baseline_timezone_offset + ) values ( + '${legacyId}', 'aaaa1111-1111-4111-8111-111111111111', + 'legacy-engine-1', 'legacy-hash-1', 'legacy-v1', + '{"start_time":"04:00","end_time":"06:00"}', + '[{"rank":1,"time":"05:00","relative_support":70,"tied_minute_count":1}]', + 'medium', true, false, '05:00', '05:00', 'user_accepted', now() - interval '1 day', + '1997-08-08', '05:00', 'family_exact', 10, 10, 36.420487, 114.209936, 8 + ); + insert into public.agentic_rectification_results ( + user_id, session_id, engine_result_id, canonical_input_hash, algorithm_version, + candidate_range, candidates, overall_confidence, selection_allowed, confirmation_allowed, + representative_time, selected_time, selection_kind, selected_at, + baseline_birth_date, baseline_reported_birth_time, baseline_birth_time_source, + baseline_uncertainty_before_minutes, baseline_uncertainty_after_minutes, + baseline_latitude, baseline_longitude, baseline_timezone_offset + ) values ( + '${confirmedId}', 'dddd1111-1111-4111-8111-111111111111', + 'legacy-engine-2', 'legacy-hash-2', 'legacy-v1', + '{"start_time":"04:00","end_time":"06:00"}', + '[{"rank":1,"time":"05:00","relative_support":90,"tied_minute_count":1}]', + 'high', true, true, '05:00', '05:00', 'engine_confirmed', now() - interval '2 days', + '1997-08-08', '05:00', 'family_exact', 10, 10, 36.420487, 114.209936, 8 + ); + `); + + const service = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + + // Preflight count: 8 legacy sessions without a V9 case. + assert.equal( + fixture.psql(` + select count(*) from public.chat_sessions + where session_type = 'birth_time_rectification' + and not exists ( + select 1 from public.agentic_rectification_cases c where c.session_id = public.chat_sessions.id + ) + `), + "8", + ); + + const backfill = await service.rpc("backfill_agentic_rectification_legacy_cases", {}); + assert.equal(backfill.error, null, rpcError(backfill.error)); + const stats = backfill.data as Record; + assert.equal(stats.cases_created, 8); + assert.equal(stats.results_mapped, 2); + assert.equal(stats.confirmed, 1); + assert.equal(stats.candidate_accepted, 1); + assert.equal(stats.collecting_evidence, 1); + assert.equal(stats.draft, 2); + assert.equal(stats.abandoned, 2); + assert.equal(stats.superseded, 1); + + // Statuses landed correctly. + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'dddd1111-1111-4111-8111-111111111111'`), + "confirmed", + ); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'ffff1111-1111-4111-8111-111111111111'`), + "draft", + ); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'eeee1111-1111-4111-8111-111111111111'`), + "collecting_evidence", + ); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'aaaa1111-1111-4111-8111-111111111111'`), + "candidate_accepted", + ); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where session_id = 'bbbb1111-1111-4111-8111-111111111111'`), + "superseded", + ); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where session_id = '10111111-1111-4111-8111-111111111111'`), + "draft", + ); + assert.equal( + fixture.psql(`select status from public.agentic_rectification_cases where session_id = '20222222-2222-4222-8222-222222222222'`), + "abandoned", + ); + + // Exactly one resumable case per user. + assert.equal( + fixture.psql(` + select count(*) from ( + select user_id from public.agentic_rectification_cases + where status in ('draft','collecting_evidence','candidate_ready','candidate_accepted','needs_rebaseline','paused') + group by user_id having count(*) > 1 + ) conflicts + `), + "0", + ); + + // Results are mapped to their cases. + assert.equal( + fixture.psql(`select count(*) from public.agentic_rectification_results where case_id is null`), + "0", + ); + + // Verification report is clean. + const verify = await service.rpc("verify_agentic_rectification_backfill", {}); + assert.equal(verify.error, null, rpcError(verify.error)); + const report = verify.data as Record; + assert.equal(report.orphan_cases, 0); + assert.equal(report.resumable_conflicts, 0); + assert.equal(report.legacy_sessions_without_case, 0); + + // Re-running the backfill creates nothing new (idempotent). + const again = await service.rpc("backfill_agentic_rectification_legacy_cases", {}); + assert.equal(again.error, null, rpcError(again.error)); + assert.equal((again.data as Record).cases_created, 0); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts new file mode 100644 index 00000000..e0beb583 --- /dev/null +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -0,0 +1,216 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const migration = readFileSync( + new URL( + "../supabase/migrations/20260812010000_agentic_rectification_v9_runtime.sql", + import.meta.url, + ), + "utf8", +); +const dbMigrationsCopy = fileURLToPath( + new URL("../db/migrations/20260812010000_agentic_rectification_v9_runtime.sql", import.meta.url), +); + +test("v9 runtime migration sorts after the newest business migration and stays unique", () => { + assert.ok( + "20260812010000_agentic_rectification_v9_runtime.sql" > + "20260811010000_consultation_status_service_role_read.sql", + ); + assert.match(migration, /^begin;[\s\S]*^commit;$/m); +}); + +test("v9 runtime migration must never be duplicated into the identity foundation", () => { + assert.equal( + existsSync(dbMigrationsCopy), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); +}); + +test("v9 runtime migration creates the five durable tables with required columns", () => { + assert.match(migration, /create table if not exists public\.agentic_rectification_cases \(/); + assert.match(migration, /user_id uuid not null references auth\.users\(id\) on delete cascade/); + assert.match(migration, /session_id uuid not null unique references public\.chat_sessions\(id\) on delete cascade/); + assert.match(migration, /baseline_profile_fingerprint text not null/); + assert.match(migration, /baseline_birth_snapshot jsonb not null/); + assert.match(migration, /skill_version text not null/); + assert.match(migration, /accepted_time time without time zone/); + assert.match(migration, /confirmed_time time without time zone/); + assert.match(migration, /completed_at timestamptz/); + assert.match(migration, /closed_reason text/); + + assert.match(migration, /create table if not exists public\.agentic_rectification_evidence \(/); + assert.match(migration, /source_turn_id uuid not null references public\.agentic_rectification_turns\(id\)/); + assert.match(migration, /user_quote text not null/); + assert.match(migration, /event_kind text not null/); + assert.match(migration, /date_precision text not null check \(date_precision in \('year', 'month', 'day', 'range', 'unknown'\)\)/); + assert.match(migration, /supersedes_evidence_id uuid references public\.agentic_rectification_evidence\(id\)/); + + assert.match(migration, /create table if not exists public\.agentic_rectification_turns \(/); + assert.match(migration, /status text not null check \(status in \('pending', 'completed', 'failed', 'retryable'\)\)/); + assert.match(migration, /model_name text not null/); + const turnsTable = migration.slice( + migration.indexOf("create table if not exists public.agentic_rectification_turns"), + migration.indexOf("-- 3. Evidence"), + ); + assert.doesNotMatch(turnsTable, /reasoning/); + + assert.match(migration, /create table if not exists public\.agentic_rectification_tool_receipts \(/); + assert.match(migration, /input_fingerprint text/); + assert.match(migration, /result_fingerprint text/); + assert.match(migration, /safe_error_code text/); + assert.doesNotMatch(migration, /create table if not exists public\.agentic_rectification_tool_receipts \([\s\S]*payload/); + + assert.match(migration, /create table if not exists public\.agentic_rectification_open_ledger \(/); + assert.match(migration, /primary key \(user_id, request_id\)/); +}); + +test("v9 runtime migration enforces one resumable case per user at the database level", () => { + assert.match( + migration, + /create unique index if not exists agentic_rectification_cases_one_resumable_per_user[\s\S]*where status in \([\s\S]*'draft'[\s\S]*'collecting_evidence'[\s\S]*'candidate_ready'[\s\S]*'candidate_accepted'[\s\S]*'needs_rebaseline'[\s\S]*'paused'[\s\S]*\)/, + ); +}); + +test("v9 runtime migration keeps Case/Session bidirectional consistency", () => { + assert.match(migration, /alter table public\.chat_sessions\s+add column if not exists agentic_rectification_case_id uuid/); + assert.match(migration, /create unique index if not exists chat_sessions_agentic_rectification_case_unique/); + assert.match(migration, /agentic_rectification_cases_sync_session/); + assert.match(migration, /agentic_rectification_case_session_mismatch/); + assert.match(migration, /agentic_rectification_case_owner_mismatch/); +}); + +test("v9 runtime migration extends results with case_id and fingerprints", () => { + assert.match(migration, /add column if not exists case_id uuid/); + assert.match(migration, /add column if not exists evidence_ledger_fingerprint text/); + assert.match(migration, /add column if not exists candidate_range_fingerprint text/); + assert.match(migration, /add column if not exists skill_version text/); +}); + +test("v9 runtime migration grants only service_role on the new tables", () => { + for (const table of [ + "agentic_rectification_cases", + "agentic_rectification_turns", + "agentic_rectification_evidence", + "agentic_rectification_tool_receipts", + "agentic_rectification_open_ledger", + ]) { + assert.match(migration, new RegExp(`revoke all on table public\\.${table} from public, anon, authenticated, service_role`)); + assert.match(migration, new RegExp(`grant all on table public\\.${table} to service_role`)); + assert.doesNotMatch(migration, new RegExp(`grant select on table public\\.${table} to authenticated`)); + } +}); + +test("v9 RPCs are security definer and service-role only", () => { + for (const functionName of [ + "open_agentic_rectification_case", + "get_agentic_rectification_entry_summary", + "get_agentic_rectification_case", + "close_agentic_rectification_case", + "upgrade_agentic_rectification_skill", + "append_agentic_rectification_turn", + "insert_agentic_rectification_tool_receipt", + "propose_agentic_rectification_evidence", + "confirm_agentic_rectification_evidence", + "revise_agentic_rectification_evidence", + ]) { + assert.match(migration, new RegExp(`create or replace function public\\.${functionName}\\(`)); + assert.match(migration, new RegExp(`grant execute on function public\\.${functionName}\\([\\s\\S]*?to service_role`)); + } + assert.doesNotMatch( + migration, + /grant execute on function public\.open_agentic_rectification_case\([\s\S]*?to authenticated/, + ); +}); + +test("open RPC serializes same-user requests and forbids silent supersede", () => { + assert.match(migration, /pg_catalog\.pg_advisory_xact_lock\(/); + assert.match(migration, /hashtext\('agentic_rectification_open:' \|\| p_user_id::text\)/); + assert.match(migration, /agentic_rectification_active_case_conflict/); + assert.doesNotMatch(migration, /supersede_active/); + assert.doesNotMatch(migration, /p_supersede/); +}); + +test("open RPC creates the case and session atomically and derives shouldStartOpening", () => { + const open = migration.slice( + migration.indexOf("create or replace function public.open_agentic_rectification_case"), + migration.indexOf("-- 12. Entry summary"), + ); + assert.match(open, /insert into public\.chat_sessions/); + assert.match(open, /insert into public\.agentic_rectification_cases/); + assert.match(open, /insert into public\.agentic_rectification_open_ledger/); + assert.match(open, /exception when unique_violation/); + assert.match(open, /'should_start_opening', true/); + // Snapshot/range validation lives inside the create block, before the + // inserts -- a session/view-only open never needs a complete profile. + const createBlock = open.slice( + open.indexOf("Create a new case + a new session atomically"), + open.lastIndexOf("return jsonb_build_object("), + ); + assert.match(createBlock, /agentic_rectification_profile_incomplete/); + assert.match(createBlock, /agentic_rectification_invalid_range/); +}); + +test("open RPC never creates a case for an incomplete profile", () => { + assert.match(migration, /agentic_rectification_profile_incomplete/); + assert.match(migration, /p_baseline_birth_snapshot ->> 'birth_date' is null/); +}); + +test("terminal cases reject evidence and turn writes", () => { + const propose = migration.slice( + migration.indexOf("create or replace function public.propose_agentic_rectification_evidence"), + migration.indexOf("create or replace function public.confirm_agentic_rectification_evidence"), + ); + assert.match(propose, /agentic_rectification_case_terminal/); + const append = migration.slice( + migration.indexOf("create or replace function public.append_agentic_rectification_turn"), + migration.indexOf("-- 17. Tool receipt"), + ); + assert.match(append, /agentic_rectification_case_terminal/); +}); + +test("evidence proposals require quote grounding in the source turn", () => { + assert.match(migration, /agentic_rectification_quote_not_grounded/); + assert.match(migration, /agentic_rectification_normalize_quote\(p_user_quote\)/); + assert.match(migration, /agentic_rectification_normalize_quote\(v_turn\.user_message\)/); +}); + +test("evidence revisions are append-only and never generate confirmed evidence from text", () => { + assert.match(migration, /set status = 'superseded'/); + assert.match(migration, /supersedes_evidence_id[\s\S]*'pending_confirmation', v_target\.id/); + const backfill = migration.slice( + migration.indexOf("create or replace function public.backfill_agentic_rectification_legacy_cases"), + migration.indexOf("-- 20. Backfill verification"), + ); + assert.doesNotMatch(backfill, /insert into public\.agentic_rectification_evidence/); +}); + +test("legacy backfill maps every documented status and keeps one resumable per user", () => { + const backfill = migration.slice( + migration.indexOf("create or replace function public.backfill_agentic_rectification_legacy_cases"), + migration.indexOf("-- 20. Backfill verification"), + ); + assert.match(backfill, /when result_kind = 'engine_confirmed' then 'confirmed'/); + assert.match(backfill, /when result_kind = 'user_accepted' then 'candidate_accepted'/); + assert.match(backfill, /when has_messages then case when has_results then 'candidate_ready' else 'collecting_evidence' end/); + assert.match(backfill, /then 'superseded'/); + assert.match(backfill, /then 'draft'/); + assert.match(backfill, /agentic_rectification_legacy_fingerprint/); + assert.match(backfill, /update public\.agentic_rectification_results\s+set case_id = v_case_id/); + assert.match(backfill, /not exists \(\s*select 1 from public\.agentic_rectification_cases c where c\.session_id = s\.id\s*\)/); +}); + +test("backfill apply runs inside the migration and verification is re-runnable", () => { + assert.match(migration, /select public\.backfill_agentic_rectification_legacy_cases\(\) into v_result/); + assert.match(migration, /create or replace function public\.verify_agentic_rectification_backfill\(\)/); + assert.match(migration, /resumable_conflicts/); + assert.match(migration, /orphan_cases/); +}); + +test("v9 fingerprint uses the already-installed pgcrypto digest", () => { + assert.match(migration, /public\.digest\(/); + assert.match(migration, /'sha256'/); +});