/** * V9 case open request/response contracts. * * The browser submits only an intent + idempotency key (+ exact sessionId for * the session intent). The server owns profile normalization, baseline * snapshot/fingerprint generation, candidate range derivation and every * disposition decision. */ import { z } from "zod"; import { isRectificationCaseStatus, type RectificationCaseStatus, } from "./case-status"; export const OPEN_RECTIFICATION_INTENTS = [ "homepage", "session", "new", ] as const; export type OpenRectificationIntent = (typeof OPEN_RECTIFICATION_INTENTS)[number]; export const openRectificationCaseRequestSchema = z.discriminatedUnion( "intent", [ z .object({ intent: z.literal("homepage"), requestId: z.string().uuid(), }) .strict(), z .object({ intent: z.literal("session"), requestId: z.string().uuid(), sessionId: z.string().uuid(), }) .strict(), z .object({ intent: z.literal("new"), requestId: z.string().uuid(), supersedeActive: z.literal(false).optional(), }) .strict(), ], ); export type OpenRectificationCaseRequest = z.infer< typeof openRectificationCaseRequestSchema >; export const openRectificationDispositions = [ "created", "resumed", "readonly", ] as const; export type OpenRectificationDisposition = (typeof openRectificationDispositions)[number]; export type OpenRectificationCaseResponse = Readonly<{ disposition: OpenRectificationDisposition; caseId: string; sessionId: string; status: RectificationCaseStatus; shouldStartOpening: boolean; skillVersion: string; }>; /** Entry-summary contract used by the homepage card. */ export type RectificationEntrySummary = Readonly<{ hasResumableCase: boolean; hasTerminalCaseWithTime: boolean; latestResumable: Readonly<{ caseId: string; status: RectificationCaseStatus; lastActivityAt: string; }> | null; latestTerminal: Readonly<{ caseId: string; status: RectificationCaseStatus; hasUsableTime: boolean; }> | null; }>; export function parseOpenRectificationCaseRequest( value: unknown, ): OpenRectificationCaseRequest | null { const parsed = openRectificationCaseRequestSchema.safeParse(value); return parsed.success ? parsed.data : null; } export function isOpenRectificationDisposition( value: unknown, ): value is OpenRectificationDisposition { return ( typeof value === "string" && (openRectificationDispositions as readonly string[]).includes(value) ); } /** * shouldStartOpening is server-owned: true only for a freshly created case * that has never started (no turns). Resumed cases and readonly history must * never auto-generate an opening. */ export function shouldStartOpening( disposition: OpenRectificationDisposition, turnCount: number, ): boolean { if (disposition !== "created") return false; return turnCount === 0; } export function openResponse( value: unknown, ): OpenRectificationCaseResponse | null { if (!value || typeof value !== "object") return null; const row = value as Record; const caseId = typeof row.case_id === "string" ? row.case_id : ""; const sessionId = typeof row.session_id === "string" ? row.session_id : ""; const skillVersion = typeof row.skill_version === "string" ? row.skill_version : ""; if ( !caseId || !sessionId || !skillVersion || !isOpenRectificationDisposition(row.disposition) || !isRectificationCaseStatus(row.status) ) { return null; } return { disposition: row.disposition, caseId, sessionId, status: row.status, shouldStartOpening: row.should_start_opening === true, skillVersion, }; } /** Profile fields the server requires before a case may be opened. */ export const PROFILE_COMPLETENESS_REQUIREMENTS = [ "birth_date", "latitude", "longitude", "timezone_offset", "birth_time_source", ] as const;