feat(rectification): add v9 skill and domain contracts
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* V9 rectification Case state machine contracts.
|
||||
*
|
||||
* The server owns the authoritative Case status. The browser and the agent
|
||||
* never derive status from message counts, candidate existence or session
|
||||
* ordering.
|
||||
*/
|
||||
|
||||
export const RECTIFICATION_CASE_STATUSES = [
|
||||
"draft",
|
||||
"collecting_evidence",
|
||||
"candidate_ready",
|
||||
"candidate_accepted",
|
||||
"needs_rebaseline",
|
||||
"paused",
|
||||
"confirmed",
|
||||
"closed",
|
||||
"abandoned",
|
||||
"superseded",
|
||||
] as const;
|
||||
|
||||
export type RectificationCaseStatus =
|
||||
(typeof RECTIFICATION_CASE_STATUSES)[number];
|
||||
|
||||
/** Statuses that may be resumed by the user. */
|
||||
export const RESUMABLE_CASE_STATUSES: readonly RectificationCaseStatus[] = [
|
||||
"draft",
|
||||
"collecting_evidence",
|
||||
"candidate_ready",
|
||||
"candidate_accepted",
|
||||
"needs_rebaseline",
|
||||
"paused",
|
||||
];
|
||||
|
||||
/** Statuses that only allow read-only history access. */
|
||||
export const TERMINAL_CASE_STATUSES: readonly RectificationCaseStatus[] = [
|
||||
"confirmed",
|
||||
"closed",
|
||||
"abandoned",
|
||||
"superseded",
|
||||
];
|
||||
|
||||
const RESUMABLE_SET = new Set<string>(RESUMABLE_CASE_STATUSES);
|
||||
const TERMINAL_SET = new Set<string>(TERMINAL_CASE_STATUSES);
|
||||
|
||||
export function isRectificationCaseStatus(
|
||||
value: unknown,
|
||||
): value is RectificationCaseStatus {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
(RESUMABLE_SET.has(value) || TERMINAL_SET.has(value))
|
||||
);
|
||||
}
|
||||
|
||||
export function isResumableStatus(
|
||||
status: RectificationCaseStatus,
|
||||
): boolean {
|
||||
return RESUMABLE_SET.has(status);
|
||||
}
|
||||
|
||||
export function isTerminalStatus(status: RectificationCaseStatus): boolean {
|
||||
return TERMINAL_SET.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legal terminal transitions. A resumable case may move to any terminal
|
||||
* status; terminal statuses are immutable.
|
||||
*/
|
||||
export function canTransitToTerminal(
|
||||
from: RectificationCaseStatus,
|
||||
to: RectificationCaseStatus,
|
||||
): boolean {
|
||||
if (isTerminalStatus(from)) return false;
|
||||
return isTerminalStatus(to);
|
||||
}
|
||||
|
||||
/** statuses that reject evidence/turn writes. Terminal cases are read-only. */
|
||||
export function evidenceWritesAllowed(
|
||||
status: RectificationCaseStatus,
|
||||
): boolean {
|
||||
return isResumableStatus(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* The one-resumable-case-per-user invariant, mirrored from the database
|
||||
* partial unique index. The database is the enforcement point; this is the
|
||||
* contract the service layer relies on.
|
||||
*/
|
||||
export const MAX_RESUMABLE_CASES_PER_USER = 1;
|
||||
|
||||
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
|
||||
export const RECTIFICATION_SKILL_VERSION = "9.0.0";
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* V9 evidence model contracts: event kinds, domains, date precision and the
|
||||
* append-only revision/status machine. IDs are always generated by the server.
|
||||
*/
|
||||
|
||||
export const EVIDENCE_KINDS = [
|
||||
"education_start",
|
||||
"education_completion",
|
||||
"education_interruption",
|
||||
"career_entry",
|
||||
"career_change",
|
||||
"promotion",
|
||||
"career_pressure",
|
||||
"career_exit",
|
||||
"relationship_start",
|
||||
"relationship_commitment",
|
||||
"relationship_separation",
|
||||
"relocation",
|
||||
"finance_gain",
|
||||
"finance_loss",
|
||||
"self_health_event",
|
||||
"family_event",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type EvidenceKind = (typeof EVIDENCE_KINDS)[number];
|
||||
|
||||
export const EVIDENCE_DOMAINS = [
|
||||
"education",
|
||||
"career",
|
||||
"relationship",
|
||||
"relocation",
|
||||
"finance",
|
||||
"health",
|
||||
"family",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type EvidenceDomain = (typeof EVIDENCE_DOMAINS)[number];
|
||||
|
||||
export const DATE_PRECISIONS = [
|
||||
"year",
|
||||
"month",
|
||||
"day",
|
||||
"range",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
export type DatePrecision = (typeof DATE_PRECISIONS)[number];
|
||||
|
||||
export const EVIDENCE_STATUSES = [
|
||||
"draft",
|
||||
"pending_confirmation",
|
||||
"confirmed",
|
||||
"superseded",
|
||||
"rejected",
|
||||
] as const;
|
||||
|
||||
export type EvidenceStatus = (typeof EVIDENCE_STATUSES)[number];
|
||||
|
||||
const KIND_SET = new Set<string>(EVIDENCE_KINDS);
|
||||
const DOMAIN_SET = new Set<string>(EVIDENCE_DOMAINS);
|
||||
const PRECISION_SET = new Set<string>(DATE_PRECISIONS);
|
||||
const STATUS_SET = new Set<string>(EVIDENCE_STATUSES);
|
||||
|
||||
export function isEvidenceKind(value: unknown): value is EvidenceKind {
|
||||
return typeof value === "string" && KIND_SET.has(value);
|
||||
}
|
||||
|
||||
export function isEvidenceDomain(value: unknown): value is EvidenceDomain {
|
||||
return typeof value === "string" && DOMAIN_SET.has(value);
|
||||
}
|
||||
|
||||
export function isDatePrecision(value: unknown): value is DatePrecision {
|
||||
return typeof value === "string" && PRECISION_SET.has(value);
|
||||
}
|
||||
|
||||
export function isEvidenceStatus(value: unknown): value is EvidenceStatus {
|
||||
return typeof value === "string" && STATUS_SET.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kinds that are semantically distinct and must never be folded together.
|
||||
* The scoring path keys on event_kind, so collapsing these would corrupt
|
||||
* both the ledger and the candidate contrast.
|
||||
*/
|
||||
export const DISTINCT_KIND_GROUPS: readonly (readonly EvidenceKind[])[] = [
|
||||
["career_entry", "career_pressure", "career_exit"],
|
||||
["relationship_start", "relationship_commitment", "relationship_separation"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Legal evidence status transitions. Only the server confirmation path may
|
||||
* produce `confirmed`; an agent may only ever create `draft` rows.
|
||||
*/
|
||||
export const EVIDENCE_STATUS_TRANSITIONS: Readonly<
|
||||
Record<EvidenceStatus, readonly EvidenceStatus[]>
|
||||
> = {
|
||||
draft: ["pending_confirmation", "rejected", "superseded"],
|
||||
pending_confirmation: ["confirmed", "rejected", "superseded"],
|
||||
confirmed: ["superseded"],
|
||||
superseded: [],
|
||||
rejected: [],
|
||||
};
|
||||
|
||||
export function canTransitEvidenceStatus(
|
||||
from: EvidenceStatus,
|
||||
to: EvidenceStatus,
|
||||
): boolean {
|
||||
return EVIDENCE_STATUS_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized quote grounding contract: a user_quote is accepted only when it
|
||||
* is a substring match after whitespace/punctuation normalization of the
|
||||
* source turn's user message.
|
||||
*/
|
||||
export function normalizeQuote(value: string): string {
|
||||
return value.replace(/[\s\u3000,。!?、;:“”‘’()《》·—…]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function quoteIsGroundedInMessage(
|
||||
userMessage: string,
|
||||
quote: string,
|
||||
): boolean {
|
||||
const normalizedMessage = normalizeQuote(userMessage);
|
||||
const normalizedQuote = normalizeQuote(quote);
|
||||
return (
|
||||
normalizedQuote.length > 0 &&
|
||||
normalizedMessage.includes(normalizedQuote)
|
||||
);
|
||||
}
|
||||
|
||||
/** Background kinds that never advance scoring coverage counts. */
|
||||
export const BACKGROUND_ONLY_KINDS: ReadonlySet<EvidenceKind> = new Set([
|
||||
"family_event",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export function isBackgroundEvidenceKind(kind: EvidenceKind): boolean {
|
||||
return BACKGROUND_ONLY_KINDS.has(kind);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* V9 rectification domain contracts.
|
||||
*
|
||||
* Server-owned truth: Case state machine, evidence model, public receipt
|
||||
* allowlists and open request/response schemas. These contracts must not be
|
||||
* mixed with the legacy rectification state machine.
|
||||
*/
|
||||
export * from "./case-status";
|
||||
export * from "./evidence-model";
|
||||
export * from "./public-receipt";
|
||||
export * from "./open-request";
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* V9 public execution receipts and activity event allowlists.
|
||||
*
|
||||
* The web client may only ever see allowlisted phases, tool names and
|
||||
* activity events. Reasoning, raw tool payloads, internal scores, birth data
|
||||
* and provider metadata must never reach the client.
|
||||
*/
|
||||
|
||||
export const PUBLIC_RECTIFICATION_PHASES = [
|
||||
"run.started",
|
||||
"skill.started",
|
||||
"skill.loaded",
|
||||
"case.loaded",
|
||||
"evidence.proposed",
|
||||
"evidence.confirmed",
|
||||
"candidates.comparing",
|
||||
"candidates.updated",
|
||||
"diagnostics.completed",
|
||||
"candidate.accepted",
|
||||
"birth_time.confirmed",
|
||||
"answer.delta",
|
||||
"run.completed",
|
||||
"run.failed",
|
||||
] as const;
|
||||
|
||||
export type PublicRectificationPhase =
|
||||
(typeof PUBLIC_RECTIFICATION_PHASES)[number];
|
||||
|
||||
export const PUBLIC_RECTIFICATION_TOOLS = [
|
||||
"rectification-read-case",
|
||||
"rectification-propose-evidence",
|
||||
"rectification-confirm-evidence",
|
||||
"rectification-revise-evidence",
|
||||
"rectification-compare-candidates",
|
||||
"rectification-read-diagnostics",
|
||||
"rectification-offer-candidates",
|
||||
"rectification-accept-candidate",
|
||||
"rectification-confirm-birth-time",
|
||||
"rectification-close-case",
|
||||
] as const;
|
||||
|
||||
export type PublicRectificationTool =
|
||||
(typeof PUBLIC_RECTIFICATION_TOOLS)[number];
|
||||
|
||||
export const RECEIPT_STATUSES = [
|
||||
"completed",
|
||||
"degraded",
|
||||
"blocked",
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
export type RectificationReceiptStatus =
|
||||
(typeof RECEIPT_STATUSES)[number];
|
||||
|
||||
export type RectificationExecutionReceipt = Readonly<{
|
||||
turnId: string;
|
||||
skillName: string;
|
||||
skillVersion: string;
|
||||
engineVersion: string | null;
|
||||
phases: readonly PublicRectificationPhase[];
|
||||
toolsUsed: readonly PublicRectificationTool[];
|
||||
status: RectificationReceiptStatus;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}>;
|
||||
|
||||
const PHASE_SET = new Set<string>(PUBLIC_RECTIFICATION_PHASES);
|
||||
const TOOL_SET = new Set<string>(PUBLIC_RECTIFICATION_TOOLS);
|
||||
|
||||
export function isPublicRectificationPhase(
|
||||
value: unknown,
|
||||
): value is PublicRectificationPhase {
|
||||
return typeof value === "string" && PHASE_SET.has(value);
|
||||
}
|
||||
|
||||
export function isPublicRectificationTool(
|
||||
value: unknown,
|
||||
): value is PublicRectificationTool {
|
||||
return typeof value === "string" && TOOL_SET.has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact NDJSON activity stream events the API is allowed to emit.
|
||||
* Anything not in this list must be dropped before it reaches the browser.
|
||||
*/
|
||||
export const PUBLIC_ACTIVITY_EVENTS = PUBLIC_RECTIFICATION_PHASES;
|
||||
|
||||
export function safeActivityEvent(
|
||||
value: unknown,
|
||||
): PublicRectificationPhase | null {
|
||||
return isPublicRectificationPhase(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Denied content that must never appear in any public projection.
|
||||
* The working candidate range is user-visible (BUG-074), but the baseline
|
||||
* birth snapshot, raw scores and internal identifiers are not.
|
||||
*/
|
||||
export const DENIED_PUBLIC_CONTENT = [
|
||||
"chain-of-thought",
|
||||
"reasoning",
|
||||
"tool payload",
|
||||
"baseline birth snapshot",
|
||||
"user_id",
|
||||
"engine score",
|
||||
] as const;
|
||||
Reference in New Issue
Block a user