feat(report): add personal report contract and persistence

This commit is contained in:
Jesse
2026-08-06 12:43:10 +08:00
parent 03d3b58ff7
commit 82dab96b07
14 changed files with 3796 additions and 0 deletions
@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";
import {
canonicalEvidence,
safeParseReportDocument,
ReportDocumentValidationError,
type EvidenceAppendix,
type ReportDocumentParseResult,
type ReportDocumentV1,
} from "./personal-report-contract.ts";
/**
* Server hash core for ReportDocument v1.
*
* Pure Node implementation (node:crypto) without the server-only marker so
* tests can import it directly; the production entry
* personal-report-contract.server.ts adds `import "server-only"` and
* re-exports this module. Client bundles must never import this file: besides
* the marker on the production entry, node:crypto fails Next.js client builds.
*
* Flow per the architecture ruling: canonical isomorphic parse first, then
* verify provenance.evidenceHash against the recomputed hash. The hash is
* never trusted as a model self-report. The Python validator
* (scripts/personal_report_contract.py) performs the same recomputation.
*/
export function computeEvidenceHash(appendix: EvidenceAppendix): string {
const canonical = canonicalEvidence(appendix);
return createHash("sha256").update(JSON.stringify(canonical), "utf8").digest("hex");
}
export function safeParseServerReportDocument(input: unknown): ReportDocumentParseResult {
const parsed = safeParseReportDocument(input);
if (!parsed.ok) return parsed;
const recomputed = computeEvidenceHash(parsed.document.evidenceAppendix);
if (parsed.document.provenance.evidenceHash !== recomputed) {
return {
ok: false,
errors: [{
path: "provenance.evidenceHash",
message: `does not match recomputed evidence hash ${recomputed}`,
}],
};
}
return parsed;
}
export function parseServerReportDocument(input: unknown): ReportDocumentV1 {
const result = safeParseServerReportDocument(input);
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
return result.document;
}
@@ -0,0 +1,3 @@
import "server-only";
export * from "./personal-report-contract.server-core";
@@ -0,0 +1,413 @@
/**
* ReportDocument v1 contract (isomorphic Zod side).
*
* This file is importable from server and client bundles: it contains no
* node:crypto and no hash recomputation. Semantics are shared with:
* - contracts/personal-report/report-document.v1.schema.json (JSON Schema)
* - scripts/personal_report_contract.py (stdlib Python validator)
* - frontend/src/lib/personal-report-contract.server-core.ts (server hash)
*
* JSON Schema draft-07 cannot express every rule; the runtime-enforced
* semantics below (chart-set invariants, evidenceRefs existence, blocked
* non-determinism, forbidden content, evidence-id uniqueness, serialization
* cap) are implemented identically in this file and in the Python validator,
* with tests on both sides. The cryptographic evidence hash is verified only
* by the server runtime (personal-report-contract.server.ts) and the Python
* validator; it is never part of this isomorphic parse.
*/
import { z } from "zod";
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
export const REPORT_CONTRACT_VERSION = "1" as const;
export const REPORT_DOCUMENT_MAX_BYTES = 1_572_864; // 1.5 MiB hard cap.
export const CLAIM_STATUSES = [
"multi_system_consensus",
"single_system_inference",
"parameter_sensitive",
"unclosed_divisional_chart",
"user_history_verification_required",
"blocked",
] as const;
export type ClaimStatus = (typeof CLAIM_STATUSES)[number];
export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PRESENTATION_MODES = ["default", "research"] as const;
export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const;
export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
export const CHART_IDS = ["D1", "D9", "D10"] as const;
const claimStatusSchema = z.enum(CLAIM_STATUSES);
const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id");
const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, "invalid sha256 hex");
const iso8601Schema = z.string()
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/, "invalid ISO-8601 timestamp");
const text = (maxLength: number, minLength = 1) => z.string().min(minLength).max(maxLength);
const textArray = (maxItems: number, maxLength: number) => z.array(text(maxLength)).max(maxItems);
const houseSchema = z.strictObject({
houseNumber: z.number().int().min(1).max(12),
sign: text(40),
occupants: textArray(12, 40),
});
const planetSchema = z.strictObject({
name: text(40),
sign: text(40),
longitudeDegrees: z.number().min(0).lt(360),
houseNumber: z.number().int().min(1).max(12),
retrograde: z.boolean(),
});
const chartSchema = z.strictObject({
id: z.enum(CHART_IDS),
title: text(120),
houses: z.array(houseSchema).max(12),
planets: z.array(planetSchema).max(12).optional(),
claimStatus: claimStatusSchema,
});
const thematicSectionSchema = z.strictObject({
id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"),
title: text(160),
narrative: text(4000),
actions: textArray(12, 400),
caveats: textArray(12, 400),
claimStatus: claimStatusSchema,
evidenceRefs: z.array(evidenceIdSchema).max(24),
});
const techniqueAuditRowSchema = z.strictObject({
id: evidenceIdSchema,
techniqueId: z.string().regex(/^[a-z0-9_.-]{1,80}$/, "invalid technique id"),
techniqueName: text(160),
status: z.enum(TECHNIQUE_STATUSES),
used: z.boolean(),
notes: z.string().max(500).optional(),
});
const conflictRowSchema = z.strictObject({
id: evidenceIdSchema,
description: text(1000),
impact: text(500),
status: z.enum(CONFLICT_STATUSES),
});
const calculationEvidenceRowSchema = z.strictObject({
id: evidenceIdSchema,
label: text(160),
value: text(500),
source: text(200),
});
const evidenceAppendixSchema = z.strictObject({
expandedByDefault: z.boolean(),
techniqueAudit: z.array(techniqueAuditRowSchema).max(100),
conflicts: z.array(conflictRowSchema).max(50),
calculationEvidence: z.array(calculationEvidenceRowSchema).max(100),
blockedTechniques: textArray(100, 120),
});
const reportDocumentShape = {
schemaVersion: z.literal(REPORT_DOCUMENT_SCHEMA_VERSION),
reportId: z.string().uuid(),
reportType: z.enum(REPORT_TYPES),
presentationMode: z.enum(PRESENTATION_MODES),
generatedAt: iso8601Schema,
subject: z.strictObject({
displayName: text(120),
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
birthPlaceLabel: text(200),
}),
provenance: z.strictObject({
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
skillSnapshotSha256: sha256HexSchema,
calculationHash: sha256HexSchema,
evidenceHash: sha256HexSchema,
reportContractVersion: z.literal(REPORT_CONTRACT_VERSION),
}),
executiveSummary: z.strictObject({
headline: text(200),
summary: text(2000),
priorities: textArray(8, 200),
overallClaimStatus: claimStatusSchema,
}),
charts: z.array(chartSchema).min(1).max(3),
thematicNarrative: z.array(thematicSectionSchema).max(12),
evidenceAppendix: evidenceAppendixSchema,
disclaimer: text(2000),
};
export const reportDocumentSchema = z.strictObject(reportDocumentShape);
export type ReportDocumentV1 = z.infer<typeof reportDocumentSchema>;
export type EvidenceAppendix = ReportDocumentV1["evidenceAppendix"];
export type ChartV1 = ReportDocumentV1["charts"][number];
export type ThematicSectionV1 = ReportDocumentV1["thematicNarrative"][number];
export type ReportDocumentParseError = Readonly<{
path: string;
message: string;
}>;
export class ReportDocumentValidationError extends Error {
readonly errors: readonly ReportDocumentParseError[];
constructor(errors: readonly ReportDocumentParseError[]) {
super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
this.name = "ReportDocumentValidationError";
this.errors = errors;
}
}
/** Canonical evidence object used by the evidence hash on both language sides. */
export function canonicalEvidence(appendix: EvidenceAppendix): Record<string, unknown> {
return {
techniqueAudit: appendix.techniqueAudit.map((row) => ({
id: row.id,
techniqueId: row.techniqueId,
techniqueName: row.techniqueName,
status: row.status,
used: row.used,
...(row.notes !== undefined ? { notes: row.notes } : {}),
})),
conflicts: appendix.conflicts.map((row) => ({
id: row.id,
description: row.description,
impact: row.impact,
status: row.status,
})),
calculationEvidence: appendix.calculationEvidence.map((row) => ({
id: row.id,
label: row.label,
value: row.value,
source: row.source,
})),
};
}
export function serializedReportDocumentBytes(document: ReportDocumentV1): number {
return new TextEncoder().encode(JSON.stringify(document)).length;
}
/**
* Forbidden content patterns. Keep byte-for-byte equivalent to
* FORBIDDEN_PATTERNS in scripts/personal_report_contract.py.
*/
export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; pattern: RegExp }>[] = [
{ name: "html_tag_open", pattern: /<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b/i },
{ name: "html_tag_close", pattern: /<\/\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>/i },
{ name: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/i },
{ name: "executable_url", pattern: /\b(?:javascript|vbscript|data:text\/html|data:text\/javascript|file):/i },
{ name: "processing_instruction", pattern: /<\?/i },
{ name: "template_literal", pattern: /\$\{/i },
{ name: "stack_trace", pattern: /(?:Traceback \(most recent call last\)|node:internal\/| at (?:Object|async|node)\.)/i },
{ name: "dunder_path", pattern: /__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__/i },
{ name: "process_env", pattern: /\bprocess\.env\b/i },
{ name: "unix_home_path", pattern: /(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]/i },
{ name: "windows_drive_path", pattern: /^[a-zA-Z]:[\\/]/i },
{ name: "jwt_token", pattern: /\beyJ[A-Za-z0-9_-]{20,}\b/i },
{ name: "secret_marker", pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b/i },
{ name: "tool_trace", pattern: /\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b/i },
{ name: "chain_of_thought", pattern: /\bchain[\s_-]?of[\s_-]?thought\b/i },
];
/**
* Deterministic-prediction phrases forbidden inside blocked sections.
* Keep equivalent to DETERMINISTIC_PHRASES in the Python validator.
*/
export const BLOCKED_DETERMINISTIC_PHRASES: readonly string[] = [
"必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑",
"guaranteed", "definitely will", "certainly will", "will certainly", "is certain to",
];
export function findForbiddenContent(value: string): readonly string[] {
return FORBIDDEN_CONTENT_PATTERNS
.filter(({ pattern }) => pattern.test(value))
.map(({ name }) => name);
}
function blockedTexts(document: ReportDocumentV1): readonly Readonly<{ path: string; text: string }>[] {
const entries: { path: string; text: string }[] = [];
if (document.executiveSummary.overallClaimStatus === "blocked") {
entries.push({ path: "executiveSummary.headline", text: document.executiveSummary.headline });
entries.push({ path: "executiveSummary.summary", text: document.executiveSummary.summary });
document.executiveSummary.priorities.forEach((priority, index) => {
entries.push({ path: `executiveSummary.priorities[${index}]`, text: priority });
});
}
document.charts.forEach((chart, index) => {
if (chart.claimStatus === "blocked") {
entries.push({ path: `charts[${index}].title`, text: chart.title });
}
});
document.thematicNarrative.forEach((section, index) => {
if (section.claimStatus !== "blocked") return;
entries.push({ path: `thematicNarrative[${index}].title`, text: section.title });
entries.push({ path: `thematicNarrative[${index}].narrative`, text: section.narrative });
section.actions.forEach((action, actionIndex) => {
entries.push({ path: `thematicNarrative[${index}].actions[${actionIndex}]`, text: action });
});
section.caveats.forEach((caveat, caveatIndex) => {
entries.push({ path: `thematicNarrative[${index}].caveats[${caveatIndex}]`, text: caveat });
});
});
return entries;
}
export function findBlockedDeterministicClaims(document: ReportDocumentV1): readonly string[] {
const phrases = BLOCKED_DETERMINISTIC_PHRASES.map((phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"));
return blockedTexts(document)
.filter(({ text }) => phrases.some((pattern) => pattern.test(text)))
.map(({ path }) => path);
}
export function findDanglingEvidenceRefs(document: ReportDocumentV1): readonly string[] {
const knownIds = new Set<string>([
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
...document.evidenceAppendix.conflicts.map((row) => row.id),
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
]);
return document.thematicNarrative.flatMap((section) =>
section.evidenceRefs.filter((ref) => !knownIds.has(ref)).map((ref) => `${section.id}:${ref}`),
);
}
/** Evidence ids must be globally unique across the whole appendix. */
export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly string[] {
const locations = new Map<string, string>();
const duplicates: string[] = [];
const rows: Readonly<{ key: string; index: number; id: string }>[] = [
...document.evidenceAppendix.techniqueAudit.map((row, index) => ({ key: "techniqueAudit", index, id: row.id })),
...document.evidenceAppendix.conflicts.map((row, index) => ({ key: "conflicts", index, id: row.id })),
...document.evidenceAppendix.calculationEvidence.map((row, index) => ({ key: "calculationEvidence", index, id: row.id })),
];
for (const { key, index, id } of rows) {
const location = `${key}[${index}]`;
const first = locations.get(id);
if (first !== undefined) {
duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
} else {
locations.set(id, location);
}
}
return duplicates;
}
export function findChartSetViolations(document: ReportDocumentV1): readonly string[] {
const violations: string[] = [];
const ids = document.charts.map((chart) => chart.id);
const d1Count = ids.filter((id) => id === "D1").length;
if (d1Count !== 1) violations.push(`charts must contain exactly one D1 chart, found ${d1Count}`);
const seen = new Set<string>();
for (const id of ids) {
if (seen.has(id)) violations.push(`duplicate chart id ${id}`);
seen.add(id);
}
const d1 = document.charts.find((chart) => chart.id === "D1");
if (d1) {
const numbers = d1.houses.map((house) => house.houseNumber);
const unique = new Set(numbers);
if (unique.size !== numbers.length) violations.push("D1 chart contains duplicate house numbers");
const expected = Array.from({ length: 12 }, (_, index) => index + 1);
if (numbers.length !== 12 || expected.some((number) => !unique.has(number))) {
violations.push("D1 chart must contain all twelve house numbers 1..12 exactly once");
}
}
return violations;
}
export function validateReportDocumentGuards(document: ReportDocumentV1): readonly string[] {
const errors: string[] = [];
errors.push(...findChartSetViolations(document));
errors.push(...findDuplicateEvidenceIds(document));
errors.push(...findBlockedDeterministicClaims(document).map((path) => `${path}: blocked section contains deterministic prediction`));
errors.push(...findDanglingEvidenceRefs(document).map((ref) => `thematicNarrative.evidenceRefs: unknown evidence id ${ref}`));
const forbidden: { path: string; hits: readonly string[] }[] = [];
const collectTexts = (path: string, value: string) => {
const hits = findForbiddenContent(value);
if (hits.length > 0) forbidden.push({ path, hits });
};
collectTexts("subject.displayName", document.subject.displayName);
collectTexts("subject.birthPlaceLabel", document.subject.birthPlaceLabel);
collectTexts("executiveSummary.headline", document.executiveSummary.headline);
collectTexts("executiveSummary.summary", document.executiveSummary.summary);
document.executiveSummary.priorities.forEach((priority, index) => collectTexts(`executiveSummary.priorities[${index}]`, priority));
document.charts.forEach((chart, chartIndex) => {
collectTexts(`charts[${chartIndex}].title`, chart.title);
chart.houses.forEach((house, houseIndex) => {
collectTexts(`charts[${chartIndex}].houses[${houseIndex}].sign`, house.sign);
house.occupants.forEach((occupant, occupantIndex) => collectTexts(`charts[${chartIndex}].houses[${houseIndex}].occupants[${occupantIndex}]`, occupant));
});
(chart.planets ?? []).forEach((planet, planetIndex) => {
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].name`, planet.name);
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].sign`, planet.sign);
});
});
document.thematicNarrative.forEach((section, sectionIndex) => {
collectTexts(`thematicNarrative[${sectionIndex}].title`, section.title);
collectTexts(`thematicNarrative[${sectionIndex}].narrative`, section.narrative);
section.actions.forEach((action, actionIndex) => collectTexts(`thematicNarrative[${sectionIndex}].actions[${actionIndex}]`, action));
section.caveats.forEach((caveat, caveatIndex) => collectTexts(`thematicNarrative[${sectionIndex}].caveats[${caveatIndex}]`, caveat));
});
document.evidenceAppendix.techniqueAudit.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].techniqueName`, row.techniqueName);
if (row.notes !== undefined) collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].notes`, row.notes);
});
document.evidenceAppendix.conflicts.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].description`, row.description);
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].impact`, row.impact);
});
document.evidenceAppendix.calculationEvidence.forEach((row, rowIndex) => {
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].label`, row.label);
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].value`, row.value);
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].source`, row.source);
});
document.evidenceAppendix.blockedTechniques.forEach((technique, rowIndex) => {
collectTexts(`evidenceAppendix.blockedTechniques[${rowIndex}]`, technique);
});
collectTexts("disclaimer", document.disclaimer);
forbidden.forEach(({ path, hits }) => errors.push(`${path}: forbidden content ${hits.join(",")}`));
const size = serializedReportDocumentBytes(document);
if (size > REPORT_DOCUMENT_MAX_BYTES) {
errors.push(`serialized document is ${size} bytes, exceeding ${REPORT_DOCUMENT_MAX_BYTES}`);
}
return errors;
}
export type ReportDocumentParseResult =
| Readonly<{ ok: true; document: ReportDocumentV1 }>
| Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>;
export function safeParseReportDocument(input: unknown): ReportDocumentParseResult {
const parsed = reportDocumentSchema.safeParse(input);
if (!parsed.success) {
return {
ok: false,
errors: parsed.error.issues.map((issue) => ({
path: issue.path.join(".") || "(root)",
message: issue.message,
})),
};
}
const document = parsed.data;
const guardErrors = validateReportDocumentGuards(document);
if (guardErrors.length > 0) {
return {
ok: false,
errors: guardErrors.map((message) => ({ path: "(guard)", message })),
};
}
return { ok: true, document };
}
export function parseReportDocument(input: unknown): ReportDocumentV1 {
const result = safeParseReportDocument(input);
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
return result.document;
}
@@ -0,0 +1,518 @@
import {
parseServerReportDocument,
computeEvidenceHash,
} from "./personal-report-contract.server-core.ts";
import { REPORT_DOCUMENT_SCHEMA_VERSION } from "./personal-report-contract.ts";
import type { ReportDocumentV1 } from "./personal-report-contract.ts";
/**
* Server-only persistence layer for personal reports (pure core).
*
* Routing: production callers pass the result of createServerSupabaseClient()
* (which already resolves self-hosted PostgreSQL vs Supabase). This module
* never inspects query-builder or pg specifics: it depends on the narrow
* PersonalReportDataClient port below, so unit tests can inject an in-memory
* fake and neither Supabase QueryBuilder nor node-postgres shapes leak into
* the API layer. The production entry personal-report-service.ts adds
* `import "server-only"` and re-exports this module.
*
* Ownership: every operation is scoped by userId; completeReady additionally
* verifies the validated document's reportId against the row id and the row's
* stored hash columns against the document (hashes are recomputed here, never
* trusted as model self-report).
*
* Idempotency: unique (user_id, request_id) is the primary lock. Replaying a
* known requestId requires the same requestFingerprint; a different
* fingerprint under the same requestId returns request_conflict instead of
* silently overwriting. Failed records are never implicitly resurrected by
* this service; callers reuse a failed record only via a new requestId.
*
* Privacy: this module never logs, indexes or returns birth details, report
* bodies, prompts, model text or exception stacks. failures carry a stable
* failure_code enum only.
*/
export const PERSONAL_REPORT_FAILURE_CODES = [
"profile_incomplete",
"birth_time_not_usable",
"report_generation_in_progress",
"report_rate_limited",
"calculation_unavailable",
"model_unavailable",
"report_schema_invalid",
"report_guard_rejected",
"report_not_found",
] as const;
export type PersonalReportFailureCode = (typeof PERSONAL_REPORT_FAILURE_CODES)[number];
export const PERSONAL_REPORT_STATUSES = ["generating", "ready", "failed"] as const;
export type PersonalReportStatus = (typeof PERSONAL_REPORT_STATUSES)[number];
export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
export const PERSONAL_REPORT_PRESENTATION_MODES = ["default", "research"] as const;
export type PersonalReportServiceErrorCode =
| "invalid_request"
| "not_found"
| "invalid_state"
| "generation_in_progress"
| "request_conflict"
| "invalid_document"
| "invalid_failure_code"
| "storage_failed";
export class PersonalReportServiceError extends Error {
readonly code: PersonalReportServiceErrorCode;
constructor(code: PersonalReportServiceErrorCode, message?: string) {
super(message ?? `Personal report service error: ${code}`);
this.name = "PersonalReportServiceError";
this.code = code;
}
}
export type PersonalReportRecord = Readonly<{
id: string;
userId: string;
sessionId: string | null;
chartProfileId: string | null;
requestId: string;
requestFingerprint: string;
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
status: PersonalReportStatus;
schemaVersion: string;
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
requestedThemes: readonly string[];
reportDocument: ReportDocumentV1 | null;
calculationHash: string | null;
evidenceHash: string | null;
skillSourceCommit: string | null;
skillSnapshotSha256: string;
failureCode: PersonalReportFailureCode | null;
createdAt: string;
updatedAt: string;
completedAt: string | null;
}>;
export type CreateGeneratingInput = Readonly<{
userId: string;
requestId: string;
requestFingerprint: string;
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
requestedThemes?: readonly string[];
sessionId?: string | null;
chartProfileId?: string | null;
skillSourceCommit?: string | null;
skillSnapshotSha256: string;
}>;
export type CreateGeneratingResult =
| Readonly<{ kind: "created"; record: PersonalReportRecord }>
| Readonly<{ kind: "replayed"; record: PersonalReportRecord }>
| Readonly<{ kind: "request_conflict"; record: PersonalReportRecord }>
| Readonly<{ kind: "generation_in_progress"; record: PersonalReportRecord }>;
export type PersonalReportQueryResult = Readonly<{
data: unknown;
error: Readonly<{ message: string; code?: string }> | null;
count?: number | null;
}>;
/** Narrow structural port implemented by the in-memory fake and the adapter. */
export interface PersonalReportQueryBuilder extends PromiseLike<PersonalReportQueryResult> {
select(columns: string): PersonalReportQueryBuilder;
insert(row: Readonly<Record<string, unknown>>): PersonalReportQueryBuilder;
update(values: Readonly<Record<string, unknown>>): PersonalReportQueryBuilder;
delete(options?: Readonly<{ count?: string }>): PersonalReportQueryBuilder;
eq(column: string, value: unknown): PersonalReportQueryBuilder;
order(column: string, options?: Readonly<{ ascending?: boolean }>): PersonalReportQueryBuilder;
limit(value: number): PersonalReportQueryBuilder;
maybeSingle(): PromiseLike<PersonalReportQueryResult>;
single(): PromiseLike<PersonalReportQueryResult>;
}
export interface PersonalReportDataClient {
from(table: "personal_reports"): PersonalReportQueryBuilder;
}
export type PersonalReportServiceDeps = Readonly<{
now?: () => Date;
}>;
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const sha256Pattern = /^[0-9a-f]{64}$/;
const sha1Pattern = /^[0-9a-f]{40}$/;
const RECORD_COLUMNS = [
"id",
"user_id",
"session_id",
"chart_profile_id",
"request_id",
"request_fingerprint",
"report_type",
"status",
"schema_version",
"presentation_mode",
"requested_themes",
"report_document",
"calculation_hash",
"evidence_hash",
"skill_source_commit",
"skill_snapshot_sha256",
"failure_code",
"created_at",
"updated_at",
"completed_at",
].join(",");
type DbRow = Readonly<Record<string, unknown>>;
function requireUuid(value: unknown, field: string): string {
if (typeof value !== "string" || !uuidPattern.test(value)) {
throw new PersonalReportServiceError("invalid_request", `${field} must be a uuid`);
}
return value;
}
function optionalUuid(value: unknown, field: string): string | null {
if (value === null || value === undefined) return null;
return requireUuid(value, field);
}
function requireSha256(value: unknown, field: string): string {
if (typeof value !== "string" || !sha256Pattern.test(value)) {
throw new PersonalReportServiceError("invalid_request", `${field} must be a 64-char sha256 hex`);
}
return value;
}
function optionalHash(value: unknown, field: string): string | null {
if (value === null || value === undefined) return null;
return requireSha256(value, field);
}
function optionalCommit(value: unknown, field: string): string | null {
if (value === null || value === undefined) return null;
if (typeof value !== "string" || !sha1Pattern.test(value)) {
throw new PersonalReportServiceError("invalid_request", `${field} must be a 40-char commit sha`);
}
return value;
}
function stringOrNull(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | null {
if (!row) return null;
const requestedThemes = Array.isArray(row.requested_themes)
? row.requested_themes.filter((theme): theme is string => typeof theme === "string")
: [];
const reportDocument = row.report_document === null || row.report_document === undefined
? null
: row.report_document as ReportDocumentV1;
return {
id: stringOrNull(row.id) ?? "",
userId: stringOrNull(row.user_id) ?? "",
sessionId: stringOrNull(row.session_id),
chartProfileId: stringOrNull(row.chart_profile_id),
requestId: stringOrNull(row.request_id) ?? "",
requestFingerprint: stringOrNull(row.request_fingerprint) ?? "",
reportType: row.report_type as PersonalReportRecord["reportType"],
status: row.status as PersonalReportStatus,
schemaVersion: stringOrNull(row.schema_version) ?? "",
presentationMode: row.presentation_mode as PersonalReportRecord["presentationMode"],
requestedThemes,
reportDocument,
calculationHash: stringOrNull(row.calculation_hash),
evidenceHash: stringOrNull(row.evidence_hash),
skillSourceCommit: stringOrNull(row.skill_source_commit),
skillSnapshotSha256: stringOrNull(row.skill_snapshot_sha256) ?? "",
failureCode: stringOrNull(row.failure_code) as PersonalReportFailureCode | null,
createdAt: stringOrNull(row.created_at) ?? "",
updatedAt: stringOrNull(row.updated_at) ?? "",
completedAt: stringOrNull(row.completed_at),
};
}
function normalizedResult(result: PersonalReportQueryResult): PersonalReportQueryResult {
return {
data: result.data,
error: result.error ? { message: result.error.message, code: result.error.code } : null,
...(typeof result.count === "number" ? { count: result.count } : {}),
};
}
/**
* Wraps any routed client (real Supabase or the local PostgreSQL compatibility
* client) into the narrow port. Every chain step is re-wrapped so callers only
* ever see PersonalReportQueryBuilder shapes.
*/
export function createPersonalReportDataClient(supabase: {
from(table: string): unknown;
}): PersonalReportDataClient {
type AnyBuilder = {
select(columns: string): unknown;
insert(row: unknown): unknown;
update(values: unknown): unknown;
delete(options?: unknown): unknown;
eq(column: string, value: unknown): unknown;
order(column: string, options?: unknown): unknown;
limit(value: number): unknown;
maybeSingle(): PromiseLike<PersonalReportQueryResult>;
single(): PromiseLike<PersonalReportQueryResult>;
then: PromiseLike<PersonalReportQueryResult>["then"];
};
const wrap = (builder: AnyBuilder): PersonalReportQueryBuilder => ({
select: (columns) => wrap(builder.select(columns) as AnyBuilder),
insert: (row) => wrap(builder.insert(row) as AnyBuilder),
update: (values) => wrap(builder.update(values) as AnyBuilder),
delete: (options) => wrap(builder.delete(options) as AnyBuilder),
eq: (column, value) => wrap(builder.eq(column, value) as AnyBuilder),
order: (column, options) => wrap(builder.order(column, options) as AnyBuilder),
limit: (value) => wrap(builder.limit(value) as AnyBuilder),
maybeSingle: () => builder.maybeSingle().then(normalizedResult),
single: () => builder.single().then(normalizedResult),
then: (onfulfilled, onrejected) =>
Promise.resolve(builder.then(normalizedResult)).then(onfulfilled, onrejected),
});
return {
from: (table) => wrap(supabase.from(table) as unknown as AnyBuilder),
};
}
export function createPersonalReportService(
client: PersonalReportDataClient,
deps: PersonalReportServiceDeps = {},
): PersonalReportService {
const now = deps.now ?? (() => new Date());
const records = () => client.from("personal_reports");
async function loadRow(
userId: string,
reportId: string,
): Promise<DbRow | null> {
const { data, error } = await records()
.select(RECORD_COLUMNS)
.eq("id", reportId)
.eq("user_id", userId)
.maybeSingle();
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
return data as DbRow | null;
}
async function loadByRequest(
userId: string,
requestId: string,
): Promise<DbRow | null> {
const { data, error } = await records()
.select(RECORD_COLUMNS)
.eq("user_id", userId)
.eq("request_id", requestId)
.maybeSingle();
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
return data as DbRow | null;
}
async function anyGeneratingFor(userId: string): Promise<DbRow | null> {
const { data, error } = await records()
.select(RECORD_COLUMNS)
.eq("user_id", userId)
.eq("status", "generating")
.limit(1)
.maybeSingle();
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
return data as DbRow | null;
}
return {
async createGenerating(input: CreateGeneratingInput): Promise<CreateGeneratingResult> {
const userId = requireUuid(input.userId, "userId");
const requestId = requireUuid(input.requestId, "requestId");
const requestFingerprint = requireSha256(input.requestFingerprint, "requestFingerprint");
if (!PERSONAL_REPORT_TYPES.includes(input.reportType)) {
throw new PersonalReportServiceError("invalid_request", "unsupported reportType");
}
if (!PERSONAL_REPORT_PRESENTATION_MODES.includes(input.presentationMode)) {
throw new PersonalReportServiceError("invalid_request", "unsupported presentationMode");
}
optionalUuid(input.sessionId, "sessionId");
optionalUuid(input.chartProfileId, "chartProfileId");
optionalHash(input.skillSnapshotSha256, "skillSnapshotSha256");
optionalCommit(input.skillSourceCommit, "skillSourceCommit");
const themes = input.requestedThemes ?? [];
if (!Array.isArray(themes) || themes.length > 12 || themes.some((theme) => typeof theme !== "string" || theme.length > 64)) {
throw new PersonalReportServiceError("invalid_request", "invalid requestedThemes");
}
const row: Record<string, unknown> = {
user_id: userId,
request_id: requestId,
request_fingerprint: requestFingerprint,
report_type: input.reportType,
status: "generating",
schema_version: REPORT_DOCUMENT_SCHEMA_VERSION,
presentation_mode: input.presentationMode,
requested_themes: themes,
skill_snapshot_sha256: input.skillSnapshotSha256,
session_id: input.sessionId ?? null,
chart_profile_id: input.chartProfileId ?? null,
skill_source_commit: input.skillSourceCommit ?? null,
created_at: now().toISOString(),
updated_at: now().toISOString(),
};
const { data, error } = await records()
.insert(row)
.select(RECORD_COLUMNS)
.single();
if (!error && data) return { kind: "created", record: recordFromRow(data as DbRow)! };
// The (user_id, request_id) unique constraint already holds this
// request, or the per-user in-flight index rejected a second
// generation. Re-read state instead of trusting backend error codes.
const existing = await loadByRequest(userId, requestId);
if (existing) {
if (existing.request_fingerprint === requestFingerprint) {
return { kind: "replayed", record: recordFromRow(existing)! };
}
return { kind: "request_conflict", record: recordFromRow(existing)! };
}
const inFlight = await anyGeneratingFor(userId);
if (inFlight) return { kind: "generation_in_progress", record: recordFromRow(inFlight)! };
throw new PersonalReportServiceError("storage_failed", error?.message ?? "insert failed");
},
async getByUserAndRequestId(userId: string, requestId: string): Promise<PersonalReportRecord | null> {
requireUuid(userId, "userId");
requireUuid(requestId, "requestId");
const row = await loadByRequest(userId, requestId);
return recordFromRow(row);
},
async getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null> {
requireUuid(userId, "userId");
requireUuid(reportId, "reportId");
const row = await loadRow(userId, reportId);
return recordFromRow(row);
},
async completeReady(
userId: string,
reportId: string,
document: unknown,
): Promise<PersonalReportRecord> {
requireUuid(userId, "userId");
requireUuid(reportId, "reportId");
let parsed: ReportDocumentV1;
try {
parsed = parseServerReportDocument(document);
} catch (error) {
throw new PersonalReportServiceError(
"invalid_document",
error instanceof Error ? error.message : "report document failed contract validation",
);
}
if (parsed.reportId !== reportId) {
throw new PersonalReportServiceError("invalid_document", "document reportId does not match record id");
}
const row = await loadRow(userId, reportId);
if (!row) throw new PersonalReportServiceError("not_found");
if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`);
// Hashes are recomputed here and cross-checked against the stored row;
// provenance.evidenceHash was already verified by parseServerReportDocument.
const evidenceHash = computeEvidenceHash(parsed.evidenceAppendix);
if (row.evidence_hash !== null && row.evidence_hash !== evidenceHash) {
throw new PersonalReportServiceError("invalid_document", "evidence hash does not match stored row");
}
if (row.calculation_hash !== null && row.calculation_hash !== parsed.provenance.calculationHash) {
throw new PersonalReportServiceError("invalid_document", "calculation hash does not match stored row");
}
const completedAt = now().toISOString();
const { data, error } = await records()
.update({
status: "ready",
report_document: parsed,
evidence_hash: evidenceHash,
calculation_hash: parsed.provenance.calculationHash,
completed_at: completedAt,
updated_at: completedAt,
})
.eq("id", reportId)
.eq("user_id", userId)
.eq("status", "generating")
.select(RECORD_COLUMNS)
.single();
if (!error && data) return recordFromRow(data as DbRow)!;
const current = await loadRow(userId, reportId);
if (!current) throw new PersonalReportServiceError("not_found");
throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`);
},
async markFailed(
userId: string,
reportId: string,
failureCode: string,
): Promise<PersonalReportRecord> {
requireUuid(userId, "userId");
requireUuid(reportId, "reportId");
if (!PERSONAL_REPORT_FAILURE_CODES.includes(failureCode as PersonalReportFailureCode)) {
throw new PersonalReportServiceError("invalid_failure_code", `unsupported failure code ${failureCode}`);
}
const row = await loadRow(userId, reportId);
if (!row) throw new PersonalReportServiceError("not_found");
if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`);
const updatedAt = now().toISOString();
const { data, error } = await records()
.update({
status: "failed",
failure_code: failureCode,
updated_at: updatedAt,
})
.eq("id", reportId)
.eq("user_id", userId)
.eq("status", "generating")
.select(RECORD_COLUMNS)
.single();
if (!error && data) return recordFromRow(data as DbRow)!;
const current = await loadRow(userId, reportId);
if (!current) throw new PersonalReportServiceError("not_found");
throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`);
},
async deleteOwned(userId: string, reportId: string): Promise<boolean> {
requireUuid(userId, "userId");
requireUuid(reportId, "reportId");
const { error, count } = await records()
.delete({ count: "exact" })
.eq("id", reportId)
.eq("user_id", userId);
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
return (count ?? 0) > 0;
},
};
}
export interface PersonalReportService {
createGenerating(input: CreateGeneratingInput): Promise<CreateGeneratingResult>;
getByUserAndRequestId(userId: string, requestId: string): Promise<PersonalReportRecord | null>;
getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null>;
completeReady(userId: string, reportId: string, document: unknown): Promise<PersonalReportRecord>;
markFailed(userId: string, reportId: string, failureCode: string): Promise<PersonalReportRecord>;
deleteOwned(userId: string, reportId: string): Promise<boolean>;
}
/** Production wiring: routes through the caller's resolved backend client. */
export function createSupabasePersonalReportService(
supabase: { from(table: string): unknown },
deps?: PersonalReportServiceDeps,
): PersonalReportService {
return createPersonalReportService(createPersonalReportDataClient(supabase), deps);
}
@@ -0,0 +1,3 @@
import "server-only";
export * from "./personal-report-service-core";