feat(rectification): add durable case and evidence runtime

This commit is contained in:
Jesse
2026-08-11 16:18:53 +08:00
parent 60e2ce4fa4
commit d394dd0585
13 changed files with 3794 additions and 0 deletions
+27
View File
@@ -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 且并发会重复创建
- 状态:resolvedlocal 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 领域 contractsCase 状态机(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 表并断言新迁移 appliedBUG-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 一次性回填的数据库级验证,因此这些缺陷在回归中被遗漏。
@@ -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 },
);
}
}
@@ -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 },
);
}
}
@@ -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 },
);
}
}
@@ -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 },
);
}
}
@@ -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);
}
}
@@ -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<Record<string, { start_time: string; end_time: string }>> = {
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<V9RectificationProfile> {
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<string, unknown>;
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<string, { status: number; code: string; message: string }>([
["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<OpenRectificationCaseResponse> {
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<RectificationEntrySummary> {
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<string, unknown>;
const latestResumable =
value.latest_resumable && typeof value.latest_resumable === "object"
? (value.latest_resumable as Record<string, unknown>)
: null;
const latestTerminal =
value.latest_terminal && typeof value.latest_terminal === "object"
? (value.latest_terminal as Record<string, unknown>)
: 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<string, unknown>): 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<RectificationCaseView> {
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<string, unknown>);
}
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<RectificationCloseResult> {
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<string, unknown>;
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<RectificationUpgradeResult> {
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<string, unknown>;
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,
};
}
File diff suppressed because it is too large Load Diff
@@ -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",
@@ -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<string, unknown>) => Promise<{
data: unknown;
error: { message: string } | null;
}>;
function fakeAccounting(overrides: {
profile?: Record<string, unknown> | 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<string, unknown> | 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");
});
@@ -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);
});
@@ -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<string, unknown>;
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<string, unknown>).case_id, caseId);
assert.equal((replay.data as Record<string, unknown>).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<string, unknown>).disposition, "resumed");
assert.equal((resume.data as Record<string, unknown>).should_start_opening, false);
assert.equal((resume.data as Record<string, unknown>).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<string, unknown>).case_id, caseId);
assert.equal((sessionOpen.data as Record<string, unknown>).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<string, unknown>).case_id);
const ownerSessionId = String((opened.data as Record<string, unknown>).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<string, unknown>).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<string, unknown>).disposition, "readonly");
assert.equal((readonly.data as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).evidence_id);
assert.equal((proposed.data as Record<string, unknown>).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<string, unknown>).evidence_id, evidenceId);
assert.equal((replay.data as Record<string, unknown>).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<string, unknown>).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<string, unknown>).evidence_id);
assert.equal((revised.data as Record<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>).cases_created, 0);
} finally {
fixture.stop();
}
});
@@ -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'/);
});