/** * 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 { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts"; import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts"; import { resolveActiveSkillPackage, resolveSkillPackageVersion, } from "../../skill-package-registry.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; birth_place_label: string; latitude: number; longitude: number; timezone_id: string; 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 RectificationSkillIdentityStatus = "verified" | "legacy_unverifiable"; export type RectificationSkillIdentityStatusView = Readonly<{ skillName: string; skillVersion: string; skillSha256: string | null; sourceCommit: string | null; status: RectificationSkillIdentityStatus; requiresSkillAdoption: boolean; }>; export type RectificationCaseView = Readonly<{ caseId: string; sessionId: string; status: string; skillName: string; skillVersion: string; skillIdentityStatus: RectificationSkillIdentityStatus; requiresSkillAdoption: boolean; 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; } 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")}`; } // This is an engine execution boundary, not a user-declared uncertainty. // Exact-time Cases receive a movable search radius; imprecise declarations keep // the honest server-owned range instead of inventing a baseline minute. const FRESH_CASE_SEARCH_RADIUS_MINUTES = 15; const PERIOD_CANDIDATE_RANGES = { 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" }, } as const; function deriveCandidateRange(input: { reportedTime: string | null; source: string; period: string | null; }): { start_time: string; end_time: string } { if (input.reportedTime) { return { start_time: shiftedTime(input.reportedTime, -FRESH_CASE_SEARCH_RADIUS_MINUTES), end_time: shiftedTime(input.reportedTime, FRESH_CASE_SEARCH_RADIUS_MINUTES), }; } if (input.source === "period_only" || input.source === "legacy_import") { const periodRange = input.period && Object.hasOwn(PERIOD_CANDIDATE_RANGES, input.period) ? PERIOD_CANDIDATE_RANGES[input.period as keyof typeof PERIOD_CANDIDATE_RANGES] : null; if (periodRange) return periodRange; 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, baseline.birth_place_label, String(baseline.latitude), String(baseline.longitude), baseline.timezone_id, 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,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset", ) .eq("id", userId) .single(); if (error || !data) throw new RectificationCaseServiceError("profile_unavailable"); let resolvedData: unknown; try { resolvedData = await resolveMissingBirthTimezoneOffset(data); } catch { throw new RectificationCaseServiceError("profile_unavailable"); } const row = resolvedData as Record; const birthDate = normalizePersistedBirthDate(row.birth_date); const latitude = numberOrNull(row.latitude); const longitude = numberOrNull(row.longitude); const birthPlaceLabel = typeof row.birth_place_label === "string" ? row.birth_place_label.trim() : ""; const timezoneId = typeof row.timezone_id === "string" ? row.timezone_id.trim() : ""; 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 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 || !birthPlaceLabel || latitude === null || longitude === null || !timezoneId || timezoneOffset === null || !source) { throw new RectificationCaseServiceError("profile_incomplete"); } const baseline: V9BaselineSnapshot = { birth_date: birthDate, birth_place_label: birthPlaceLabel, latitude, longitude, timezone_id: timezoneId, timezone_offset: timezoneOffset, birth_time_source: source, birth_time_period: period, reported_birth_time: reportedTime, // A fresh Case must never inherit an accepted/active minute as its new baseline. active_birth_time: null, uncertainty_before_minutes: uncertaintyBefore, uncertainty_after_minutes: uncertaintyAfter, }; return { userId, baseline, baselineFingerprint: baselineFingerprint(baseline), candidateRange: deriveCandidateRange({ reportedTime, source, period }), }; } 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_skill_identity_missing", { status: 409, code: "skill_identity_missing", message: "该校正绑定的 Skill 版本不可用,请联系支持人员" }], ["agentic_rectification_legacy_skill_identity_unverifiable", { status: 409, code: "skill_identity_unverifiable", message: "该校正绑定的是无法核验的历史 Skill,请先采用当前注册版本" }], ["agentic_rectification_skill_identity_already_verified", { status: 409, code: "skill_identity_already_verified", message: "该校正已经绑定可核验的 Skill,不能重复采用历史 Skill" }], ["agentic_rectification_invalid_skill_identity", { status: 400, code: "invalid_skill_identity", message: "Skill 版本身份不正确" }], ["agentic_rectification_skill_name_mismatch", { status: 409, code: "skill_name_mismatch", message: "不能将校正记录升级到其他 Skill" }], ["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: "该事件当前不能修订" }], ["agentic_rectification_precision_downgrade", { status: 422, code: "precision_downgrade", 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) || message.includes(view.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 activeSkill = resolveActiveSkillPackage(RECTIFICATION_SKILL_NAME); if (activeSkill.version !== RECTIFICATION_SKILL_VERSION) { throw new RectificationCaseServiceError("skill_registry_version_mismatch"); } const { data, error } = await accounting.rpc("open_agentic_rectification_case_v2", { 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: activeSkill.name, p_skill_version: activeSkill.version, p_skill_sha256: activeSkill.sha256, p_skill_source_commit: activeSkill.sourceCommit, 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 ?? ""), skillIdentityStatus: row.skill_identity_status === "legacy_unverifiable" ? "legacy_unverifiable" : "verified", requiresSkillAdoption: row.requires_skill_adoption === true, 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 getRectificationSkillIdentityStatus( accounting: AccountingClient, userId: string, caseId: string, ): Promise { const { data, error } = await accounting.rpc("get_agentic_rectification_skill_identity_status", { 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_skill_identity_status"); } const value = row as Record; const status = value.skill_identity_status; const skillSha256 = value.skill_sha256 === null ? null : typeof value.skill_sha256 === "string" ? value.skill_sha256 : null; const sourceCommit = value.skill_source_commit === null ? null : typeof value.skill_source_commit === "string" ? value.skill_source_commit : null; if (typeof value.skill_name !== "string" || !value.skill_name || typeof value.skill_version !== "string" || !value.skill_version || (status !== "verified" && status !== "legacy_unverifiable") || (skillSha256 !== null && !/^[0-9a-f]{64}$/.test(skillSha256)) || (sourceCommit !== null && !/^[0-9a-f]{40}$/.test(sourceCommit)) || (status === "verified" && skillSha256 === null) || (status === "legacy_unverifiable" && skillSha256 !== null) || (status === "legacy_unverifiable" && value.requires_skill_adoption !== true)) { throw new RectificationCaseServiceError("invalid_skill_identity_status"); } return { skillName: value.skill_name, skillVersion: value.skill_version, skillSha256, sourceCommit, status, requiresSkillAdoption: value.requires_skill_adoption === true, }; } 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; previousSkillSha256: string | null; skillVersion: string; skillSha256: string; sourceCommit: string | null; receiptId: string | null; idempotent: boolean; }>; export type RectificationSkillAdoptionResult = Readonly<{ success: true; caseId: string; previousSkillName: string; previousSkillVersion: string; previousSkillSha256: null; previousSourceCommit: string | null; previousIdentityStatus: "legacy_unverifiable"; upgradeKind: "legacy_adoption"; skillName: string; skillVersion: string; skillSha256: string; sourceCommit: string | null; receiptId: string; idempotent: boolean; }>; export async function adoptLegacyRectificationSkill( accounting: AccountingClient, userId: string, caseId: string, skillVersion: string, ): Promise { const target = resolveSkillPackageVersion(RECTIFICATION_SKILL_NAME, skillVersion); const { data, error } = await accounting.rpc("adopt_agentic_rectification_skill_v1", { p_user_id: userId, p_case_id: caseId, p_skill_name: target.name, p_skill_version: target.version, p_skill_sha256: target.sha256, p_skill_source_commit: target.sourceCommit, }); if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code); const row = readRpcData(data); if (!row || typeof row !== "object") throw new RectificationCaseServiceError("invalid_adoption_result"); const value = row as Record; const previousSourceCommit = value.previous_source_commit === null ? null : typeof value.previous_source_commit === "string" ? value.previous_source_commit : null; const sourceCommit = value.source_commit === null ? null : typeof value.source_commit === "string" ? value.source_commit : null; const receiptId = typeof value.receipt_id === "string" ? value.receipt_id : ""; if ( value.success !== true || value.case_id !== caseId || typeof value.previous_skill_name !== "string" || !value.previous_skill_name || typeof value.previous_skill_version !== "string" || !value.previous_skill_version || value.previous_skill_sha256 !== null || (previousSourceCommit !== null && !/^[0-9a-f]{40}$/.test(previousSourceCommit)) || value.previous_identity_status !== "legacy_unverifiable" || value.upgrade_kind !== "legacy_adoption" || value.skill_name !== target.name || value.skill_version !== target.version || value.skill_sha256 !== target.sha256 || sourceCommit !== target.sourceCommit || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(receiptId) ) { throw new RectificationCaseServiceError("invalid_adoption_result"); } return { success: true, caseId, previousSkillName: value.previous_skill_name, previousSkillVersion: value.previous_skill_version, previousSkillSha256: null, previousSourceCommit, previousIdentityStatus: "legacy_unverifiable", upgradeKind: "legacy_adoption", skillName: target.name, skillVersion: target.version, skillSha256: target.sha256, sourceCommit, receiptId, idempotent: value.idempotent === true, }; } export async function upgradeRectificationSkill( accounting: AccountingClient, userId: string, caseId: string, skillVersion: string, ): Promise { const target = resolveSkillPackageVersion(RECTIFICATION_SKILL_NAME, skillVersion); const { data, error } = await accounting.rpc("upgrade_agentic_rectification_skill_v2", { p_user_id: userId, p_case_id: caseId, p_skill_name: target.name, p_skill_version: target.version, p_skill_sha256: target.sha256, p_skill_source_commit: target.sourceCommit, }); 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 ?? ""), previousSkillSha256: value.previous_skill_sha256 === null || value.previous_skill_sha256 === undefined ? null : String(value.previous_skill_sha256), skillVersion: String(value.skill_version ?? skillVersion), skillSha256: String(value.skill_sha256 ?? target.sha256), sourceCommit: typeof value.source_commit === "string" ? value.source_commit : null, receiptId: typeof value.receipt_id === "string" ? value.receipt_id : null, idempotent: value.idempotent === true, }; }