Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/tool-service.ts
T
Jesse_Chen 7c03c1a4b5
Staging Backend Quality Gate / validate (pull_request) Successful in 17m59s
Staging Backend Quality Gate / publish (pull_request) Has been skipped
fix(rectification): confirm clear events in same turn
2026-08-12 14:18:48 +08:00

864 lines
28 KiB
TypeScript

/**
* V9 rectification tool service.
*
* Server-owned fact layer behind the ten `rectification-*` tools. Every tool
* call authenticates through the Case row, validates ownership/status/skill
* version, reads the durable evidence ledger, derives canonical fingerprints,
* invokes the deterministic Python engine when evidence effectively changed,
* persists a candidate snapshot or receipt, and returns a compact safe
* projection. The model never supplies userId, birth data, candidate ranges,
* event arrays, scores or permission decisions.
*/
import { createHash } from "node:crypto";
import {
EVIDENCE_KINDS,
type EvidenceKind,
} from "./evidence-model";
import {
isPublicRectificationMethod,
isPublicRectificationPhase,
isPublicRectificationTool,
type PublicRectificationMethod,
type PublicRectificationPhase,
type PublicRectificationTool,
} from "./public-receipt";
import { RECTIFICATION_SKILL_VERSION } from "./case-status";
/**
* Minimal structural RPC client. SupabaseClient satisfies this; tests can
* pass a fake with a single rpc() method.
*/
export type RectificationRpcClient = {
rpc(
fn: string,
args: Record<string, unknown>,
): PromiseLike<{ data: unknown; error: { message: string } | null }>;
};
export type AccountingClient = RectificationRpcClient;
export class RectificationToolServiceError extends Error {
readonly code: string;
constructor(code: string) {
super(`Rectification tool service error: ${code}`);
this.name = "RectificationToolServiceError";
this.code = code;
}
}
function first(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;
}
function rpc<T>(
accounting: AccountingClient,
fn: string,
args: Record<string, unknown>,
): Promise<T> {
return Promise.resolve(accounting.rpc(fn, args)).then(({ data, error }) => {
if (error) throw new RectificationToolServiceError(error.message);
return first(data) as T;
});
}
export type V9CaseDossier = Readonly<{
case: 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;
completedAt: string | null;
closedReason: string | null;
lastActivityAt: string;
evidenceCount: number;
turnCount: number;
}>;
turns: readonly Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
createdAt: string;
}>[];
evidence: readonly Readonly<{
id: string;
sourceTurnId: string;
subject: string;
eventKind: string;
domain: string;
occurredFrom: string | null;
occurredTo: string | null;
datePrecision: string;
summary: string;
status: string;
supersedesEvidenceId: string | null;
createdAt: string;
}>[];
latestResult: V9CandidateSnapshot | null;
}>;
export type V9CandidateSnapshot = Readonly<{
resultId: string;
candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[];
overallConfidence: "low" | "medium" | "high";
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
selectedTime: string | null;
selectionKind: string | null;
evidenceLedgerFingerprint: string | null;
candidateRangeFingerprint: string | null;
skillVersion: string | null;
algorithmVersion: string | null;
createdAt: string;
invalidatedAt: string | null;
}>;
function timeValue(value: unknown): string | null {
const time = typeof value === "string" ? value.slice(0, 5) : "";
return /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(time) ? time : null;
}
function rowText(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function rowNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function rowBoolean(value: unknown): boolean {
return value === true;
}
function rowArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function parseV9CaseDossier(value: unknown): V9CaseDossier | null {
if (!value || typeof value !== "object") return null;
const root = value as Record<string, unknown>;
const caseRow = root.case && typeof root.case === "object"
? root.case as Record<string, unknown>
: null;
if (!caseRow || typeof caseRow.case_id !== "string") return null;
const range = caseRow.candidate_range && typeof caseRow.candidate_range === "object"
? caseRow.candidate_range as { start_time?: unknown; end_time?: unknown }
: null;
const candidateRange =
range && typeof range.start_time === "string" && typeof range.end_time === "string"
? { start_time: range.start_time, end_time: range.end_time }
: null;
const turns = rowArray(root.turns).flatMap((item): V9CaseDossier["turns"] => {
if (!item || typeof item !== "object") return [];
const turn = item as Record<string, unknown>;
if (typeof turn.id !== "string") return [];
return [{
id: turn.id,
role: turn.role === "user" ? "user" : "assistant",
text: rowText(turn.text),
status: String(turn.status ?? ""),
createdAt: String(turn.created_at ?? ""),
}];
});
const evidence = rowArray(root.evidence).flatMap((item): V9CaseDossier["evidence"] => {
if (!item || typeof item !== "object") return [];
const row = item as Record<string, unknown>;
if (typeof row.id !== "string") return [];
return [{
id: row.id,
sourceTurnId: String(row.source_turn_id ?? ""),
subject: String(row.subject ?? ""),
eventKind: String(row.event_kind ?? ""),
domain: String(row.domain ?? ""),
occurredFrom: rowText(row.occurred_from),
occurredTo: rowText(row.occurred_to),
datePrecision: String(row.date_precision ?? ""),
summary: String(row.summary ?? ""),
status: String(row.status ?? ""),
supersedesEvidenceId: rowText(row.supersedes_evidence_id),
createdAt: String(row.created_at ?? ""),
}];
});
const latestResult = parseV9CandidateSnapshot(root.latest_result);
return {
case: {
caseId: caseRow.case_id,
sessionId: String(caseRow.session_id ?? ""),
status: String(caseRow.status ?? ""),
skillName: String(caseRow.skill_name ?? ""),
skillVersion: String(caseRow.skill_version ?? ""),
candidateRange,
acceptedTime: timeValue(caseRow.accepted_time),
confirmedTime: timeValue(caseRow.confirmed_time),
completedAt: rowText(caseRow.completed_at),
closedReason: rowText(caseRow.closed_reason),
lastActivityAt: String(caseRow.last_activity_at ?? ""),
evidenceCount: rowNumber(caseRow.evidence_count) ?? 0,
turnCount: rowNumber(caseRow.turn_count) ?? 0,
},
turns,
evidence,
latestResult,
};
}
export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const resultId = typeof row.result_id === "string" ? row.result_id : "";
if (!resultId) return null;
const candidates = rowArray(row.candidates).flatMap((item) => {
if (!item || typeof item !== "object") return [];
const candidate = item as Record<string, unknown>;
const time = timeValue(candidate.time);
if (!time || typeof candidate.rank !== "number") return [];
return [{
rank: Math.trunc(candidate.rank),
time,
relative_support: typeof candidate.relative_support === "number" ? Math.max(0, Math.min(100, Math.trunc(candidate.relative_support))) : 0,
tied_minute_count: typeof candidate.tied_minute_count === "number" ? Math.max(1, Math.trunc(candidate.tied_minute_count)) : 1,
}];
});
return {
resultId,
candidates,
overallConfidence: row.overall_confidence === "high" || row.overall_confidence === "medium" ? row.overall_confidence : "low",
selectionAllowed: rowBoolean(row.selection_allowed),
confirmationAllowed: rowBoolean(row.confirmation_allowed),
representativeTime: timeValue(row.representative_time),
selectedTime: timeValue(row.selected_time),
selectionKind: rowText(row.selection_kind),
evidenceLedgerFingerprint: rowText(row.evidence_ledger_fingerprint),
candidateRangeFingerprint: rowText(row.candidate_range_fingerprint),
skillVersion: rowText(row.skill_version),
algorithmVersion: rowText(row.algorithm_version),
createdAt: String(row.created_at ?? ""),
invalidatedAt: rowText(row.invalidated_at),
};
}
export type V9ComputeProjection = Readonly<{
caseId: string;
skillVersion: string;
baselineProfileFingerprint: string;
baselineBirthSnapshot: Readonly<Record<string, unknown>>;
candidateRange: { start_time: string; end_time: string };
}>;
export function parseV9ComputeProjection(value: unknown): V9ComputeProjection | null {
if (!value || typeof value !== "object") return null;
const row = value as Record<string, unknown>;
const range = row.candidate_range && typeof row.candidate_range === "object"
? row.candidate_range as { start_time?: unknown; end_time?: unknown }
: null;
const snapshot = row.baseline_birth_snapshot && typeof row.baseline_birth_snapshot === "object"
? row.baseline_birth_snapshot as Record<string, unknown>
: null;
if (
typeof row.case_id !== "string"
|| typeof row.skill_version !== "string"
|| typeof row.baseline_profile_fingerprint !== "string"
|| !snapshot
|| !range
|| typeof range.start_time !== "string"
|| typeof range.end_time !== "string"
) {
return null;
}
return {
caseId: row.case_id,
skillVersion: row.skill_version,
baselineProfileFingerprint: row.baseline_profile_fingerprint,
baselineBirthSnapshot: snapshot,
candidateRange: { start_time: range.start_time, end_time: range.end_time },
};
}
export async function loadV9CaseDossier(
accounting: AccountingClient,
userId: string,
caseId: string,
): Promise<V9CaseDossier> {
const row = await rpc<unknown>(
accounting,
"get_agentic_rectification_case_dossier",
{ p_user_id: userId, p_case_id: caseId },
);
const dossier = parseV9CaseDossier(row);
if (!dossier) throw new RectificationToolServiceError("invalid_case_dossier");
return dossier;
}
export async function loadV9CaseCompute(
accounting: AccountingClient,
userId: string,
caseId: string,
): Promise<V9ComputeProjection> {
const row = await rpc<unknown>(
accounting,
"get_agentic_rectification_case_compute",
{ p_user_id: userId, p_case_id: caseId },
);
const projection = parseV9ComputeProjection(row);
if (!projection) throw new RectificationToolServiceError("invalid_case_compute");
return projection;
}
/** Evidence rows that actually carry a scorable date. */
export function scorableEvidence(
evidence: V9CaseDossier["evidence"],
): V9CaseDossier["evidence"] {
return evidence.filter(
(item) =>
item.status === "confirmed"
&& item.datePrecision !== "unknown"
&& (item.occurredFrom || item.occurredTo),
);
}
function hashParts(parts: readonly string[]): string {
return createHash("sha256").update(parts.join("|")).digest("hex");
}
/** Canonical evidence ledger fingerprint over scorable evidence rows. */
export function evidenceLedgerFingerprint(
evidence: V9CaseDossier["evidence"],
): string {
const rows = [...scorableEvidence(evidence)]
.sort((left, right) => left.id.localeCompare(right.id))
.map((item) =>
[
item.id,
item.eventKind,
item.domain,
item.occurredFrom ?? "",
item.occurredTo ?? "",
item.datePrecision,
item.summary,
].join("::"),
);
return hashParts(["v9-evidence-ledger-v1", ...rows]);
}
/** Candidate range fingerprint: range + baseline fingerprint. */
export function candidateRangeFingerprint(
range: { start_time: string; end_time: string },
baselineProfileFingerprint: string,
): string {
return hashParts([
"v9-candidate-range-v1",
range.start_time,
range.end_time,
baselineProfileFingerprint,
]);
}
/** Canonical tool input fingerprint for receipts. */
export function canonicalToolInputFingerprint(
toolName: string,
args: Readonly<Record<string, unknown>>,
): string {
const safe = Object.fromEntries(
Object.entries(args)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, typeof value === "string" ? value : JSON.stringify(value)]),
);
return hashParts([`v9-tool:${toolName}`, JSON.stringify(safe)]);
}
export type V9TurnAppendResult = Readonly<{ turnId: string }>;
export async function appendV9Turn(
accounting: AccountingClient,
userId: string,
caseId: string,
input: {
userMessage: string | null;
modelName: string;
modelVersion?: string;
},
): Promise<V9TurnAppendResult> {
const row = await rpc<{ turn_id?: unknown }>(
accounting,
"append_agentic_rectification_turn",
{
p_user_id: userId,
p_case_id: caseId,
p_user_message: input.userMessage,
p_assistant_message: null,
p_model_name: input.modelName,
p_model_version: input.modelVersion ?? null,
p_status: "pending",
},
);
const turnId = typeof row?.turn_id === "string" ? row.turn_id : "";
if (!turnId) throw new RectificationToolServiceError("invalid_turn_id");
return { turnId };
}
export async function finalizeV9Turn(
accounting: AccountingClient,
userId: string,
caseId: string,
turnId: string,
status: "completed" | "failed" | "retryable",
assistantMessage: string | null,
): Promise<void> {
await rpc<unknown>(
accounting,
"finalize_agentic_rectification_turn",
{
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_status: status,
p_assistant_message: assistantMessage,
},
);
}
export async function insertV9ToolReceipt(
accounting: AccountingClient,
userId: string,
caseId: string,
turnId: string,
input: {
toolName: string;
publicPhase: string;
status: "started" | "completed" | "failed" | "skipped";
inputFingerprint?: string | null;
resultFingerprint?: string | null;
engineVersion?: string | null;
safeErrorCode?: string | null;
executedMethods?: readonly PublicRectificationMethod[];
},
): Promise<void> {
if (!isPublicRectificationTool(input.toolName)) {
throw new RectificationToolServiceError("tool_not_allowlisted");
}
if (!isPublicRectificationPhase(input.publicPhase)) {
throw new RectificationToolServiceError("phase_not_allowlisted");
}
const executedMethods = [...new Set(input.executedMethods ?? [])];
if (!executedMethods.every(isPublicRectificationMethod)) {
throw new RectificationToolServiceError("method_not_allowlisted");
}
await rpc<unknown>(
accounting,
"insert_agentic_rectification_tool_receipt",
{
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_tool_name: input.toolName,
p_public_phase: input.publicPhase,
p_status: input.status,
p_input_fingerprint: input.inputFingerprint ?? null,
p_result_fingerprint: input.resultFingerprint ?? null,
p_engine_version: input.engineVersion ?? null,
p_safe_error_code: input.safeErrorCode ?? null,
p_executed_methods: executedMethods,
},
);
}
export async function insertV9RunPhase(
accounting: AccountingClient,
userId: string,
caseId: string,
turnId: string,
phase: string,
toolName: string | null,
sequence: number,
): Promise<void> {
if (!isPublicRectificationPhase(phase)) {
throw new RectificationToolServiceError("phase_not_allowlisted");
}
await rpc<unknown>(
accounting,
"insert_agentic_rectification_run_phase",
{
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_phase: phase,
p_tool_name: toolName,
p_sequence: sequence,
},
);
}
export type V9TurnReceipt = Readonly<{
turnId: string;
status: string;
skillName: string;
skillVersion: string;
engineVersion: string | null;
phases: readonly Readonly<{ phase: string; tool: string | null }>[];
tools: readonly string[];
methods: readonly PublicRectificationMethod[];
startedAt: string;
completedAt: string | null;
}>;
export async function loadV9TurnReceipt(
accounting: AccountingClient,
userId: string,
caseId: string,
turnId: string,
): Promise<V9TurnReceipt | null> {
const row = await rpc<Record<string, unknown>>(
accounting,
"get_agentic_rectification_turn_receipt",
{ p_user_id: userId, p_case_id: caseId, p_turn_id: turnId },
);
if (!row || typeof row.turn_id !== "string") return null;
const phases = rowArray(row.phases).flatMap((item) => {
if (!item || typeof item !== "object") return [];
const phaseRow = item as Record<string, unknown>;
return [{ phase: String(phaseRow.phase ?? ""), tool: rowText(phaseRow.tool) }];
});
return {
turnId: row.turn_id,
status: String(row.status ?? ""),
skillName: String(row.skill_name ?? ""),
skillVersion: String(row.skill_version ?? ""),
engineVersion: rowText(row.engine_version),
phases,
tools: rowArray(row.tools).map((item) => String(item)),
methods: rowArray(row.methods).filter(isPublicRectificationMethod),
startedAt: String(row.started_at ?? ""),
completedAt: rowText(row.completed_at),
};
}
/** Map a persisted turn status to the public receipt status vocabulary. */
export function receiptStatusFromTurn(status: string): "completed" | "degraded" | "blocked" | "failed" {
if (status === "completed") return "completed";
if (status === "retryable") return "degraded";
return "failed";
}
export async function proposeV9Evidence(
accounting: AccountingClient,
userId: string,
caseId: string,
input: {
sourceTurnId: string;
quote: string;
subject: "self" | "family" | "other";
eventKind: EvidenceKind;
domain: string;
occurredFrom: string | null;
occurredTo: string | null;
datePrecision: string;
summary: string;
},
): Promise<Readonly<{ evidenceId: string; idempotent: boolean }>> {
const row = await rpc<{ evidence_id?: unknown; idempotent?: unknown }>(
accounting,
"propose_agentic_rectification_evidence",
{
p_user_id: userId,
p_case_id: caseId,
p_source_turn_id: input.sourceTurnId,
p_user_quote: input.quote,
p_subject: input.subject,
p_event_kind: input.eventKind,
p_domain: input.domain,
p_occurred_from: input.occurredFrom,
p_occurred_to: input.occurredTo,
p_date_precision: input.datePrecision,
p_summary: input.summary,
},
);
const evidenceId = typeof row?.evidence_id === "string" ? row.evidence_id : "";
if (!evidenceId) throw new RectificationToolServiceError("invalid_evidence_id");
return { evidenceId, idempotent: row?.idempotent === true };
}
export async function confirmV9Evidence(
accounting: AccountingClient,
userId: string,
caseId: string,
evidenceId: string,
): Promise<Readonly<{ evidenceId: string; status: string; idempotent: boolean }>> {
const row = await rpc<{ evidence_id?: unknown; status?: unknown; idempotent?: unknown }>(
accounting,
"confirm_agentic_rectification_evidence",
{ p_user_id: userId, p_case_id: caseId, p_evidence_id: evidenceId },
);
const confirmedId = typeof row?.evidence_id === "string" ? row.evidence_id : "";
if (!confirmedId) throw new RectificationToolServiceError("invalid_evidence_id");
return {
evidenceId: confirmedId,
status: String(row?.status ?? "confirmed"),
idempotent: row?.idempotent === true,
};
}
export async function reviseV9Evidence(
accounting: AccountingClient,
userId: string,
caseId: string,
input: {
evidenceId: string;
quote: string;
occurredFrom: string | null;
occurredTo: string | null;
datePrecision: string;
summary: string;
},
): Promise<Readonly<{ evidenceId: string; supersedesEvidenceId: string; idempotent: boolean }>> {
const row = await rpc<{ evidence_id?: unknown; supersedes_evidence_id?: unknown; idempotent?: unknown }>(
accounting,
"revise_agentic_rectification_evidence",
{
p_user_id: userId,
p_case_id: caseId,
p_evidence_id: input.evidenceId,
p_user_quote: input.quote,
p_occurred_from: input.occurredFrom,
p_occurred_to: input.occurredTo,
p_date_precision: input.datePrecision,
p_summary: input.summary,
},
);
const evidenceId = typeof row?.evidence_id === "string" ? row.evidence_id : "";
const supersedes = typeof row?.supersedes_evidence_id === "string" ? row.supersedes_evidence_id : "";
if (!evidenceId) throw new RectificationToolServiceError("invalid_evidence_id");
return { evidenceId, supersedesEvidenceId: supersedes, idempotent: row?.idempotent === true };
}
export async function transitionV9CaseStatus(
accounting: AccountingClient,
userId: string,
caseId: string,
status: "draft" | "collecting_evidence" | "candidate_ready" | "candidate_accepted" | "needs_rebaseline" | "paused",
): Promise<Readonly<{ status: string; idempotent: boolean }>> {
const row = await rpc<{ status?: unknown; idempotent?: unknown }>(
accounting,
"transition_agentic_rectification_case_status",
{ p_user_id: userId, p_case_id: caseId, p_status: status },
);
return { status: String(row?.status ?? status), idempotent: row?.idempotent === true };
}
export type V9PersistedCandidate = Readonly<{
resultId: string;
cached: boolean;
candidates: V9CandidateSnapshot["candidates"];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
algorithmVersion: string | null;
}>;
export async function persistV9Candidate(
accounting: AccountingClient,
userId: string,
caseId: string,
input: {
engineResultId: string;
algorithmVersion: string;
evidenceFingerprint: string;
rangeFingerprint: string;
skillVersion: string;
candidateRange: { start_time: string; end_time: string };
candidates: V9CandidateSnapshot["candidates"];
overallConfidence: "low" | "medium" | "high";
marginPercent: number | null;
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
},
): Promise<V9PersistedCandidate> {
const row = await rpc<Record<string, unknown>>(
accounting,
"persist_agentic_rectification_candidate",
{
p_user_id: userId,
p_case_id: caseId,
p_engine_result_id: input.engineResultId,
p_algorithm_version: input.algorithmVersion,
p_evidence_ledger_fingerprint: input.evidenceFingerprint,
p_candidate_range_fingerprint: input.rangeFingerprint,
p_skill_version: input.skillVersion,
p_candidate_range: input.candidateRange,
p_candidates: input.candidates,
p_overall_confidence: input.overallConfidence,
p_margin_percent: input.marginPercent,
p_selection_allowed: input.selectionAllowed,
p_confirmation_allowed: input.confirmationAllowed,
p_representative_time: input.representativeTime,
},
);
const resultId = typeof row?.result_id === "string" ? row.result_id : "";
if (!resultId) throw new RectificationToolServiceError("invalid_candidate_result");
return {
resultId,
cached: row?.cached === true,
candidates: parseV9CandidateSnapshot(row)?.candidates ?? input.candidates,
overallConfidence: row?.overall_confidence === "high" || row?.overall_confidence === "medium" ? row.overall_confidence : "low",
marginPercent: typeof row?.margin_percent === "number" ? row.margin_percent : input.marginPercent,
selectionAllowed: row?.selection_allowed === true,
confirmationAllowed: row?.confirmation_allowed === true,
representativeTime: timeValue(row?.representative_time),
algorithmVersion: typeof row?.algorithm_version === "string" ? row.algorithm_version : input.algorithmVersion,
};
}
export type V9AcceptResult = Readonly<{
success: boolean;
savedTime: string;
status: "accepted" | "confirmed";
resultId: string;
caseStatus: string;
idempotent: boolean;
}>;
export async function acceptV9Candidate(
accounting: AccountingClient,
userId: string,
caseId: string,
resultId: string,
time: string,
): Promise<V9AcceptResult> {
const row = await rpc<Record<string, unknown>>(
accounting,
"accept_agentic_rectification_candidate_for_case",
{
p_user_id: userId,
p_case_id: caseId,
p_result_id: resultId,
p_time: time,
},
);
const savedTime = timeValue(row?.saved_time);
if (row?.success !== true || !savedTime) throw new RectificationToolServiceError("invalid_accept_result");
return {
success: true,
savedTime,
status: row?.status === "confirmed" ? "confirmed" : "accepted",
resultId: String(row?.result_id ?? resultId),
caseStatus: String(row?.case_status ?? "candidate_accepted"),
idempotent: row?.idempotent === true,
};
}
export type V9ConfirmResult = Readonly<{
success: boolean;
savedTime: string;
status: "confirmed";
resultId: string;
caseStatus: string;
idempotent: boolean;
}>;
export async function confirmV9BirthTime(
accounting: AccountingClient,
userId: string,
caseId: string,
input: {
resultId: string;
time: string;
consentQuote: string;
sourceTurnId: string;
},
): Promise<V9ConfirmResult> {
const row = await rpc<Record<string, unknown>>(
accounting,
"confirm_agentic_rectification_birth_time",
{
p_user_id: userId,
p_case_id: caseId,
p_result_id: input.resultId,
p_time: input.time,
p_consent_quote: input.consentQuote,
p_source_turn_id: input.sourceTurnId,
},
);
const savedTime = timeValue(row?.saved_time);
if (row?.success !== true || !savedTime) throw new RectificationToolServiceError("invalid_confirm_result");
return {
success: true,
savedTime,
status: "confirmed",
resultId: String(row?.result_id ?? input.resultId),
caseStatus: String(row?.case_status ?? "confirmed"),
idempotent: row?.idempotent === true,
};
}
export async function closeV9Case(
accounting: AccountingClient,
userId: string,
caseId: string,
reason: "completed_by_user" | "abandoned_by_user" | "other",
): Promise<Readonly<{ success: boolean; caseId: string; status: string; idempotent: boolean }>> {
const row = await rpc<Record<string, unknown>>(
accounting,
"close_agentic_rectification_case",
{ p_user_id: userId, p_case_id: caseId, p_reason: reason },
);
if (row?.success !== true) throw new RectificationToolServiceError("invalid_close_result");
return {
success: true,
caseId: String(row?.case_id ?? caseId),
status: String(row?.status ?? "closed"),
idempotent: row?.idempotent === true,
};
}
/** Map an RPC error message back to a safe tool error code. */
export function safeToolErrorCode(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const known = [
"quote_not_grounded",
"consent_not_grounded",
"evidence_not_found",
"evidence_not_confirmable",
"evidence_not_revisable",
"case_terminal",
"case_not_found",
"candidate_not_found",
"candidate_expired",
"candidate_selection_blocked",
"candidate_time_not_allowed",
"candidate_already_selected",
"candidate_superseded",
"candidate_profile_changed",
"confirmation_blocked",
"confirm_time_mismatch",
"case_already_confirmed",
"skill_version_mismatch",
"turn_not_found",
"invalid_input",
];
for (const code of known) {
if (message.includes(`agentic_rectification_${code}`)) return code;
}
return "tool_failed";
}
export const V9_EVIDENCE_KINDS = EVIDENCE_KINDS;
export const V9_SKILL_VERSION = RECTIFICATION_SKILL_VERSION;
export type V9PublicTool = PublicRectificationTool;
export type V9PublicPhase = PublicRectificationPhase;