Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/case-service.ts
T
Jesse_ChenandCursor a31a5e2426
Independent Staging Quality Gate / validate (push) Successful in 9m46s
Independent Staging Quality Gate / publish (push) Successful in 32m4s
fix(rectification): open history cases with their bound skill identity (BUG-621)
Session open was sending the live registry version into v2, so every Skill
bump rejected older cases with an unmapped 500 and no error on the clicked row.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 19:35:23 +08:00

761 lines
32 KiB
TypeScript

/**
* 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 { declaredClockRange } from "../../declared-birth-window.ts";
import {
parseRectificationCaseStage,
type RectificationCaseStage,
} from "./block-scan.ts";
import { resolveAyanamsa, type AyanamsaName } from "../../ayanamsa.ts";
import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts";
import {
SkillPackageRegistryError,
resolveActiveSkillPackage,
resolveExactSkillPackage,
resolveSkillPackageVersion,
} from "../../skill-package-registry.ts";
import {
isRectificationCaseStatus,
RECTIFICATION_SKILL_NAME,
RECTIFICATION_SKILL_VERSION,
type RectificationCaseStatus,
} from "./case-status.ts";
export { projectRectificationStepState } from "./step-state.ts";
export type { RectificationStepState } from "./step-state.ts";
import { RECTIFICATION_USER_COPY } from "../user-copy.ts";
import {
deriveDeclaredSearchWindow,
stageForClockWindow,
} from "./search-window.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;
birth_time_clue: string | null;
declared_window_start: string | null;
declared_window_end: string | null;
reported_birth_time: string | null;
active_birth_time: string | null;
uncertainty_before_minutes: number | null;
uncertainty_after_minutes: number | null;
ayanamsa: AyanamsaName;
}>;
export type V9RectificationProfile = Readonly<{
userId: string;
baseline: V9BaselineSnapshot;
baselineFingerprint: string;
candidateRange: { start_time: string; end_time: string };
stage: RectificationCaseStage;
}>;
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;
stage: RectificationCaseStage;
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")}`;
}
function deriveRectificationOpenPlan(input: {
reportedTime: string | null;
source: string;
period: string | null;
windowStart: string | null;
windowEnd: string | null;
uncertaintyBefore?: number | null;
uncertaintyAfter?: number | null;
}): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } {
if (input.reportedTime) {
const candidateRange = deriveDeclaredSearchWindow({
reportedTime: input.reportedTime,
source: input.source,
uncertaintyBefore: input.uncertaintyBefore ?? null,
uncertaintyAfter: input.uncertaintyAfter ?? null,
}) ?? {
start_time: shiftedTime(input.reportedTime, -15),
end_time: shiftedTime(input.reportedTime, 15),
};
return {
candidateRange,
stage: input.source === "unknown" ? "minute" : stageForClockWindow(candidateRange),
};
}
const range = declaredClockRange({
source: input.source,
period: input.period,
startTime: input.windowStart,
endTime: input.windowEnd,
});
if (!range) throw new RectificationCaseServiceError("profile_incomplete");
const candidateRange = { start_time: range.startTime, end_time: range.endTime };
if (input.source === "unknown") {
return { candidateRange, stage: "block_scan" };
}
return {
candidateRange,
stage: stageForClockWindow(candidateRange),
};
}
export function deriveRectificationOpenWindow(input: {
reportedTime: string | null;
source: string;
period: string | null;
windowStart: string | null;
windowEnd: string | null;
uncertaintyBefore?: number | null;
uncertaintyAfter?: number | null;
}): { candidateRange: { start_time: string; end_time: string }; stage: RectificationCaseStage } {
return deriveRectificationOpenPlan(input);
}
function deriveCandidateRange(input: {
reportedTime: string | null;
source: string;
period: string | null;
windowStart: string | null;
windowEnd: string | null;
}): { start_time: string; end_time: string } {
return deriveRectificationOpenPlan(input).candidateRange;
}
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
const canonical = [
baseline.birth_date,
String(baseline.latitude),
String(baseline.longitude),
baseline.timezone_id,
String(baseline.timezone_offset),
baseline.birth_time_source,
baseline.birth_time_period ?? "",
baseline.declared_window_start ?? "",
baseline.declared_window_end ?? "",
baseline.reported_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,birth_place_label,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,declared_window_start,declared_window_end,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset,ayanamsa",
)
.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<string, unknown>;
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 clue = typeof row.birth_time_clue === "string" && row.birth_time_clue.trim()
? row.birth_time_clue.trim().slice(0, 240)
: null;
const windowStart = timeValue(row.declared_window_start);
const windowEnd = timeValue(row.declared_window_end);
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,
birth_time_clue: clue,
declared_window_start: windowStart,
declared_window_end: windowEnd,
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,
ayanamsa: resolveAyanamsa(row),
};
const openPlan = deriveRectificationOpenPlan({
reportedTime,
source,
period,
windowStart,
windowEnd,
uncertaintyBefore,
uncertaintyAfter,
});
return {
userId,
baseline,
baselineFingerprint: baselineFingerprint(baseline),
candidateRange: openPlan.candidateRange,
stage: openPlan.stage,
};
}
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_skill_identity_missing", { status: 409, code: "skill_identity_missing", message: "该校正绑定的 Skill 版本不可用,请联系支持人员" }],
["agentic_rectification_skill_identity_mismatch", { status: 409, code: "skill_identity_mismatch", message: "该校正绑定的规则版本与当前不一致,请刷新后重试" }],
["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_invalid_block_scan_window", { status: 400, code: "invalid_block_scan_window", message: "当前窗口不能比较时段" }],
["agentic_rectification_not_block_scan", { status: 409, code: "not_block_scan", message: "当前不是时段比较阶段" }],
["agentic_rectification_invalid_block_period", { status: 400, code: "invalid_block_period", message: "出生时段不正确" }],
["agentic_rectification_invalid_block_window", { status: 400, code: "invalid_block_window", message: "新窗口必须落在当前范围内" }],
["agentic_rectification_invalid_widen_window", { status: 400, code: "invalid_widen_window", message: "只能在当前范围内放宽,且不能超过前后两小时" }],
["agentic_rectification_not_minute", { status: 409, code: "not_minute", message: "当前不是按分钟比较的阶段" }],
["agentic_rectification_already_adopted", { status: 409, code: "already_adopted", 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: "不能把已确认的更细日期精度改粗" }],
["agentic_rectification_stale_probe", { status: 409, code: "stale_probe", message: "这道区分题已经过期,请回答当前问题" }],
["agentic_rectification_stale_question", { status: 409, code: "stale_question", message: "这道题已经过期,请回答当前问题" }],
["agentic_rectification_focus_not_active", { status: 409, code: "focus_not_active", message: "当前没有等待回答的问题" }],
["agentic_rectification_invalid_choice_schema", { status: 409, code: "invalid_choice_schema", message: RECTIFICATION_USER_COPY.questionUpdated }],
["agentic_rectification_focus_not_found", { status: 409, code: "focus_not_found", message: "当前没有等待回答的问题" }],
["agentic_rectification_revision_conflict", { status: 409, code: "revision_conflict", message: "推断状态已更新,请刷新后再试" }],
["agentic_rectification_inference_patch_retired", { status: 409, code: "inference_patch_retired", 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: "校正服务暂时不可用" };
}
type OpenSkillIdentity = Readonly<{
name: string;
version: string;
sha256: string;
sourceCommit: string | null;
}>;
function readBoundSkillIdentity(row: unknown): OpenSkillIdentity {
if (!row || typeof row !== "object") {
throw new RectificationCaseServiceError("invalid_skill_identity");
}
const value = row as Record<string, unknown>;
const name = typeof value.skill_name === "string" ? value.skill_name : "";
const version = typeof value.skill_version === "string" ? value.skill_version : "";
const sha256 = typeof value.skill_sha256 === "string" ? value.skill_sha256 : "";
const sourceCommit = value.skill_source_commit === null || value.skill_source_commit === undefined
? null
: typeof value.skill_source_commit === "string"
? value.skill_source_commit
: null;
if (
!name
|| !version
|| !/^[0-9a-f]{64}$/.test(sha256)
|| (sourceCommit !== null && !/^[0-9a-f]{40}$/.test(sourceCommit))
) {
throw new RectificationCaseServiceError("invalid_skill_identity");
}
return { name, version, sha256, sourceCommit };
}
async function resolveBoundSkillForSessionOpen(
accounting: AccountingClient,
userId: string,
request: Extract<OpenRectificationCaseRequest, { intent: "session" }>,
placeholder: OpenSkillIdentity,
): Promise<OpenSkillIdentity> {
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.sessionId,
p_skill_name: placeholder.name,
p_skill_version: placeholder.version,
p_baseline_profile_fingerprint: "session-view",
p_baseline_birth_snapshot: {},
p_candidate_range: { start_time: "00:00", end_time: "23:59" },
});
if (error) throw new RectificationCaseServiceError(mapRectificationRpcError(error).code);
const preview = openResponse(readRpcData(data));
if (!preview) throw new RectificationCaseServiceError("invalid_open_response");
const identity = await accounting.rpc("get_agentic_rectification_skill_identity", {
p_user_id: userId,
p_case_id: preview.caseId,
});
if (identity.error) throw new RectificationCaseServiceError(mapRectificationRpcError(identity.error).code);
const bound = readBoundSkillIdentity(readRpcData(identity.data));
try {
resolveExactSkillPackage(bound.name, bound.version, bound.sha256);
} catch (caught) {
if (caught instanceof SkillPackageRegistryError) {
throw new RectificationCaseServiceError("skill_identity_missing");
}
throw caught;
}
return bound;
}
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 activeSkill = resolveActiveSkillPackage(RECTIFICATION_SKILL_NAME);
if (activeSkill.version !== RECTIFICATION_SKILL_VERSION) {
throw new RectificationCaseServiceError("skill_registry_version_mismatch");
}
const skill = request.intent === "session"
? await resolveBoundSkillForSessionOpen(accounting, userId, request, activeSkill)
: activeSkill;
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: skill.name,
p_skill_version: skill.version,
p_skill_sha256: skill.sha256,
p_skill_source_commit: skill.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");
if (profile?.stage === "block_scan" && response.disposition === "created") {
const staged = await accounting.rpc("set_agentic_rectification_case_stage", {
p_user_id: userId,
p_case_id: response.caseId,
p_stage: "block_scan",
});
if (staged.error) throw new RectificationCaseServiceError(mapRectificationRpcError(staged.error).code);
}
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 ?? ""),
skillIdentityStatus: row.skill_identity_status === "legacy_unverifiable" ? "legacy_unverifiable" : "verified",
requiresSkillAdoption: row.requires_skill_adoption === true,
stage: parseRectificationCaseStage(row.stage),
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<RectificationSkillIdentityStatusView> {
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<string, unknown>;
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<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;
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<RectificationSkillAdoptionResult> {
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<string, unknown>;
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<RectificationUpgradeResult> {
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<string, unknown>;
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,
};
}