909de8b884
Engine result rows stay immutable. Choice answers append transitions, and reads overlay the latest revision instead of patching the cached receipt. Co-authored-by: Cursor <cursoragent@cursor.com>
1627 lines
52 KiB
TypeScript
1627 lines
52 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";
|
|
import {
|
|
asInferenceState,
|
|
type InferenceTransitionSnapshot,
|
|
} from "../core/compose-receipt";
|
|
import { INFERENCE_ALGORITHM_VERSION } from "../core/types";
|
|
import { decisionStateFingerprint } from "../core/decision-fingerprint";
|
|
|
|
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
/**
|
|
* 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 ConversationFocusStatus =
|
|
| "active"
|
|
| "resolved"
|
|
| "declined"
|
|
| "skipped"
|
|
| "superseded";
|
|
|
|
export type ConversationFocus = Readonly<{
|
|
id: string;
|
|
caseId: string;
|
|
questionId: string;
|
|
intent: string;
|
|
targetEvidenceId: string | null;
|
|
targetDomain: string | null;
|
|
targetKind: string | null;
|
|
expectedAnswerSchema: Readonly<Record<string, unknown>>;
|
|
status: ConversationFocusStatus;
|
|
askedAt: string;
|
|
resolvedAt: string | null;
|
|
}>;
|
|
|
|
export type CaseConversationSummary = Readonly<{
|
|
confirmedEvidenceSummary: readonly Readonly<Record<string, unknown>>[];
|
|
pendingRevisions: readonly Readonly<Record<string, unknown>>[];
|
|
activeFocus: ConversationFocus | null;
|
|
declinedSkippedTopics: readonly Readonly<Record<string, unknown>>[];
|
|
candidateDivergenceSummary: Readonly<Record<string, unknown>> | null;
|
|
missingEvidenceCategories: readonly string[];
|
|
lastResultPolicy: Readonly<Record<string, unknown>> | null;
|
|
summaryVersion: number;
|
|
updatedAt: string;
|
|
}>;
|
|
|
|
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;
|
|
dateSource?: string | null;
|
|
dateReliability?: string | null;
|
|
dateCorroboration?: string | null;
|
|
dateConflictStatus?: string | null;
|
|
}>[];
|
|
conversationSummary: CaseConversationSummary;
|
|
latestResult: V9CandidateSnapshot | null;
|
|
}>;
|
|
|
|
export type RectificationCaseSkillIdentityStatus = Readonly<{
|
|
name: string;
|
|
version: string;
|
|
sha256: string | null;
|
|
sourceCommit: string | null;
|
|
status: "verified" | "legacy_unverifiable";
|
|
requiresSkillAdoption: boolean;
|
|
}>;
|
|
|
|
export type RectificationCaseSkillIdentity = Readonly<{
|
|
name: string;
|
|
version: string;
|
|
sha256: string;
|
|
sourceCommit: string | null;
|
|
}>;
|
|
|
|
export async function loadV9CaseSkillIdentityStatus(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<RectificationCaseSkillIdentityStatus> {
|
|
const value = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"get_agentic_rectification_skill_identity_status",
|
|
{ p_user_id: userId, p_case_id: caseId },
|
|
);
|
|
const name = rowText(value?.skill_name);
|
|
const version = rowText(value?.skill_version);
|
|
const sha256 = value?.skill_sha256 === null ? null : rowText(value?.skill_sha256);
|
|
const sourceCommit = value?.skill_source_commit === null ? null : rowText(value?.skill_source_commit);
|
|
const status = value?.skill_identity_status;
|
|
if (!name || !version || (status !== "verified" && status !== "legacy_unverifiable")
|
|
|| (sha256 !== null && (!sha256 || !/^[0-9a-f]{64}$/.test(sha256)))
|
|
|| (sourceCommit !== null && (!sourceCommit || !/^[0-9a-f]{40}$/.test(sourceCommit)))) {
|
|
throw new RectificationToolServiceError("agentic_rectification_invalid_skill_identity");
|
|
}
|
|
if (status === "verified" && sha256 === null) {
|
|
throw new RectificationToolServiceError("agentic_rectification_invalid_skill_identity");
|
|
}
|
|
if (status === "legacy_unverifiable" && sha256 !== null) {
|
|
throw new RectificationToolServiceError("agentic_rectification_invalid_skill_identity");
|
|
}
|
|
return {
|
|
name,
|
|
version,
|
|
sha256,
|
|
sourceCommit,
|
|
status,
|
|
requiresSkillAdoption: value?.requires_skill_adoption === true,
|
|
};
|
|
}
|
|
|
|
export async function loadV9CaseSkillIdentity(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<RectificationCaseSkillIdentity> {
|
|
let value: Record<string, unknown>;
|
|
try {
|
|
value = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"get_agentic_rectification_skill_identity",
|
|
{ p_user_id: userId, p_case_id: caseId },
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof RectificationToolServiceError
|
|
&& error.code.includes("agentic_rectification_legacy_skill_identity_unverifiable")) {
|
|
throw error;
|
|
}
|
|
throw error;
|
|
}
|
|
const name = rowText(value?.skill_name);
|
|
const version = rowText(value?.skill_version);
|
|
const sha256 = rowText(value?.skill_sha256);
|
|
const sourceCommit = rowText(value?.skill_source_commit);
|
|
if (!name || !version || !sha256 || !/^[0-9a-f]{64}$/.test(sha256)
|
|
|| (sourceCommit !== null && !/^[0-9a-f]{40}$/.test(sourceCommit))) {
|
|
throw new RectificationToolServiceError("agentic_rectification_skill_identity_missing");
|
|
}
|
|
return { name, version, sha256, sourceCommit };
|
|
}
|
|
|
|
export async function insertV9SkillRunReceipt(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
requestId: string,
|
|
runKind: "turn" | "regeneration",
|
|
identity: RectificationCaseSkillIdentity,
|
|
): Promise<void> {
|
|
await rpc<unknown>(
|
|
accounting,
|
|
"insert_agentic_rectification_skill_run_receipt",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_turn_id: turnId,
|
|
p_request_id: requestId,
|
|
p_run_kind: runKind,
|
|
p_skill_name: identity.name,
|
|
p_skill_version: identity.version,
|
|
p_skill_sha256: identity.sha256,
|
|
p_source_commit: identity.sourceCommit,
|
|
},
|
|
);
|
|
}
|
|
|
|
export type V9Candidate = Readonly<{
|
|
candidateId: string;
|
|
time: string;
|
|
rank: number;
|
|
relativeSupport: number;
|
|
tiedMinuteCount: number;
|
|
}>;
|
|
|
|
export type V9CandidateSnapshot = Readonly<{
|
|
resultId: string;
|
|
candidates: readonly V9Candidate[];
|
|
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;
|
|
eventContractVersion: string | null;
|
|
policyVersion: string | null;
|
|
decisionReceipt: Readonly<Record<string, unknown>> | null;
|
|
executionLedger: readonly Readonly<Record<string, unknown>>[] | 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 : [];
|
|
}
|
|
|
|
function rowObject(value: unknown): Readonly<Record<string, unknown>> | null {
|
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
? value as Readonly<Record<string, unknown>>
|
|
: null;
|
|
}
|
|
|
|
function parseConversationFocus(value: unknown): ConversationFocus | null {
|
|
const row = rowObject(value);
|
|
if (!row || typeof row.id !== "string" || typeof row.case_id !== "string"
|
|
|| typeof row.question_id !== "string" || typeof row.intent !== "string") return null;
|
|
const status = row.status;
|
|
if (status !== "active" && status !== "resolved" && status !== "declined"
|
|
&& status !== "skipped" && status !== "superseded") return null;
|
|
return {
|
|
id: row.id,
|
|
caseId: row.case_id,
|
|
questionId: row.question_id,
|
|
intent: row.intent,
|
|
targetEvidenceId: rowText(row.target_evidence_id),
|
|
targetDomain: rowText(row.target_domain),
|
|
targetKind: rowText(row.target_kind),
|
|
expectedAnswerSchema: rowObject(row.expected_answer_schema) ?? {},
|
|
status,
|
|
askedAt: String(row.asked_at ?? ""),
|
|
resolvedAt: rowText(row.resolved_at),
|
|
};
|
|
}
|
|
|
|
function parseConversationSummary(value: unknown): CaseConversationSummary {
|
|
const row = rowObject(value) ?? {};
|
|
return {
|
|
confirmedEvidenceSummary: rowArray(row.confirmed_evidence_summary).flatMap((item) => {
|
|
const parsed = rowObject(item);
|
|
return parsed ? [parsed] : [];
|
|
}),
|
|
pendingRevisions: rowArray(row.pending_revisions).flatMap((item) => {
|
|
const parsed = rowObject(item);
|
|
return parsed ? [parsed] : [];
|
|
}),
|
|
activeFocus: parseConversationFocus(row.active_focus),
|
|
declinedSkippedTopics: rowArray(row.declined_skipped_topics).flatMap((item) => {
|
|
const parsed = rowObject(item);
|
|
return parsed ? [parsed] : [];
|
|
}),
|
|
candidateDivergenceSummary: rowObject(row.candidate_divergence_summary),
|
|
missingEvidenceCategories: rowArray(row.missing_evidence_categories)
|
|
.filter((item): item is string => typeof item === "string"),
|
|
lastResultPolicy: rowObject(row.last_result_policy),
|
|
summaryVersion: rowNumber(row.summary_version) ?? 1,
|
|
updatedAt: String(row.updated_at ?? ""),
|
|
};
|
|
}
|
|
|
|
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 ?? ""),
|
|
dateSource: rowText(row.date_source),
|
|
dateReliability: rowText(row.date_reliability),
|
|
dateCorroboration: rowText(row.date_corroboration),
|
|
dateConflictStatus: rowText(row.date_conflict_status),
|
|
}];
|
|
});
|
|
|
|
const latestResult = parseV9CandidateSnapshot(root.latest_result);
|
|
const conversationSummary = parseConversationSummary(root.conversation_summary);
|
|
|
|
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,
|
|
conversationSummary,
|
|
latestResult,
|
|
};
|
|
}
|
|
|
|
export function parseV9CandidateSnapshot(value: unknown): V9CandidateSnapshot | null {
|
|
const row = rowObject(value);
|
|
const resultId = rowText(row?.result_id);
|
|
if (!row || !resultId || !Array.isArray(row.candidates) || row.candidates.length === 0) return null;
|
|
const candidates: V9Candidate[] = [];
|
|
const seenIds = new Set<string>();
|
|
for (const item of row.candidates) {
|
|
const candidate = rowObject(item);
|
|
const candidateId = rowText(candidate?.candidate_id);
|
|
const candidateTime = timeValue(candidate?.time);
|
|
const rank = rowNumber(candidate?.rank);
|
|
const relativeSupport = rowNumber(candidate?.relative_support);
|
|
const tiedMinuteCount = rowNumber(candidate?.tied_minute_count);
|
|
if (
|
|
!candidate || !candidateId || !uuidPattern.test(candidateId) || seenIds.has(candidateId)
|
|
|| !candidateTime
|
|
|| rank === null || !Number.isInteger(rank) || rank < 1
|
|
|| relativeSupport === null || !Number.isInteger(relativeSupport) || relativeSupport < 0 || relativeSupport > 100
|
|
|| tiedMinuteCount === null || !Number.isInteger(tiedMinuteCount) || tiedMinuteCount < 1
|
|
) return null;
|
|
seenIds.add(candidateId);
|
|
candidates.push({ candidateId, time: candidateTime, rank, relativeSupport, tiedMinuteCount });
|
|
}
|
|
const decisionReceipt = rowObject(row.decision_receipt);
|
|
const executionLedger = Array.isArray(row.execution_ledger)
|
|
&& row.execution_ledger.every((item) => rowObject(item) !== null)
|
|
? row.execution_ledger as readonly Readonly<Record<string, unknown>>[]
|
|
: null;
|
|
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),
|
|
eventContractVersion: rowText(row.event_contract_version),
|
|
policyVersion: rowText(row.decision_policy_version) ?? rowText(decisionReceipt?.policy_version),
|
|
decisionReceipt,
|
|
executionLedger,
|
|
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 V10RunAttemptStatus = "completed" | "failed" | "retryable" | "aborted";
|
|
|
|
export type V10RunAttemptClaim = Readonly<{
|
|
attemptId: string;
|
|
status: string;
|
|
shouldExecute: boolean;
|
|
alreadyInProgress: boolean;
|
|
}>;
|
|
|
|
export async function createV10RunAttempt(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
attemptNumber: number,
|
|
): Promise<V10RunAttemptClaim> {
|
|
const row = await rpc<{
|
|
attempt_id?: unknown;
|
|
status?: unknown;
|
|
should_execute?: unknown;
|
|
already_in_progress?: unknown;
|
|
}>(
|
|
accounting,
|
|
"create_agentic_rectification_run_attempt",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_turn_id: turnId,
|
|
p_attempt_number: attemptNumber,
|
|
},
|
|
);
|
|
const attemptId = rowText(row?.attempt_id);
|
|
const status = rowText(row?.status) ?? "started";
|
|
if (!attemptId) throw new RectificationToolServiceError("invalid_attempt_id");
|
|
const legacyIdempotent = rowBoolean((row as { idempotent?: unknown } | null)?.idempotent);
|
|
return {
|
|
attemptId,
|
|
status,
|
|
shouldExecute: row?.should_execute === undefined
|
|
? !legacyIdempotent
|
|
: rowBoolean(row.should_execute),
|
|
alreadyInProgress: row?.already_in_progress === undefined
|
|
? legacyIdempotent && status === "started"
|
|
: rowBoolean(row.already_in_progress),
|
|
};
|
|
}
|
|
|
|
export async function finalizeV10RunAttempt(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
attemptId: string,
|
|
status: V10RunAttemptStatus,
|
|
errorCode: string | null,
|
|
usage: Readonly<Record<string, unknown>>,
|
|
): Promise<void> {
|
|
await rpc<unknown>(
|
|
accounting,
|
|
"finalize_agentic_rectification_run_attempt",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_turn_id: turnId,
|
|
p_attempt_id: attemptId,
|
|
p_status: status,
|
|
p_error_code: errorCode,
|
|
p_usage: usage,
|
|
},
|
|
);
|
|
}
|
|
|
|
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[];
|
|
attemptId?: string | null;
|
|
},
|
|
): 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,
|
|
p_attempt_id: input.attemptId ?? null,
|
|
},
|
|
);
|
|
}
|
|
|
|
export async function insertV9RunPhase(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
turnId: string,
|
|
phase: string,
|
|
toolName: string | null,
|
|
sequence: number,
|
|
attemptId?: string | null,
|
|
): 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,
|
|
p_attempt_id: attemptId ?? null,
|
|
},
|
|
);
|
|
}
|
|
|
|
export type V9TurnReceipt = Readonly<{
|
|
turnId: string;
|
|
attemptId: string | null;
|
|
status: string;
|
|
skillName: string;
|
|
skillVersion: string;
|
|
engineVersion: string | null;
|
|
phases: readonly Readonly<{ phase: string; tool: string | null }>[];
|
|
toolActivities: readonly Readonly<{
|
|
tool: PublicRectificationTool;
|
|
status: "completed" | "failed";
|
|
methods: readonly PublicRectificationMethod[];
|
|
}>[];
|
|
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) }];
|
|
});
|
|
const toolActivities = rowArray(row.tool_activities)
|
|
.flatMap<V9TurnReceipt["toolActivities"][number]>((item) => {
|
|
const activity = rowObject(item);
|
|
if (!activity) return [];
|
|
const tool = activity.tool;
|
|
const status = activity.status;
|
|
if (!isPublicRectificationTool(tool) || (status !== "completed" && status !== "failed")) return [];
|
|
return [{
|
|
tool,
|
|
status,
|
|
methods: status === "completed"
|
|
? rowArray(activity.methods).filter(isPublicRectificationMethod)
|
|
: [],
|
|
}];
|
|
});
|
|
return {
|
|
turnId: row.turn_id,
|
|
attemptId: rowText(row.attempt_id),
|
|
status: String(row.status ?? ""),
|
|
skillName: String(row.skill_name ?? ""),
|
|
skillVersion: String(row.skill_version ?? ""),
|
|
engineVersion: rowText(row.engine_version),
|
|
phases,
|
|
toolActivities,
|
|
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 type ProposeEvidenceResult = Readonly<{
|
|
evidenceId: string | null;
|
|
idempotent: boolean;
|
|
outcome: "accepted" | "rejected";
|
|
errorCode: string | null;
|
|
status: string;
|
|
}>;
|
|
|
|
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<ProposeEvidenceResult> {
|
|
const row = await rpc<{
|
|
evidence_id?: unknown;
|
|
idempotent?: unknown;
|
|
outcome?: unknown;
|
|
error_code?: unknown;
|
|
status?: 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 errorCode = rowText(row?.error_code);
|
|
const evidenceId = typeof row?.evidence_id === "string" ? row.evidence_id : null;
|
|
if (errorCode || !evidenceId) {
|
|
return {
|
|
evidenceId: null,
|
|
idempotent: false,
|
|
outcome: "rejected",
|
|
errorCode: errorCode ?? "invalid_item",
|
|
status: String(row?.status ?? "rejected"),
|
|
};
|
|
}
|
|
return {
|
|
evidenceId,
|
|
idempotent: row?.idempotent === true,
|
|
outcome: "accepted",
|
|
errorCode: null,
|
|
status: String(row?.status ?? "draft"),
|
|
};
|
|
}
|
|
|
|
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 setV10ConversationFocus(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
questionId: string;
|
|
intent: string;
|
|
targetEvidenceId?: string | null;
|
|
targetDomain?: string | null;
|
|
targetKind?: string | null;
|
|
expectedAnswerSchema?: Readonly<Record<string, unknown>>;
|
|
},
|
|
): Promise<Readonly<{ focus: ConversationFocus; idempotent: boolean }>> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"set_agentic_rectification_conversation_focus",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_question_id: input.questionId,
|
|
p_intent: input.intent,
|
|
p_target_evidence_id: input.targetEvidenceId ?? null,
|
|
p_target_domain: input.targetDomain ?? null,
|
|
p_target_kind: input.targetKind ?? null,
|
|
p_expected_answer_schema: input.expectedAnswerSchema ?? {},
|
|
},
|
|
);
|
|
const focus = parseConversationFocus(row.focus ?? row);
|
|
if (!focus) throw new RectificationToolServiceError("invalid_focus");
|
|
return { focus, idempotent: row.idempotent === true };
|
|
}
|
|
|
|
export async function resolveV10ConversationFocus(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
focusId: string;
|
|
status: "resolved" | "declined" | "skipped";
|
|
evidenceId?: string | null;
|
|
},
|
|
): Promise<Readonly<{ focusId: string; status: string; evidenceId: string | null; idempotent: boolean }>> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"resolve_agentic_rectification_conversation_focus",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_focus_id: input.focusId,
|
|
p_status: input.status,
|
|
p_evidence_id: input.evidenceId ?? null,
|
|
},
|
|
);
|
|
const focusId = rowText(row.focus_id);
|
|
if (!focusId) throw new RectificationToolServiceError("invalid_focus");
|
|
return {
|
|
focusId,
|
|
status: String(row.status ?? input.status),
|
|
evidenceId: rowText(row.evidence_id),
|
|
idempotent: row.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export async function confirmV10Evidence(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
focusId: string | null,
|
|
evidenceId: string,
|
|
): Promise<Readonly<{ focusId: string | null; evidenceId: string; status: string; idempotent: boolean }>> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"confirm_agentic_rectification_evidence_v10",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_focus_id: focusId,
|
|
p_evidence_id: evidenceId,
|
|
},
|
|
);
|
|
const confirmedId = rowText(row.evidence_id);
|
|
if (!confirmedId) throw new RectificationToolServiceError("invalid_evidence_id");
|
|
return {
|
|
focusId: rowText(row.focus_id),
|
|
evidenceId: confirmedId,
|
|
status: String(row.status ?? "confirmed"),
|
|
idempotent: row.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export async function reviseV10Evidence(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
focusId: string;
|
|
evidenceId: string;
|
|
quote: string;
|
|
occurredFrom: string | null;
|
|
occurredTo: string | null;
|
|
datePrecision: string;
|
|
summary: string;
|
|
},
|
|
): Promise<Readonly<{ focusId: string; evidenceId: string; supersedesEvidenceId: string; idempotent: boolean }>> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"revise_agentic_rectification_evidence_v10",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_focus_id: input.focusId,
|
|
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 = rowText(row.evidence_id);
|
|
const supersedesEvidenceId = rowText(row.supersedes_evidence_id);
|
|
if (!evidenceId || !supersedesEvidenceId) {
|
|
throw new RectificationToolServiceError("invalid_evidence_id");
|
|
}
|
|
return {
|
|
focusId: rowText(row.focus_id) ?? input.focusId,
|
|
evidenceId,
|
|
supersedesEvidenceId,
|
|
idempotent: row.idempotent === true,
|
|
};
|
|
}
|
|
|
|
export type V10EvidenceBatchItem = Readonly<{
|
|
quote: string;
|
|
subject: "self" | "family" | "other";
|
|
eventKind: EvidenceKind;
|
|
domain: string;
|
|
occurredFrom: string | null;
|
|
occurredTo: string | null;
|
|
datePrecision: string;
|
|
summary: string;
|
|
}>;
|
|
|
|
export type V10EvidenceBatchResult = Readonly<{
|
|
items: readonly Readonly<{
|
|
index: number;
|
|
outcome: "accepted" | "needs_clarification" | "rejected";
|
|
evidenceId: string | null;
|
|
status: string;
|
|
idempotent: boolean;
|
|
clarificationFields: readonly string[];
|
|
errorCode: string | null;
|
|
}>[];
|
|
acceptedCount: number;
|
|
needsClarificationCount: number;
|
|
rejectedCount: number;
|
|
focusId: string | null;
|
|
}>;
|
|
|
|
export async function recordV10EvidenceBatch(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
sourceTurnId: string,
|
|
focusId: string | null,
|
|
items: readonly V10EvidenceBatchItem[],
|
|
): Promise<V10EvidenceBatchResult> {
|
|
const rpcItems = items.map((item) => ({
|
|
idempotency_key: hashParts([
|
|
"v10-evidence-item-v1",
|
|
caseId,
|
|
sourceTurnId,
|
|
JSON.stringify({
|
|
quote: item.quote,
|
|
subject: item.subject,
|
|
event_kind: item.eventKind,
|
|
domain: item.domain,
|
|
occurred_from: item.occurredFrom,
|
|
occurred_to: item.occurredTo,
|
|
date_precision: item.datePrecision,
|
|
summary: item.summary,
|
|
}),
|
|
]),
|
|
quote: item.quote,
|
|
subject: item.subject,
|
|
event_kind: item.eventKind,
|
|
domain: item.domain,
|
|
occurred_from: item.occurredFrom,
|
|
occurred_to: item.occurredTo,
|
|
date_precision: item.datePrecision,
|
|
summary: item.summary,
|
|
}));
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"record_agentic_rectification_evidence_batch",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_source_turn_id: sourceTurnId,
|
|
p_focus_id: focusId,
|
|
p_items: rpcItems,
|
|
},
|
|
);
|
|
const results = rowArray(row.items).flatMap((value) => {
|
|
const item = rowObject(value);
|
|
if (!item) return [];
|
|
const outcome = item.outcome;
|
|
if (outcome !== "accepted" && outcome !== "needs_clarification" && outcome !== "rejected") return [];
|
|
return [{
|
|
index: rowNumber(item.index) ?? 0,
|
|
outcome: outcome as V10EvidenceBatchResult["items"][number]["outcome"],
|
|
evidenceId: rowText(item.evidence_id),
|
|
status: String(item.status ?? ""),
|
|
idempotent: item.idempotent === true,
|
|
clarificationFields: rowArray(item.clarification_fields)
|
|
.filter((field): field is string => typeof field === "string"),
|
|
errorCode: rowText(item.error_code),
|
|
}];
|
|
});
|
|
return {
|
|
items: results,
|
|
acceptedCount: rowNumber(row.accepted_count) ?? results.filter((item) => item.outcome === "accepted").length,
|
|
needsClarificationCount: rowNumber(row.needs_clarification_count)
|
|
?? results.filter((item) => item.outcome === "needs_clarification").length,
|
|
rejectedCount: rowNumber(row.rejected_count) ?? results.filter((item) => item.outcome === "rejected").length,
|
|
focusId: rowText(row.focus_id),
|
|
};
|
|
}
|
|
|
|
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";
|
|
selectionAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
representativeTime: string | null;
|
|
algorithmVersion: string | null;
|
|
eventContractVersion: string | null;
|
|
policyVersion: string | null;
|
|
decisionReceipt: Readonly<Record<string, unknown>>;
|
|
executionLedger: readonly Readonly<Record<string, unknown>>[];
|
|
}>;
|
|
|
|
export async function persistV9Candidate(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: {
|
|
engineResultId: string;
|
|
algorithmVersion: string;
|
|
evidenceFingerprint: string;
|
|
rangeFingerprint: string;
|
|
skillVersion: string;
|
|
eventContractVersion: string;
|
|
policyVersion: string;
|
|
candidateRange: { start_time: string; end_time: string };
|
|
candidates: V9CandidateSnapshot["candidates"];
|
|
decisionReceipt: Readonly<Record<string, unknown>>;
|
|
executionLedger: readonly Readonly<Record<string, unknown>>[];
|
|
},
|
|
): Promise<V9PersistedCandidate> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"persist_agentic_rectification_candidate_v2",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_engine_result_id: input.engineResultId,
|
|
p_evidence_ledger_fingerprint: input.evidenceFingerprint,
|
|
p_candidate_range_fingerprint: input.rangeFingerprint,
|
|
p_skill_version: input.skillVersion,
|
|
p_algorithm_version: input.algorithmVersion,
|
|
p_event_contract_version: input.eventContractVersion,
|
|
p_decision_policy_version: input.policyVersion,
|
|
p_candidate_range: input.candidateRange,
|
|
p_candidates: input.candidates.map((candidate) => ({
|
|
candidate_id: candidate.candidateId,
|
|
time: candidate.time,
|
|
rank: candidate.rank,
|
|
relative_support: candidate.relativeSupport,
|
|
tied_minute_count: candidate.tiedMinuteCount,
|
|
})),
|
|
p_decision_receipt: input.decisionReceipt,
|
|
p_execution_ledger: input.executionLedger,
|
|
},
|
|
);
|
|
const snapshot = parseV9CandidateSnapshot(row);
|
|
if (!snapshot) throw new RectificationToolServiceError("invalid_candidate_result");
|
|
return {
|
|
resultId: snapshot.resultId,
|
|
cached: row.cached === true,
|
|
candidates: snapshot.candidates,
|
|
overallConfidence: snapshot.overallConfidence,
|
|
selectionAllowed: snapshot.selectionAllowed,
|
|
confirmationAllowed: snapshot.confirmationAllowed,
|
|
representativeTime: snapshot.representativeTime,
|
|
algorithmVersion: snapshot.algorithmVersion ?? input.algorithmVersion,
|
|
eventContractVersion: snapshot.eventContractVersion ?? input.eventContractVersion,
|
|
policyVersion: snapshot.policyVersion ?? input.policyVersion,
|
|
decisionReceipt: snapshot.decisionReceipt ?? input.decisionReceipt,
|
|
executionLedger: snapshot.executionLedger ?? input.executionLedger,
|
|
};
|
|
}
|
|
|
|
function parseTransitionSnapshot(value: unknown): InferenceTransitionSnapshot | null {
|
|
const row = rowObject(value);
|
|
const inference = asInferenceState(row?.inference_state);
|
|
const resultId = rowText(row?.result_id);
|
|
const fingerprint = rowText(row?.decision_state_fingerprint);
|
|
const revision = rowNumber(row?.revision);
|
|
if (!row || !inference || !resultId || !fingerprint || revision === null) return null;
|
|
return {
|
|
id: rowText(row.id) ?? undefined,
|
|
resultId,
|
|
revision,
|
|
probeId: rowText(row.probe_id),
|
|
reason: String(row.reason ?? "choice"),
|
|
idempotent: row.idempotent === true,
|
|
decisionStateFingerprint: fingerprint,
|
|
inferenceState: inference,
|
|
posteriorBefore: rowObject(row.posterior_before) as Record<string, number> ?? {},
|
|
posteriorAfter: rowObject(row.posterior_after) as Record<string, number> ?? {},
|
|
scoreDeltas: rowObject(row.score_deltas) as Record<string, number> ?? {},
|
|
};
|
|
}
|
|
|
|
export async function loadLatestInferenceTransition(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
): Promise<InferenceTransitionSnapshot | null> {
|
|
const row = await rpc<unknown>(
|
|
accounting,
|
|
"get_agentic_rectification_latest_inference_transition",
|
|
{ p_user_id: userId, p_case_id: caseId },
|
|
);
|
|
return parseTransitionSnapshot(row);
|
|
}
|
|
|
|
export type PersistInferenceTransitionInput = Readonly<{
|
|
expectedRevision: number;
|
|
probeId: string;
|
|
openProbeId: string;
|
|
semanticKey: string;
|
|
candidateSplitHash: string;
|
|
answerClass: string;
|
|
rawAnswer: string;
|
|
inferenceState: Readonly<Record<string, unknown>>;
|
|
posteriorBefore: Readonly<Record<string, number>>;
|
|
posteriorAfter: Readonly<Record<string, number>>;
|
|
scoreDeltas: Readonly<Record<string, number>>;
|
|
decisionStateFingerprint: string;
|
|
reason: "choice" | "supersede" | "already_answered";
|
|
idempotencyKey: string;
|
|
candidateSetId: string;
|
|
}>;
|
|
|
|
export async function persistV9InferenceState(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
input: PersistInferenceTransitionInput,
|
|
): Promise<Readonly<{
|
|
resultId: string;
|
|
revision: number;
|
|
idempotent: boolean;
|
|
decisionReceipt: Readonly<Record<string, unknown>>;
|
|
decisionStateFingerprint: string;
|
|
}>> {
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"append_agentic_rectification_inference_transition",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_expected_revision: input.expectedRevision,
|
|
p_probe_id: input.probeId,
|
|
p_open_probe_id: input.openProbeId,
|
|
p_semantic_key: input.semanticKey,
|
|
p_candidate_split_hash: input.candidateSplitHash,
|
|
p_answer_class: input.answerClass,
|
|
p_raw_answer: input.rawAnswer,
|
|
p_inference_state: input.inferenceState,
|
|
p_posterior_before: input.posteriorBefore,
|
|
p_posterior_after: input.posteriorAfter,
|
|
p_score_deltas: input.scoreDeltas,
|
|
p_decision_state_fingerprint: input.decisionStateFingerprint,
|
|
p_reason: input.reason,
|
|
p_idempotency_key: input.idempotencyKey,
|
|
p_candidate_set_id: input.candidateSetId,
|
|
},
|
|
);
|
|
const resultId = rowText(row.result_id);
|
|
const decisionReceipt = rowObject(row.decision_receipt);
|
|
const revision = rowNumber(row.revision);
|
|
const fingerprint = rowText(row.decision_state_fingerprint) ?? input.decisionStateFingerprint;
|
|
if (!resultId || !decisionReceipt || revision === null) {
|
|
throw new RectificationToolServiceError("invalid_inference_transition");
|
|
}
|
|
return {
|
|
resultId,
|
|
revision,
|
|
idempotent: row.idempotent === true,
|
|
decisionReceipt,
|
|
decisionStateFingerprint: fingerprint,
|
|
};
|
|
}
|
|
|
|
export function inferenceFingerprintForState(
|
|
caseId: string,
|
|
evidenceLedgerFingerprint: string,
|
|
state: { candidate_set_id: string; revision: number; answered_probes: readonly { probe_id: string }[] },
|
|
scoringPolicyVersion = INFERENCE_ALGORITHM_VERSION,
|
|
): string {
|
|
return decisionStateFingerprint({
|
|
caseId,
|
|
evidenceLedgerFingerprint,
|
|
candidateSetId: state.candidate_set_id,
|
|
inferenceRevision: state.revision,
|
|
answeredProbeIds: state.answered_probes.map((item) => item.probe_id),
|
|
scoringPolicyVersion,
|
|
});
|
|
}
|
|
|
|
export type V9AcceptResult = Readonly<{
|
|
success: boolean;
|
|
savedTime: string;
|
|
status: "accepted";
|
|
resultId: string;
|
|
caseStatus: string;
|
|
idempotent: boolean;
|
|
}>;
|
|
|
|
export async function acceptV9Candidate(
|
|
accounting: AccountingClient,
|
|
userId: string,
|
|
caseId: string,
|
|
resultId: string,
|
|
candidateId: string,
|
|
requestId: string,
|
|
): Promise<V9AcceptResult> {
|
|
if (!uuidPattern.test(candidateId) || !uuidPattern.test(requestId)) {
|
|
throw new RectificationToolServiceError("invalid_candidate_ref");
|
|
}
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"accept_agentic_rectification_candidate_for_case_v2",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_result_id: resultId,
|
|
p_candidate_id: candidateId,
|
|
p_request_id: requestId,
|
|
},
|
|
);
|
|
const savedTime = timeValue(row.saved_time);
|
|
if (row.success !== true || row.status !== "accepted" || !savedTime) {
|
|
throw new RectificationToolServiceError("invalid_accept_result");
|
|
}
|
|
return {
|
|
success: true,
|
|
savedTime,
|
|
status: "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;
|
|
candidateId: string;
|
|
requestId: string;
|
|
consentQuote: string;
|
|
sourceTurnId: string;
|
|
},
|
|
): Promise<V9ConfirmResult> {
|
|
if (!uuidPattern.test(input.candidateId) || !uuidPattern.test(input.requestId)) {
|
|
throw new RectificationToolServiceError("invalid_candidate_ref");
|
|
}
|
|
const row = await rpc<Record<string, unknown>>(
|
|
accounting,
|
|
"confirm_agentic_rectification_candidate_for_case_v2",
|
|
{
|
|
p_user_id: userId,
|
|
p_case_id: caseId,
|
|
p_result_id: input.resultId,
|
|
p_candidate_id: input.candidateId,
|
|
p_request_id: input.requestId,
|
|
p_consent_quote: input.consentQuote,
|
|
p_source_turn_id: input.sourceTurnId,
|
|
},
|
|
);
|
|
const savedTime = timeValue(row.saved_time);
|
|
if (row.success !== true || row.status !== "confirmed" || !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",
|
|
"legacy_skill_identity_unverifiable",
|
|
"skill_identity_missing",
|
|
"skill_identity_mismatch",
|
|
"invalid_skill_identity",
|
|
"skill_version_mismatch",
|
|
"turn_not_found",
|
|
"focus_not_found",
|
|
"focus_not_active",
|
|
"focus_target_mismatch",
|
|
"focus_idempotency_conflict",
|
|
"attempt_not_found",
|
|
"attempt_already_finalized",
|
|
"attempt_not_successful",
|
|
"idempotency_conflict",
|
|
"invalid_input",
|
|
"invalid_choice_copy",
|
|
"precision_downgrade",
|
|
"offer_not_allowed",
|
|
"no_candidate_result",
|
|
"stale_probe",
|
|
"revision_conflict",
|
|
"inference_patch_retired",
|
|
];
|
|
if (error instanceof RectificationToolServiceError && known.includes(error.code)) {
|
|
return error.code;
|
|
}
|
|
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;
|