feat(rectification): add v9 skill and domain contracts

This commit is contained in:
Jesse
2026-08-11 16:18:49 +08:00
parent 6d7a9a97be
commit 60e2ce4fa4
11 changed files with 833 additions and 0 deletions
@@ -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;