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,432 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://jyotisha.chat/contracts/personal-report/report-document.v1.schema.json",
"title": "ReportDocument v1",
"description": "Server-issued personal astrology report document. This contract is enforced identically by the JSON Schema below, frontend/src/lib/personal-report-contract.ts (Zod), and scripts/personal_report_contract.py (stdlib Python validator). Semantics that JSON Schema draft-07 cannot express are enforced by both runtime validators and their tests: (1) charts must contain exactly one D1 chart, chart ids must be unique, and the D1 chart must contain all twelve house numbers 1..12 (enough real houses to render without fabrication); (2) every houseNumber must be unique within its chart; (3) evidence ids (id fields of techniqueAudit, conflicts and calculationEvidence rows) must be globally unique across the whole evidence appendix so evidenceRefs are never ambiguous; (4) provenance.evidenceHash is a deterministic recomputation over the evidence appendix (techniqueAudit, conflicts, calculationEvidence in canonical field order) - it is never trusted as a model self-report; the cryptographic hash is verified by the server runtime (frontend/src/lib/personal-report-contract.server.ts) and by the Python validator (scripts/personal_report_contract.py), and a document whose evidenceHash does not equal the recomputed value is rejected; the isomorphic frontend contract validates structure only and never recomputes the hash; (5) every entry in evidenceRefs must reference an id present in evidenceAppendix.techniqueAudit, evidenceAppendix.conflicts, or evidenceAppendix.calculationEvidence; (6) sections whose claimStatus is blocked must not contain deterministic predictions (e.g. 必然, 必定, 一定会, 肯定会, 绝对会, guaranteed, definitely will); (7) the UTF-8 JSON serialization of the whole document must not exceed 1572864 bytes (1.5 MiB). Fixed reader order: this schema defines the display sequence executiveSummary, thematicNarrative, evidenceAppendix as a UI/type-level presentation contract; JSON object key order is not validated (objects are unordered by definition). Privacy rule: subject/provenance metadata must never repeat full birth date, precise coordinates, or a raw chart payload. Content rule: no HTML/JS/CSS, no executable URLs (javascript:, vbscript:, data:text/html, file:), no internal filesystem paths, no prompt/tool traces or exception stacks, no model secrets or JWTs.",
"type": "object",
"definitions": {
"claimStatus": {
"type": "string",
"enum": [
"multi_system_consensus",
"single_system_inference",
"parameter_sensitive",
"unclosed_divisional_chart",
"user_history_verification_required",
"blocked"
]
},
"evidenceId": {
"type": "string",
"pattern": "^ev-[a-z0-9_-]{1,63}$",
"minLength": 4,
"maxLength": 67
},
"sha256Hex": {
"type": "string",
"pattern": "^[0-9a-f]{64}$",
"minLength": 64,
"maxLength": 64
},
"iso8601": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,9})?(Z|[+-]\\d{2}:\\d{2})$",
"minLength": 20,
"maxLength": 40
},
"house": {
"type": "object",
"additionalProperties": false,
"properties": {
"houseNumber": {
"type": "integer",
"minimum": 1,
"maximum": 12
},
"sign": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"occupants": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"maxItems": 12
}
},
"required": ["houseNumber", "sign", "occupants"]
},
"planet": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"sign": {
"type": "string",
"minLength": 1,
"maxLength": 40
},
"longitudeDegrees": {
"type": "number",
"minimum": 0,
"exclusiveMaximum": 360
},
"houseNumber": {
"type": "integer",
"minimum": 1,
"maximum": 12
},
"retrograde": {
"type": "boolean"
}
},
"required": ["name", "sign", "longitudeDegrees", "houseNumber", "retrograde"]
},
"chart": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"enum": ["D1", "D9", "D10"]
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"houses": {
"type": "array",
"items": {
"$ref": "#/definitions/house"
},
"maxItems": 12
},
"planets": {
"type": "array",
"items": {
"$ref": "#/definitions/planet"
},
"maxItems": 12
},
"claimStatus": {
"$ref": "#/definitions/claimStatus"
}
},
"required": ["id", "title", "houses", "claimStatus"]
},
"thematicSection": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_-]{0,63}$",
"minLength": 1,
"maxLength": 64
},
"title": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"narrative": {
"type": "string",
"minLength": 1,
"maxLength": 4000
},
"actions": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 400
},
"maxItems": 12
},
"caveats": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 400
},
"maxItems": 12
},
"claimStatus": {
"$ref": "#/definitions/claimStatus"
},
"evidenceRefs": {
"type": "array",
"items": {
"$ref": "#/definitions/evidenceId"
},
"maxItems": 24
}
},
"required": ["id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs"]
},
"techniqueAuditRow": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"$ref": "#/definitions/evidenceId"
},
"techniqueId": {
"type": "string",
"pattern": "^[a-z0-9_.-]{1,80}$",
"minLength": 1,
"maxLength": 80
},
"techniqueName": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"status": {
"type": "string",
"enum": ["verified", "partial", "blocked"]
},
"used": {
"type": "boolean"
},
"notes": {
"type": "string",
"maxLength": 500
}
},
"required": ["id", "techniqueId", "techniqueName", "status", "used"]
},
"conflictRow": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"$ref": "#/definitions/evidenceId"
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 1000
},
"impact": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"status": {
"type": "string",
"enum": ["unresolved", "partial", "resolved"]
}
},
"required": ["id", "description", "impact", "status"]
},
"calculationEvidenceRow": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"$ref": "#/definitions/evidenceId"
},
"label": {
"type": "string",
"minLength": 1,
"maxLength": 160
},
"value": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"source": {
"type": "string",
"minLength": 1,
"maxLength": 200
}
},
"required": ["id", "label", "value", "source"]
}
},
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "string",
"const": "report_document.v1"
},
"reportId": {
"type": "string",
"format": "uuid",
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
},
"reportType": {
"type": "string",
"enum": ["personal_full", "personal_thematic"]
},
"presentationMode": {
"type": "string",
"enum": ["default", "research"]
},
"generatedAt": {
"$ref": "#/definitions/iso8601"
},
"subject": {
"type": "object",
"additionalProperties": false,
"properties": {
"displayName": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"birthTimeStatus": {
"type": "string",
"enum": ["reported", "candidate", "accepted", "confirmed"]
},
"birthPlaceLabel": {
"type": "string",
"minLength": 1,
"maxLength": 200
}
},
"required": ["displayName", "birthTimeStatus", "birthPlaceLabel"]
},
"provenance": {
"type": "object",
"additionalProperties": false,
"properties": {
"skillSourceCommit": {
"type": ["string", "null"],
"pattern": "^[0-9a-f]{40}$",
"minLength": 40,
"maxLength": 40
},
"skillSnapshotSha256": {
"$ref": "#/definitions/sha256Hex"
},
"calculationHash": {
"$ref": "#/definitions/sha256Hex"
},
"evidenceHash": {
"$ref": "#/definitions/sha256Hex"
},
"reportContractVersion": {
"type": "string",
"const": "1"
}
},
"required": ["skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"]
},
"executiveSummary": {
"type": "object",
"additionalProperties": false,
"properties": {
"headline": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 2000
},
"priorities": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 200
},
"maxItems": 8
},
"overallClaimStatus": {
"$ref": "#/definitions/claimStatus"
}
},
"required": ["headline", "summary", "priorities", "overallClaimStatus"]
},
"charts": {
"type": "array",
"items": {
"$ref": "#/definitions/chart"
},
"minItems": 1,
"maxItems": 3
},
"thematicNarrative": {
"type": "array",
"items": {
"$ref": "#/definitions/thematicSection"
},
"maxItems": 12
},
"evidenceAppendix": {
"type": "object",
"additionalProperties": false,
"properties": {
"expandedByDefault": {
"type": "boolean"
},
"techniqueAudit": {
"type": "array",
"items": {
"$ref": "#/definitions/techniqueAuditRow"
},
"maxItems": 100
},
"conflicts": {
"type": "array",
"items": {
"$ref": "#/definitions/conflictRow"
},
"maxItems": 50
},
"calculationEvidence": {
"type": "array",
"items": {
"$ref": "#/definitions/calculationEvidenceRow"
},
"maxItems": 100
},
"blockedTechniques": {
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"maxLength": 120
},
"maxItems": 100
}
},
"required": ["expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"]
},
"disclaimer": {
"type": "string",
"minLength": 1,
"maxLength": 2000
}
},
"required": [
"schemaVersion",
"reportId",
"reportType",
"presentationMode",
"generatedAt",
"subject",
"provenance",
"executiveSummary",
"charts",
"thematicNarrative",
"evidenceAppendix",
"disclaimer"
]
}
@@ -0,0 +1,104 @@
-- Personal report persistence for self-hosted PostgreSQL (staging).
-- Mirrors supabase/migrations/20260806010000_personal_reports.sql
-- one-to-one in table shape, constraints, RLS and grants.
--
-- Ownership model: rows are owned by auth.users(id) (the business-auth
-- mirror kept in sync by identity.sync_user_to_business_auth). Normal
-- application sessions connect as app_runtime (member of authenticated) and
-- set local role authenticated; they may select/delete only their own rows
-- through RLS plus the explicit owner grants below, and can never
-- insert/update (generation and status writes are performed exclusively
-- through service_role, which has BYPASSRLS and full table privileges).
-- admin_runtime has no direct access to report bodies (least privilege);
-- server-side generation runs through service_role, which admin_runtime may
-- SET ROLE to.
--
-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a
-- known requestId requires the same request_fingerprint (a sha256 of the
-- caller's request intent), so a different payload under the same requestId
-- surfaces as request_conflict instead of silently overwriting. Failed
-- retries are not implicitly upserted here; callers either reuse the failed
-- record via a new requestId or surface the stable failure.
--
-- No birth details, report bodies, model prompts or exception stacks are ever
-- written to logs, index columns or audit events; failure_code is a stable
-- enum shared with frontend/src/lib/personal-report-service.ts and
-- scripts/personal_report_contract.py.
create table if not exists public.personal_reports (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
session_id uuid,
chart_profile_id uuid,
request_id uuid not null,
request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'),
report_type text not null check (report_type in ('personal_full', 'personal_thematic')),
status text not null check (status in ('generating', 'ready', 'failed')),
schema_version text not null check (schema_version = 'report_document.v1'),
presentation_mode text not null check (presentation_mode in ('default', 'research')),
requested_themes text[] not null default '{}'::text[],
report_document jsonb,
calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'),
evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'),
skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'),
skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'),
failure_code text check (failure_code in (
'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'
)),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
completed_at timestamptz,
check ((status = 'ready') = (report_document is not null)),
check ((status = 'ready') = (completed_at is not null)),
check ((status = 'ready') = (calculation_hash is not null)),
check ((status = 'ready') = (evidence_hash is not null)),
check ((status = 'failed') = (failure_code is not null)),
unique (user_id, request_id)
);
create index if not exists personal_reports_user_created_idx
on public.personal_reports (user_id, created_at desc);
-- One in-flight generation per user, enforced by the database so a second
-- request cannot start while the first is still generating.
create unique index if not exists personal_reports_one_generating_per_user
on public.personal_reports (user_id)
where status = 'generating';
alter table public.personal_reports enable row level security;
revoke all on table public.personal_reports from public, anon, authenticated, service_role;
revoke all on table public.personal_reports from app_runtime, admin_runtime, migration_runner, backup_reader;
drop policy if exists personal_reports_select_own on public.personal_reports;
create policy personal_reports_select_own
on public.personal_reports
for select
to authenticated
using (auth.uid() = user_id);
drop policy if exists personal_reports_delete_own on public.personal_reports;
create policy personal_reports_delete_own
on public.personal_reports
for delete
to authenticated
using (auth.uid() = user_id);
-- Normal users can never insert or update rows: creating a generating record
-- and moving it to ready/failed are server-side operations only. RLS
-- policies alone do not grant table privileges, so the owner read/delete
-- grants below are required for the policies to be reachable.
grant select, delete on table public.personal_reports to authenticated;
-- admin_runtime intentionally has no direct access to report bodies (least
-- privilege); server-side generation runs through service_role, which
-- admin_runtime may SET ROLE to.
grant select, insert, update, delete on table public.personal_reports to service_role;
@@ -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";
@@ -0,0 +1,100 @@
-- Personal report persistence for Supabase (production).
-- Mirrors db/migrations/20260806000000_personal_reports.sql one-to-one in
-- table shape, constraints, RLS and grants.
--
-- Ownership model: rows are owned by auth.users(id). authenticated JWTs may
-- select/delete only their own rows through RLS plus the explicit owner
-- grants below, and can never insert/update (creating a generating record
-- and moving it to ready/failed are server-side operations performed only
-- through service_role, which bypasses RLS but still needs explicit grants).
--
-- Idempotency: unique (user_id, request_id) is the primary lock; replay of a
-- known requestId requires the same request_fingerprint (a sha256 of the
-- caller's request intent), so a different payload under the same requestId
-- surfaces as request_conflict instead of silently overwriting. Failed
-- retries are not implicitly upserted here; callers either reuse the failed
-- record via a new requestId or surface the stable failure.
--
-- No birth details, report bodies, model prompts or exception stacks are ever
-- written to logs, index columns or audit events; failure_code is a stable
-- enum shared with frontend/src/lib/personal-report-service.ts and
-- scripts/personal_report_contract.py.
begin;
create table if not exists public.personal_reports (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
session_id uuid,
chart_profile_id uuid,
request_id uuid not null,
request_fingerprint text not null check (request_fingerprint ~ '^[0-9a-f]{64}$'),
report_type text not null check (report_type in ('personal_full', 'personal_thematic')),
status text not null check (status in ('generating', 'ready', 'failed')),
schema_version text not null check (schema_version = 'report_document.v1'),
presentation_mode text not null check (presentation_mode in ('default', 'research')),
requested_themes text[] not null default '{}'::text[],
report_document jsonb,
calculation_hash text check (calculation_hash is null or calculation_hash ~ '^[0-9a-f]{64}$'),
evidence_hash text check (evidence_hash is null or evidence_hash ~ '^[0-9a-f]{64}$'),
skill_source_commit text check (skill_source_commit is null or skill_source_commit ~ '^[0-9a-f]{40}$'),
skill_snapshot_sha256 text not null check (skill_snapshot_sha256 ~ '^[0-9a-f]{64}$'),
failure_code text check (failure_code in (
'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'
)),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
completed_at timestamptz,
check ((status = 'ready') = (report_document is not null)),
check ((status = 'ready') = (completed_at is not null)),
check ((status = 'ready') = (calculation_hash is not null)),
check ((status = 'ready') = (evidence_hash is not null)),
check ((status = 'failed') = (failure_code is not null)),
unique (user_id, request_id)
);
create index if not exists personal_reports_user_created_idx
on public.personal_reports (user_id, created_at desc);
-- One in-flight generation per user, enforced by the database so a second
-- request cannot start while the first is still generating.
create unique index if not exists personal_reports_one_generating_per_user
on public.personal_reports (user_id)
where status = 'generating';
alter table public.personal_reports enable row level security;
revoke all on table public.personal_reports from public, anon, authenticated;
drop policy if exists personal_reports_select_own on public.personal_reports;
create policy personal_reports_select_own
on public.personal_reports
for select
to authenticated
using (auth.uid() = user_id);
drop policy if exists personal_reports_delete_own on public.personal_reports;
create policy personal_reports_delete_own
on public.personal_reports
for delete
to authenticated
using (auth.uid() = user_id);
-- Normal users can never insert or update rows: generation and status writes
-- are server-side operations only. RLS policies alone do not grant table
-- privileges, so the owner read/delete grants below are required for the
-- policies to be reachable.
grant select, delete on table public.personal_reports to authenticated;
-- admin_runtime intentionally has no direct access to report bodies (least
-- privilege); generation runs through service_role only.
grant select, insert, update, delete on table public.personal_reports to service_role;
commit;
@@ -0,0 +1,186 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
computeEvidenceHash,
safeParseServerReportDocument,
parseServerReportDocument,
} from "../src/lib/personal-report-contract.server-core.ts";
import {
findBlockedDeterministicClaims,
findChartSetViolations,
findDanglingEvidenceRefs,
findDuplicateEvidenceIds,
findForbiddenContent,
parseReportDocument,
REPORT_DOCUMENT_MAX_BYTES,
safeParseReportDocument,
serializedReportDocumentBytes,
type ReportDocumentV1,
} from "../src/lib/personal-report-contract.ts";
import { ReportDocumentValidationError } from "../src/lib/personal-report-contract.ts";
const fixtureText = readFileSync(
new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url),
"utf8",
);
const fixture: ReportDocumentV1 = JSON.parse(fixtureText);
const clone = () => structuredClone(fixture) as ReportDocumentV1;
test("fixture passes isomorphic parse and stays under the 1.5 MiB cap", () => {
const result = safeParseReportDocument(fixture);
assert.equal(result.ok, true);
assert.ok(serializedReportDocumentBytes(fixture) <= REPORT_DOCUMENT_MAX_BYTES);
assert.ok(serializedReportDocumentBytes(fixture) < 100_000);
});
test("isomorphic parse rejects extra keys, missing keys, and bad enums", () => {
const extra = clone();
(extra.subject as Record<string, unknown>).hometown = "上海";
assert.equal(safeParseReportDocument(extra).ok, false);
const missing = clone();
delete (missing as Partial<ReportDocumentV1>).disclaimer;
assert.equal(safeParseReportDocument(missing).ok, false);
const badEnum = clone();
badEnum.subject.birthTimeStatus = "guessed" as ReportDocumentV1["subject"]["birthTimeStatus"];
assert.equal(safeParseReportDocument(badEnum).ok, false);
});
test("charts must contain exactly one D1 with all twelve houses", () => {
const zeroD1 = clone();
zeroD1.charts = zeroD1.charts.filter((chart) => chart.id !== "D1");
assert.deepEqual(findChartSetViolations(zeroD1), ["charts must contain exactly one D1 chart, found 0"]);
assert.equal(safeParseReportDocument(zeroD1).ok, false);
const twoD1 = clone();
twoD1.charts.push(structuredClone(twoD1.charts[0]));
const violations = findChartSetViolations(twoD1);
assert.ok(violations.some((v) => v.includes("duplicate chart id")));
assert.ok(violations.some((v) => v.includes("exactly one D1")));
assert.equal(safeParseReportDocument(twoD1).ok, false);
const elevenHouses = clone();
elevenHouses.charts[0].houses = elevenHouses.charts[0].houses.slice(0, 11);
assert.ok(findChartSetViolations(elevenHouses).some((v) => v.includes("all twelve house numbers")));
assert.equal(safeParseReportDocument(elevenHouses).ok, false);
});
test("duplicate house numbers are rejected per chart", () => {
const duplicated = clone();
duplicated.charts[0].houses[11].houseNumber = 1;
const result = safeParseReportDocument(duplicated);
assert.equal(result.ok, false);
});
test("longitude is [0, 360)", () => {
const at360 = clone();
at360.charts[0].planets![0].longitudeDegrees = 360;
assert.equal(safeParseReportDocument(at360).ok, false);
const near360 = clone();
near360.charts[0].planets![0].longitudeDegrees = 359.999;
assert.equal(safeParseReportDocument(near360).ok, true);
});
test("evidence ids must be globally unique across the appendix", () => {
const duplicated = clone();
duplicated.evidenceAppendix.conflicts[0].id = duplicated.evidenceAppendix.techniqueAudit[0].id;
assert.ok(findDuplicateEvidenceIds(duplicated).some((v) => v.includes("ev-mevg-web")));
assert.equal(safeParseReportDocument(duplicated).ok, false);
});
test("dangling evidenceRefs are rejected", () => {
const dangling = clone();
dangling.thematicNarrative[0].evidenceRefs = ["ev-no-such-evidence"];
assert.deepEqual(findDanglingEvidenceRefs(dangling), ["career:ev-no-such-evidence"]);
assert.equal(safeParseReportDocument(dangling).ok, false);
});
test("blocked sections reject deterministic predictions", () => {
const deterministic = clone();
deterministic.thematicNarrative[3].narrative = "这个事件必然会发生在明年,一定会成功。";
assert.ok(findBlockedDeterministicClaims(deterministic).length > 0);
assert.equal(safeParseReportDocument(deterministic).ok, false);
const nonDeterministic = clone();
nonDeterministic.thematicNarrative[3].narrative = "需要更多历史事件校准后才能评估,具体应期暂不提供。";
assert.equal(findBlockedDeterministicClaims(nonDeterministic).length, 0);
assert.equal(safeParseReportDocument(nonDeterministic).ok, true);
});
const forbiddenSamples = [
"<script>alert(1)</script>",
"javascript:alert(1)",
"file:///Users/jesse/private/chart.json",
"参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}",
"onerror=alert(1)",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc",
"node:internal/modules/cjs/loader",
"Traceback (most recent call last)",
"__dirname/secret",
"__proto__ pollution",
"C:\\Users\\jesse\\chart.json",
"tool_call_id: call_123",
];
test("forbidden content patterns reject executable and internal material", () => {
for (const poison of forbiddenSamples) {
const poisoned = clone();
poisoned.disclaimer = poison;
assert.ok(
findForbiddenContent(poison).length > 0,
`expected ${poison} to be flagged`,
);
assert.equal(safeParseReportDocument(poisoned).ok, false, `poison: ${poison}`);
}
});
test("ordinary Chinese report text is not flagged as forbidden", () => {
assert.equal(findForbiddenContent("事业与财富主题的多系统证据较一致。").length, 0);
assert.equal(findForbiddenContent("A < B 的比较关系不属于 HTML 标签").length, 0);
});
test("serialization size cap rejects oversized documents", () => {
const oversized = clone();
oversized.disclaimer = "字".repeat(REPORT_DOCUMENT_MAX_BYTES);
assert.equal(safeParseReportDocument(oversized).ok, false);
});
test("JSON object key order is not validated (display order is a typed contract)", () => {
const reordered: Record<string, unknown> = {};
for (const key of Object.keys(fixture).reverse()) {
reordered[key] = (fixture as Record<string, unknown>)[key];
}
assert.equal(safeParseReportDocument(reordered).ok, true);
});
test("server parse recomputes the evidence hash and rejects self-reported mismatches", () => {
// The fixture hash is the canonical cross-language value; recomputation must agree.
assert.equal(computeEvidenceHash(fixture.evidenceAppendix), fixture.provenance.evidenceHash);
assert.equal(
computeEvidenceHash(fixture.evidenceAppendix),
"a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4",
);
assert.equal(safeParseServerReportDocument(fixture).ok, true);
// Tampered evidence with the self-reported hash left unchanged must fail.
const tampered = clone();
tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值";
const result = safeParseServerReportDocument(tampered);
assert.equal(result.ok, false);
assert.ok(result.errors.some((error) => error.path === "provenance.evidenceHash"));
assert.throws(() => parseServerReportDocument(tampered), ReportDocumentValidationError);
// The isomorphic parse alone does not verify the hash (server-only duty).
assert.equal(safeParseReportDocument(tampered).ok, true);
});
test("parseReportDocument throws a typed validation error on guard failures", () => {
const bad = clone();
bad.thematicNarrative[0].evidenceRefs = ["ev-missing"];
assert.throws(() => parseReportDocument(bad), ReportDocumentValidationError);
const parsed = parseReportDocument(fixture);
assert.equal(parsed.schemaVersion, "report_document.v1");
});
@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { PERSONAL_REPORT_FAILURE_CODES } from "../src/lib/personal-report-service-core.ts";
const localMigration = readFileSync(
new URL("../db/migrations/20260806000000_personal_reports.sql", import.meta.url),
"utf8",
);
const supabaseMigration = readFileSync(
new URL("../supabase/migrations/20260806010000_personal_reports.sql", import.meta.url),
"utf8",
);
const localMigrations = [
"20260714000000_local_auth_compatibility.sql",
"20260720000100_backend_foundation.sql",
"20260721000100_self_hosted_identity.sql",
"20260721000200_identity_business_bridge.sql",
"20260727000000_admin_viewer_identity.sql",
"20260806000000_personal_reports.sql",
];
const supabaseMigrations = [
"20260805030000_reconcile_rectification_v4_conversational_turns.sql",
"20260806010000_personal_reports.sql",
];
test("migration filenames use unique, correctly ordered 14-digit versions", () => {
const pattern = /^\d{14}_[a-z0-9_]+\.sql$/;
assert.match("20260806000000_personal_reports.sql", pattern);
assert.match("20260806010000_personal_reports.sql", pattern);
assert.ok(localMigrations[5] > localMigrations[4], "local migration must sort after existing ones");
assert.ok(supabaseMigrations[1] > supabaseMigrations[0], "supabase migration must sort after existing ones");
const versions = [...localMigrations, ...supabaseMigrations].map((name) => name.split("_")[0]);
assert.equal(new Set(versions).size, versions.length, "all migration versions must be unique");
});
test("both migrations define the same table shape with request_fingerprint after request_id", () => {
const parseColumns = (sql: string) => {
const body = sql.split("create table if not exists public.personal_reports")[1].split(");")[0];
const columns: string[] = [];
let depth = 0;
for (const raw of body.split("\n")) {
const line = raw.trim();
if (!line) continue;
const before = depth;
for (const ch of line) {
if (ch === "(") depth += 1;
if (ch === ")") depth -= 1;
}
const first = line.split(/\s+/)[0];
if (before === 1 && /^[a-z_][a-z0-9_]*$/.test(first) && first !== "check" && first !== "unique") {
columns.push(first);
}
}
return columns;
};
const expected = [
"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",
];
assert.deepEqual(parseColumns(localMigration), expected);
assert.deepEqual(parseColumns(supabaseMigration), expected);
});
test("request_fingerprint is not null and restricted to sha256 hex in both migrations", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /request_fingerprint text not null check \(request_fingerprint ~ '\^\[0-9a-f\]\{64\}\$'\)/);
}
});
test("status, report_type, presentation_mode and schema_version checks are identical", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /status text not null check \(status in \('generating', 'ready', 'failed'\)\)/);
assert.match(sql, /report_type text not null check \(report_type in \('personal_full', 'personal_thematic'\)\)/);
assert.match(sql, /presentation_mode text not null check \(presentation_mode in \('default', 'research'\)\)/);
assert.match(sql, /schema_version text not null check \(schema_version = 'report_document\.v1'\)/);
}
});
test("ready requires document, completion time and both hashes; failed requires failure_code", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /check \(\(status = 'ready'\) = \(report_document is not null\)\)/);
assert.match(sql, /check \(\(status = 'ready'\) = \(completed_at is not null\)\)/);
assert.match(sql, /check \(\(status = 'ready'\) = \(calculation_hash is not null\)\)/);
assert.match(sql, /check \(\(status = 'ready'\) = \(evidence_hash is not null\)\)/);
assert.match(sql, /check \(\(status = 'failed'\) = \(failure_code is not null\)\)/);
}
});
test("failure_code enum in SQL matches the service constant exactly", () => {
for (const sql of [localMigration, supabaseMigration]) {
const block = sql.match(/failure_code text check \(failure_code in \(([\s\S]+?)\)\)/)?.[1] ?? "";
const sqlCodes = [...block.matchAll(/'([a-z_]+)'/g)].map((match) => match[1]);
assert.deepEqual(sqlCodes, [...PERSONAL_REPORT_FAILURE_CODES]);
}
});
test("both migrations keep the (user_id, request_id) lock and one-generating-per-user index", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /unique \(user_id, request_id\)/);
assert.match(sql, /create unique index if not exists personal_reports_one_generating_per_user[\s\S]*where status = 'generating'/);
assert.match(sql, /references auth\.users\(id\) on delete cascade/);
}
});
test("RLS policies plus explicit owner grants: select/delete only, never insert/update", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /alter table public\.personal_reports enable row level security/);
assert.match(sql, /create policy personal_reports_select_own[\s\S]*for select[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/);
assert.match(sql, /create policy personal_reports_delete_own[\s\S]*for delete[\s\S]*to authenticated[\s\S]*using \(auth\.uid\(\) = user_id\)/);
// Policies alone do not grant privileges: explicit owner grants are required.
assert.match(sql, /grant select, delete on table public\.personal_reports to authenticated/);
assert.doesNotMatch(sql, /grant (insert|update) on table public\.personal_reports to authenticated/);
assert.match(sql, /grant select, insert, update, delete on table public\.personal_reports to service_role/);
}
});
test("least privilege: anon/public revoked and no direct admin_runtime body access", () => {
for (const sql of [localMigration, supabaseMigration]) {
assert.match(sql, /revoke all on table public\.personal_reports from public, anon, authenticated/);
}
assert.doesNotMatch(localMigration, /to admin_runtime/);
assert.doesNotMatch(supabaseMigration, /to admin_runtime/);
});
@@ -0,0 +1,392 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { ReportDocumentV1 } from "../src/lib/personal-report-contract.ts";
import {
createPersonalReportService,
PersonalReportServiceError,
PERSONAL_REPORT_FAILURE_CODES,
type PersonalReportDataClient,
type PersonalReportQueryBuilder,
type PersonalReportQueryResult,
} from "../src/lib/personal-report-service-core.ts";
const fixtureText = readFileSync(
new URL("../../tests/fixtures/personal_report_document.v1.json", import.meta.url),
"utf8",
);
const fixture: ReportDocumentV1 = JSON.parse(fixtureText);
const clone = () => structuredClone(fixture) as ReportDocumentV1;
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
type MemoryRow = Record<string, unknown> & {
id: string;
user_id: string;
request_id: string;
request_fingerprint: string;
};
type LoggedOperation = Readonly<{
operation: "select" | "insert" | "update" | "delete";
columns: readonly string[];
values: Readonly<Record<string, unknown>>;
}>;
class MemoryPersonalReportClient implements PersonalReportDataClient {
rows = new Map<string, MemoryRow>();
private nextId = 0;
/** Recorded operations for owner-scoping privilege assertions. */
log: LoggedOperation[] = [];
from(table: "personal_reports"): PersonalReportQueryBuilder {
if (table !== "personal_reports") throw new Error("unexpected table");
return this.chain(this.newState());
}
private newState() {
return {
operation: "select" as "select" | "insert" | "update" | "delete",
values: {} as Record<string, unknown>,
filters: [] as { column: string; value: unknown }[],
};
}
private chain(state: ReturnType<MemoryPersonalReportClient["newState"]>): PersonalReportQueryBuilder {
// One shared state object per chain; every chained method mutates it and
// returns a fresh view so .insert(...).select(...).single() works like the
// real Supabase/local builders.
const execute = (): PersonalReportQueryResult => {
this.log.push({ operation: state.operation, columns: state.filters.map((f) => f.column), values: state.values });
const matches = [...this.rows.values()].filter((row) =>
state.filters.every(({ column, value }) => row[column] === value),
);
if (state.operation === "select") {
return { data: matches, error: null };
}
if (state.operation === "insert") {
const duplicate = [...this.rows.values()].some((row) =>
row.user_id === state.values.user_id && row.request_id === state.values.request_id);
if (duplicate) {
return { data: null, error: { code: "23505", message: "duplicate key (user_id, request_id)" } };
}
const inflight = [...this.rows.values()].some((row) =>
row.user_id === state.values.user_id && row.status === "generating");
if (inflight) {
return { data: null, error: { code: "23505", message: "one generating per user" } };
}
const id = typeof state.values.id === "string"
? state.values.id
: `00000000-0000-4000-8000-${String(this.nextId++).padStart(12, "0")}`;
const row = {
evidence_hash: null,
calculation_hash: null,
failure_code: null,
completed_at: null,
report_document: null,
session_id: null,
chart_profile_id: null,
skill_source_commit: null,
...state.values,
id,
} as unknown as MemoryRow;
this.rows.set(id, row);
return { data: [row], error: null };
}
if (state.operation === "update") {
const updated: MemoryRow[] = [];
for (const row of matches) {
const next = { ...row, ...state.values } as MemoryRow;
this.rows.set(row.id, next);
updated.push(next);
}
return { data: updated, error: null };
}
const deletedCount = matches.length;
for (const row of matches) this.rows.delete(row.id);
return { data: null, error: null, count: deletedCount };
};
const wrap = (operation: typeof state.operation, values: Record<string, unknown> = {}) => {
state.operation = operation;
state.values = values;
return this.chain(state);
};
return {
select: () => this.chain(state),
insert: (row) => wrap("insert", row as Record<string, unknown>),
update: (values) => wrap("update", values as Record<string, unknown>),
delete: () => wrap("delete"),
eq: (column, value) => {
state.filters.push({ column, value });
return this.chain(state);
},
order: () => this.chain(state),
limit: () => this.chain(state),
maybeSingle: () => {
const result = execute();
const rows = Array.isArray(result.data) ? result.data : [];
return Promise.resolve(rows.length > 1
? { data: null, error: { code: "PGRST116", message: "unexpected row count" } }
: { data: rows[0] ?? null, error: null });
},
single: () => {
const result = execute();
const rows = Array.isArray(result.data) ? result.data : [];
return Promise.resolve(rows.length === 1
? { data: rows[0], error: null }
: { data: null, error: { code: "PGRST116", message: "unexpected row count" } });
},
then: (onfulfilled, onrejected) =>
Promise.resolve(execute()).then(onfulfilled, onrejected),
};
}
}
function setup(now = new Date("2026-08-06T08:00:00Z")) {
const client = new MemoryPersonalReportClient();
const service = createPersonalReportService(client, { now: () => now });
return { client, service, now };
}
const ownerId = "11111111-1111-4111-8111-111111111111";
const otherId = "22222222-2222-4222-8222-222222222222";
const requestId = "33333333-3333-4333-8333-333333333333";
const fingerprintA = sha256("request-intent-a");
const fingerprintB = sha256("request-intent-b");
function generatingInput(overrides: Record<string, unknown> = {}) {
return {
userId: ownerId,
requestId,
requestFingerprint: fingerprintA,
reportType: "personal_full" as const,
presentationMode: "default" as const,
requestedThemes: ["career", "marriage"],
skillSnapshotSha256: "a".repeat(64),
skillSourceCommit: "b".repeat(40),
...overrides,
};
}
test("createGenerating inserts a generating record with fingerprint", async () => {
const { service } = setup();
const result = await service.createGenerating(generatingInput());
assert.equal(result.kind, "created");
if (result.kind !== "created") return;
assert.equal(result.record.status, "generating");
assert.equal(result.record.requestFingerprint, fingerprintA);
assert.equal(result.record.schemaVersion, "report_document.v1");
assert.deepEqual(result.record.requestedThemes, ["career", "marriage"]);
assert.equal(result.record.reportDocument, null);
assert.equal(result.record.failureCode, null);
});
test("same requestId and fingerprint replays idempotently", async () => {
const { service } = setup();
const first = await service.createGenerating(generatingInput());
assert.equal(first.kind, "created");
const replay = await service.createGenerating(generatingInput());
assert.equal(replay.kind, "replayed");
if (first.kind !== "created" || replay.kind !== "replayed") return;
assert.equal(replay.record.id, first.record.id);
const byRequest = await service.getByUserAndRequestId(ownerId, requestId);
assert.equal(byRequest?.id, first.record.id);
});
test("same requestId with a different fingerprint is request_conflict, never replay", async () => {
const { service } = setup();
await service.createGenerating(generatingInput());
const conflict = await service.createGenerating(generatingInput({ requestFingerprint: fingerprintB }));
assert.equal(conflict.kind, "request_conflict");
if (conflict.kind !== "request_conflict") return;
assert.equal(conflict.record.requestFingerprint, fingerprintA);
});
test("a second in-flight generation for the same user is generation_in_progress", async () => {
const { service } = setup();
await service.createGenerating(generatingInput());
const second = await service.createGenerating(generatingInput({
requestId: "44444444-4444-4444-8444-444444444444",
requestFingerprint: sha256("other-intent"),
}));
assert.equal(second.kind, "generation_in_progress");
});
test("createGenerating validates fingerprints, uuids and hashes", async () => {
const { service } = setup();
const rejectsWithInvalidRequest = (input: Record<string, unknown>) =>
assert.rejects(
service.createGenerating(generatingInput(input)),
(error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_request",
);
await rejectsWithInvalidRequest({ requestFingerprint: "not-a-sha" });
await rejectsWithInvalidRequest({ requestId: "nope" });
await rejectsWithInvalidRequest({ skillSnapshotSha256: "short" });
await rejectsWithInvalidRequest({ reportType: "personal_unknown" });
await rejectsWithInvalidRequest({ userId: "not-a-uuid" });
});
test("reads are owner-scoped", async () => {
const { service } = setup();
await service.createGenerating(generatingInput());
const own = await service.getByUserAndRequestId(ownerId, requestId);
if (!own) throw new Error("missing own record");
assert.equal(await service.getByUserAndRequestId(otherId, requestId), null);
const byId = await service.getOwnedById(ownerId, own.id);
assert.equal(byId?.id, own.id);
assert.equal(await service.getOwnedById(otherId, own.id), null);
});
test("completeReady validates the contract and stores recomputed hashes", async () => {
const { service, now } = setup();
const created = await service.createGenerating(generatingInput());
assert.equal(created.kind, "created");
if (created.kind !== "created") return;
const document = clone();
document.reportId = created.record.id;
const ready = await service.completeReady(ownerId, created.record.id, document);
assert.equal(ready.status, "ready");
assert.equal(ready.reportDocument?.schemaVersion, "report_document.v1");
assert.equal(ready.evidenceHash, fixture.provenance.evidenceHash);
assert.equal(ready.calculationHash, document.provenance.calculationHash);
assert.equal(ready.completedAt, now.toISOString());
});
test("completeReady rejects document reportId mismatch and tampered evidence", async () => {
const { service } = setup();
const created = await service.createGenerating(generatingInput());
assert.equal(created.kind, "created");
if (created.kind !== "created") return;
const wrongId = clone();
wrongId.reportId = requestId;
await assert.rejects(
service.completeReady(ownerId, created.record.id, wrongId),
(error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document",
);
const tampered = clone();
tampered.reportId = created.record.id;
tampered.evidenceAppendix.calculationEvidence[0].value = "篡改后的证据值";
await assert.rejects(
service.completeReady(ownerId, created.record.id, tampered),
(error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_document",
);
});
test("completeReady rejects non-generating states and foreign owners", async () => {
const { service } = setup();
const created = await service.createGenerating(generatingInput());
assert.equal(created.kind, "created");
if (created.kind !== "created") return;
const document = clone();
document.reportId = created.record.id;
await service.markFailed(ownerId, created.record.id, "model_unavailable");
await assert.rejects(
service.completeReady(ownerId, created.record.id, document),
(error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state",
);
await assert.rejects(
service.completeReady(otherId, created.record.id, document),
(error: unknown) => error instanceof PersonalReportServiceError && error.code === "not_found",
);
});
test("markFailed uses only stable failure codes", async () => {
const { service } = setup();
const created = await service.createGenerating(generatingInput());
assert.equal(created.kind, "created");
if (created.kind !== "created") return;
await assert.rejects(
service.markFailed(ownerId, created.record.id, "model said something bad"),
(error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_failure_code",
);
const failed = await service.markFailed(ownerId, created.record.id, "model_unavailable");
assert.equal(failed.status, "failed");
assert.equal(failed.failureCode, "model_unavailable");
assert.equal(failed.reportDocument, null);
assert.equal(failed.completedAt, null);
await assert.rejects(
service.markFailed(ownerId, created.record.id, "report_rate_limited"),
(error: unknown) => error instanceof PersonalReportServiceError && error.code === "invalid_state",
);
assert.deepEqual(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",
]);
});
test("deleteOwned removes only the owner's record", async () => {
const { service, client } = setup();
const created = await service.createGenerating(generatingInput());
assert.equal(created.kind, "created");
if (created.kind !== "created") return;
assert.equal(await service.deleteOwned(otherId, created.record.id), false);
assert.ok(client.rows.has(created.record.id));
assert.equal(await service.deleteOwned(ownerId, created.record.id), true);
assert.equal(client.rows.has(created.record.id), false);
assert.equal(await service.deleteOwned(ownerId, created.record.id), false);
});
test("every write and read operation is owner-scoped", async () => {
const { service, client } = setup();
const created = await service.createGenerating(generatingInput());
assert.equal(created.kind, "created");
if (created.kind !== "created") return;
const document = clone();
document.reportId = created.record.id;
await service.getOwnedById(ownerId, created.record.id);
await service.getByUserAndRequestId(ownerId, requestId);
await service.completeReady(ownerId, created.record.id, document);
await service.deleteOwned(ownerId, created.record.id);
for (const entry of client.log) {
if (entry.operation === "insert") {
assert.equal(entry.values.user_id, ownerId, "insert row must carry the caller user_id");
} else {
assert.ok(entry.columns.includes("user_id"), `${entry.operation} missing user_id filter`);
}
}
const statefulWrites = client.log.filter((entry) => entry.operation === "update" || entry.operation === "delete");
assert.ok(statefulWrites.length > 0);
for (const entry of statefulWrites) {
assert.ok(entry.columns.includes("id"), `${entry.operation} missing id filter`);
}
});
test("failed records are never implicitly resurrected by createGenerating", async () => {
const { service } = setup();
const created = await service.createGenerating(generatingInput());
assert.equal(created.kind, "created");
if (created.kind !== "created") return;
await service.markFailed(ownerId, created.record.id, "model_unavailable");
// Same requestId + same fingerprint after failure: replay reports the failed
// record as-is; the service never flips it back to generating.
const replay = await service.createGenerating(generatingInput());
assert.equal(replay.kind, "replayed");
if (replay.kind !== "replayed") return;
assert.equal(replay.record.status, "failed");
assert.equal(replay.record.failureCode, "model_unavailable");
});
+771
View File
@@ -0,0 +1,771 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ReportDocument v1 contract validator (stdlib only).
Mirrors `contracts/personal-report/report-document.v1.schema.json` and
`frontend/src/lib/personal-report-contract.ts` (Zod). This module deliberately
uses only the Python standard library: the project does not vendor `jsonschema`,
so the validator below implements the schema semantics explicitly so that the
three sides (JSON Schema / Zod / Python) stay aligned and testable.
Public surface:
SCHEMA_VERSION, REPORT_CONTRACT_VERSION, MAX_SERIALIZED_BYTES
CLAIM_STATUSES, REPORT_TYPES, PRESENTATION_MODES, BIRTH_TIME_STATUSES
TECHNIQUE_STATUSES, CONFLICT_STATUSES, CHART_IDS
FAILURE_CODES (shared stable enum used by the persistence layer)
compute_evidence_hash(document) -> str
validate_report_document(document) -> ValidationResult
is_valid_report_document(document) -> bool
load_report_document(path) -> dict
parse_report_document_json(text) -> ValidationResult
CLI: python3 scripts/personal_report_contract.py <path-to-json>
"""
from __future__ import annotations
import hashlib
import json
import re
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
SCHEMA_VERSION = "report_document.v1"
REPORT_CONTRACT_VERSION = "1"
MAX_SERIALIZED_BYTES = 1_572_864 # 1.5 MiB hard cap on UTF-8 JSON serialization.
CLAIM_STATUSES = (
"multi_system_consensus",
"single_system_inference",
"parameter_sensitive",
"unclosed_divisional_chart",
"user_history_verification_required",
"blocked",
)
REPORT_TYPES = ("personal_full", "personal_thematic")
PRESENTATION_MODES = ("default", "research")
BIRTH_TIME_STATUSES = ("reported", "candidate", "accepted", "confirmed")
TECHNIQUE_STATUSES = ("verified", "partial", "blocked")
CONFLICT_STATUSES = ("unresolved", "partial", "resolved")
CHART_IDS = ("D1", "D9", "D10")
HOUSE_NUMBERS = tuple(range(1, 13))
# Stable failure-code enum shared with the personal_reports table check
# constraint and frontend/src/lib/personal-report-service.ts.
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",
)
UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
SHA1_PATTERN = re.compile(r"^[0-9a-f]{40}$")
ISO8601_PATTERN = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$"
)
EVIDENCE_ID_PATTERN = re.compile(r"^ev-[a-z0-9_-]{1,63}$")
SECTION_ID_PATTERN = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
TECHNIQUE_ID_PATTERN = re.compile(r"^[a-z0-9_.-]{1,80}$")
# ────────────────────────────────────────────────────────────────────────────
# Semantic guard patterns. These must stay byte-for-byte equivalent to the
# FORBIDDEN_PATTERNS list in frontend/src/lib/personal-report-contract.ts.
# ────────────────────────────────────────────────────────────────────────────
FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = (
("html_tag_open", r"<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b"),
("html_tag_close", r"</\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>"),
("event_handler", r"\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*="),
("executable_url", r"\b(?:javascript|vbscript|data:text/html|data:text/javascript|file):"),
("processing_instruction", r"<\?"),
("template_literal", r"\$\{"),
("stack_trace", r"(?:Traceback \(most recent call last\)|node:internal/| at (?:Object|async|node)\.)"),
("dunder_path", r"__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__"),
("process_env", r"\bprocess\.env\b"),
("unix_home_path", r"(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]"),
("windows_drive_path", r"^[a-zA-Z]:[\\/]"),
("jwt_token", r"\beyJ[A-Za-z0-9_-]{20,}\b"),
("secret_marker", r"\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b"),
("tool_trace", r"\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b"),
("chain_of_thought", r"\bchain[\s_-]?of[\s_-]?thought\b"),
)
# Deterministic-prediction phrases that must never appear inside a section whose
# claimStatus is "blocked". Must match the TS side exactly.
DETERMINISTIC_PHRASES: Tuple[str, ...] = (
"必然",
"必定",
"一定会",
"肯定会",
"绝对会",
"保证会",
"无疑将",
"百分之百",
"确定无疑",
"guaranteed",
"definitely will",
"certainly will",
"will certainly",
"is certain to",
)
# Bounded text limits shared with the schema.
TEXT_LIMITS: Dict[str, int] = {
"displayName": 120,
"birthPlaceLabel": 200,
"headline": 200,
"summary": 2000,
"priority": 200,
"chartTitle": 120,
"sign": 40,
"occupant": 40,
"planetName": 40,
"sectionTitle": 160,
"narrative": 4000,
"action": 400,
"caveat": 400,
"techniqueName": 160,
"notes": 500,
"conflictDescription": 1000,
"conflictImpact": 500,
"evidenceLabel": 160,
"evidenceValue": 500,
"evidenceSource": 200,
"blockedTechnique": 120,
"disclaimer": 2000,
}
ARRAY_LIMITS: Dict[str, int] = {
"priorities": 8,
"charts": 3,
"houses": 12,
"planets": 12,
"occupants": 12,
"thematicNarrative": 12,
"actions": 12,
"caveats": 12,
"evidenceRefs": 24,
"techniqueAudit": 100,
"conflicts": 50,
"calculationEvidence": 100,
"blockedTechniques": 100,
}
_RE = re.compile
def _compile(patterns: Tuple[Tuple[str, str], ...]) -> List[Tuple[str, "re.Pattern[str]"]]:
return [(name, _RE(expression, re.IGNORECASE)) for name, expression in patterns]
_FORBIDDEN_COMPILED = _compile(FORBIDDEN_PATTERNS)
_DETERMINISTIC_COMPILED = [
(_RE(phrase, re.IGNORECASE), phrase) for phrase in DETERMINISTIC_PHRASES
]
@dataclass
class ValidationResult:
valid: bool
errors: List[str] = field(default_factory=list)
def add(self, path: str, message: str) -> None:
self.errors.append(f"{path}: {message}")
# ────────────────────────────────────────────────────────────────────────────
# Evidence hash (deterministic, cross-language).
# ────────────────────────────────────────────────────────────────────────────
def _canonical_evidence(document: Dict[str, Any]) -> Dict[str, Any]:
"""Canonical evidence object. Defensive: malformed rows are reduced to
empty placeholders so validation never raises on arbitrary input; the
canonical hash only matches the TS side for structurally valid documents."""
appendix = document.get("evidenceAppendix")
appendix = appendix if isinstance(appendix, dict) else {}
def rows(key: str) -> List[Any]:
value = appendix.get(key)
return value if isinstance(value, list) else []
def safe(row: Any, key: str) -> Any:
return row.get(key) if isinstance(row, dict) else None
canonical = {
"techniqueAudit": [],
"conflicts": [],
"calculationEvidence": [],
}
for row in rows("techniqueAudit"):
entry = {
"id": safe(row, "id"),
"techniqueId": safe(row, "techniqueId"),
"techniqueName": safe(row, "techniqueName"),
"status": safe(row, "status"),
"used": safe(row, "used"),
}
if isinstance(row, dict) and "notes" in row:
entry["notes"] = row["notes"]
canonical["techniqueAudit"].append(entry)
for row in rows("conflicts"):
canonical["conflicts"].append({
"id": safe(row, "id"),
"description": safe(row, "description"),
"impact": safe(row, "impact"),
"status": safe(row, "status"),
})
for row in rows("calculationEvidence"):
canonical["calculationEvidence"].append({
"id": safe(row, "id"),
"label": safe(row, "label"),
"value": safe(row, "value"),
"source": safe(row, "source"),
})
return canonical
def _canonical_json(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=False)
def compute_evidence_hash(document: Dict[str, Any]) -> str:
"""Deterministic SHA-256 over the evidence appendix, matching the TS side."""
canonical = _canonical_evidence(document)
return hashlib.sha256(_canonical_json(canonical).encode("utf-8")).hexdigest()
def serialized_bytes(document: Dict[str, Any]) -> int:
return len(_canonical_json(document).encode("utf-8"))
# ────────────────────────────────────────────────────────────────────────────
# Semantic guards.
# ────────────────────────────────────────────────────────────────────────────
def forbidden_content_hits(value: str) -> List[str]:
hits: List[str] = []
for name, pattern in _FORBIDDEN_COMPILED:
if pattern.search(value):
hits.append(name)
return hits
def _text_guard(result: ValidationResult, path: str, value: Any) -> None:
if value is None:
return
if not isinstance(value, str):
result.add(path, f"expected string, got {type(value).__name__}")
return
hits = forbidden_content_hits(value)
if hits:
result.add(path, "forbidden content: " + ", ".join(sorted(set(hits))))
def _blocked_determinism(result: ValidationResult, path: str, claim_status: str, texts: List[Tuple[str, str]]) -> None:
if claim_status != "blocked":
return
for label, text in texts:
if not isinstance(text, str):
continue
for pattern, phrase in _DETERMINISTIC_COMPILED:
if pattern.search(text):
result.add(path, f"blocked section contains deterministic prediction ({label!r} matches {phrase!r})")
def _evidence_refs(result: ValidationResult, document: Dict[str, Any]) -> None:
appendix = document["evidenceAppendix"]
def ids(key: str) -> List[Any]:
value = appendix.get(key)
return value if isinstance(value, list) else []
known_ids: List[str] = []
seen: Dict[str, str] = {}
for key in ("techniqueAudit", "conflicts", "calculationEvidence"):
for index, row in enumerate(ids(key)):
if not isinstance(row, dict) or not isinstance(row.get("id"), str):
continue
evidence_id = row["id"]
if evidence_id in seen:
result.add(
f"evidenceAppendix.{key}[{index}].id",
f"duplicate evidence id {evidence_id!r} (also used in {seen[evidence_id]})",
)
else:
seen[evidence_id] = f"{key}[{index}]"
known_ids.append(evidence_id)
for index, section in enumerate(document.get("thematicNarrative", [])):
if not isinstance(section, dict):
continue
path = f"thematicNarrative[{index}].evidenceRefs"
refs = section.get("evidenceRefs")
if not isinstance(refs, list):
continue
for ref in refs:
if ref not in known_ids:
result.add(path, f"unknown evidence id {ref!r}")
# ────────────────────────────────────────────────────────────────────────────
# Structural validation (explicit schema implementation).
# ────────────────────────────────────────────────────────────────────────────
def _expect_object(result: ValidationResult, path: str, value: Any) -> Optional[Dict[str, Any]]:
if not isinstance(value, dict):
result.add(path, f"expected object, got {type(value).__name__}")
return None
return value
def _check_enum(result: ValidationResult, path: str, value: Any, allowed: Tuple[str, ...]) -> None:
if value not in allowed:
result.add(path, f"invalid value {value!r}; allowed: {', '.join(allowed)}")
def _check_text(result: ValidationResult, path: str, value: Any, max_length: int, min_length: int = 1) -> None:
if not isinstance(value, str):
result.add(path, f"expected string, got {type(value).__name__}")
return
if len(value) < min_length:
result.add(path, f"shorter than minimum length {min_length}")
if len(value) > max_length:
result.add(path, f"longer than maximum length {max_length}")
_text_guard(result, path, value)
def _check_uuid(result: ValidationResult, path: str, value: Any) -> None:
if not isinstance(value, str) or not UUID_PATTERN.match(value):
result.add(path, f"invalid uuid {value!r}")
def _check_hash(result: ValidationResult, path: str, value: Any, pattern: "re.Pattern[str]", length: int) -> None:
if not isinstance(value, str) or not pattern.match(value) or len(value) != length:
result.add(path, f"invalid hex hash {value!r}")
def _check_array(
result: ValidationResult,
path: str,
value: Any,
max_items: int,
min_items: int = 0,
) -> Optional[List[Any]]:
if not isinstance(value, list):
result.add(path, f"expected array, got {type(value).__name__}")
return None
if len(value) > max_items:
result.add(path, f"longer than maximum items {max_items}")
if len(value) < min_items:
result.add(path, f"shorter than minimum items {min_items}")
return value
def _check_keys(
result: ValidationResult,
path: str,
value: Dict[str, Any],
required: Tuple[str, ...],
allowed: Optional[Tuple[str, ...]] = None,
) -> None:
permitted = required if allowed is None else allowed
missing = [key for key in required if key not in value]
if missing:
result.add(path, "missing required keys: " + ", ".join(missing))
extra = [key for key in value if key not in permitted]
if extra:
result.add(path, "unexpected keys: " + ", ".join(extra))
def _validate_subject(result: ValidationResult, path: str, subject: Any) -> None:
value = _expect_object(result, path, subject)
if value is None:
return
_check_keys(result, path, value, ("displayName", "birthTimeStatus", "birthPlaceLabel"))
_check_text(result, f"{path}.displayName", value.get("displayName"), TEXT_LIMITS["displayName"])
_check_enum(result, f"{path}.birthTimeStatus", value.get("birthTimeStatus"), BIRTH_TIME_STATUSES)
_check_text(result, f"{path}.birthPlaceLabel", value.get("birthPlaceLabel"), TEXT_LIMITS["birthPlaceLabel"])
def _validate_provenance(result: ValidationResult, path: str, provenance: Any) -> None:
value = _expect_object(result, path, provenance)
if value is None:
return
_check_keys(
result,
path,
value,
("skillSourceCommit", "skillSnapshotSha256", "calculationHash", "evidenceHash", "reportContractVersion"),
)
commit = value.get("skillSourceCommit")
if commit is not None:
_check_hash(result, f"{path}.skillSourceCommit", commit, SHA1_PATTERN, 40)
_check_hash(result, f"{path}.skillSnapshotSha256", value.get("skillSnapshotSha256"), SHA256_PATTERN, 64)
_check_hash(result, f"{path}.calculationHash", value.get("calculationHash"), SHA256_PATTERN, 64)
_check_hash(result, f"{path}.evidenceHash", value.get("evidenceHash"), SHA256_PATTERN, 64)
if value.get("reportContractVersion") != REPORT_CONTRACT_VERSION:
result.add(f"{path}.reportContractVersion", f"must be {REPORT_CONTRACT_VERSION!r}")
def _validate_executive_summary(result: ValidationResult, path: str, summary: Any) -> None:
value = _expect_object(result, path, summary)
if value is None:
return
_check_keys(result, path, value, ("headline", "summary", "priorities", "overallClaimStatus"))
_check_text(result, f"{path}.headline", value.get("headline"), TEXT_LIMITS["headline"])
_check_text(result, f"{path}.summary", value.get("summary"), TEXT_LIMITS["summary"])
priorities = _check_array(result, f"{path}.priorities", value.get("priorities"), ARRAY_LIMITS["priorities"])
if priorities is not None:
for index, priority in enumerate(priorities):
_check_text(result, f"{path}.priorities[{index}]", priority, TEXT_LIMITS["priority"])
_check_enum(result, f"{path}.overallClaimStatus", value.get("overallClaimStatus"), CLAIM_STATUSES)
def _validate_chart(result: ValidationResult, path: str, chart: Any) -> None:
value = _expect_object(result, path, chart)
if value is None:
return
_check_keys(result, path, value, ("id", "title", "houses", "claimStatus"), ("id", "title", "houses", "claimStatus", "planets"))
_check_enum(result, f"{path}.id", value.get("id"), CHART_IDS)
_check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["chartTitle"])
_check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES)
houses = _check_array(result, f"{path}.houses", value.get("houses"), ARRAY_LIMITS["houses"])
seen_houses: List[int] = []
if houses is not None:
for index, house in enumerate(houses):
house_path = f"{path}.houses[{index}]"
house_value = _expect_object(result, house_path, house)
if house_value is None:
continue
_check_keys(result, house_path, house_value, ("houseNumber", "sign", "occupants"))
house_number = house_value.get("houseNumber")
if house_number in seen_houses:
result.add(house_path, f"duplicate houseNumber {house_number!r}")
if isinstance(house_number, int) and not isinstance(house_number, bool) and house_number in HOUSE_NUMBERS:
seen_houses.append(house_number)
elif not isinstance(house_number, bool):
result.add(f"{house_path}.houseNumber", f"must be integer 1..12, got {house_number!r}")
_check_text(result, f"{house_path}.sign", house_value.get("sign"), TEXT_LIMITS["sign"])
occupants = _check_array(result, f"{house_path}.occupants", house_value.get("occupants"), ARRAY_LIMITS["occupants"])
if occupants is not None:
for occupant_index, occupant in enumerate(occupants):
_check_text(result, f"{house_path}.occupants[{occupant_index}]", occupant, TEXT_LIMITS["occupant"])
planets = None
if "planets" in value:
planets = _check_array(result, f"{path}.planets", value.get("planets"), ARRAY_LIMITS["planets"])
if planets is not None:
for index, planet in enumerate(planets):
planet_path = f"{path}.planets[{index}]"
planet_value = _expect_object(result, planet_path, planet)
if planet_value is None:
continue
_check_keys(result, planet_path, planet_value, ("name", "sign", "longitudeDegrees", "houseNumber", "retrograde"))
_check_text(result, f"{planet_path}.name", planet_value.get("name"), TEXT_LIMITS["planetName"])
_check_text(result, f"{planet_path}.sign", planet_value.get("sign"), TEXT_LIMITS["sign"])
longitude = planet_value.get("longitudeDegrees")
if not isinstance(longitude, (int, float)) or isinstance(longitude, bool) or not (0 <= longitude < 360):
result.add(f"{planet_path}.longitudeDegrees", f"must be number 0..360 (360 excluded), got {longitude!r}")
house_number = planet_value.get("houseNumber")
if not isinstance(house_number, int) or isinstance(house_number, bool) or house_number not in HOUSE_NUMBERS:
result.add(f"{planet_path}.houseNumber", f"must be integer 1..12, got {house_number!r}")
if not isinstance(planet_value.get("retrograde"), bool):
result.add(f"{planet_path}.retrograde", "must be boolean")
def _validate_thematic_section(result: ValidationResult, path: str, section: Any) -> None:
value = _expect_object(result, path, section)
if value is None:
return
_check_keys(result, path, value, ("id", "title", "narrative", "actions", "caveats", "claimStatus", "evidenceRefs"))
section_id = value.get("id")
if not isinstance(section_id, str) or not SECTION_ID_PATTERN.match(section_id):
result.add(f"{path}.id", f"invalid section id {section_id!r}")
_check_text(result, f"{path}.title", value.get("title"), TEXT_LIMITS["sectionTitle"])
_check_text(result, f"{path}.narrative", value.get("narrative"), TEXT_LIMITS["narrative"])
actions = _check_array(result, f"{path}.actions", value.get("actions"), ARRAY_LIMITS["actions"])
if actions is not None:
for index, action in enumerate(actions):
_check_text(result, f"{path}.actions[{index}]", action, TEXT_LIMITS["action"])
caveats = _check_array(result, f"{path}.caveats", value.get("caveats"), ARRAY_LIMITS["caveats"])
if caveats is not None:
for index, caveat in enumerate(caveats):
_check_text(result, f"{path}.caveats[{index}]", caveat, TEXT_LIMITS["caveat"])
_check_enum(result, f"{path}.claimStatus", value.get("claimStatus"), CLAIM_STATUSES)
refs = _check_array(result, f"{path}.evidenceRefs", value.get("evidenceRefs"), ARRAY_LIMITS["evidenceRefs"])
if refs is not None:
for index, ref in enumerate(refs):
if not isinstance(ref, str) or not EVIDENCE_ID_PATTERN.match(ref):
result.add(f"{path}.evidenceRefs[{index}]", f"invalid evidence id {ref!r}")
def _validate_technique_row(result: ValidationResult, path: str, row: Any) -> None:
value = _expect_object(result, path, row)
if value is None:
return
_check_keys(result, path, value, ("id", "techniqueId", "techniqueName", "status", "used"), ("id", "techniqueId", "techniqueName", "status", "used", "notes"))
evidence_id = value.get("id")
if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id):
result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}")
technique_id = value.get("techniqueId")
if not isinstance(technique_id, str) or not TECHNIQUE_ID_PATTERN.match(technique_id):
result.add(f"{path}.techniqueId", f"invalid technique id {technique_id!r}")
_check_text(result, f"{path}.techniqueName", value.get("techniqueName"), TEXT_LIMITS["techniqueName"])
_check_enum(result, f"{path}.status", value.get("status"), TECHNIQUE_STATUSES)
if not isinstance(value.get("used"), bool):
result.add(f"{path}.used", "must be boolean")
if "notes" in value:
_check_text(result, f"{path}.notes", value.get("notes"), TEXT_LIMITS["notes"], min_length=0)
def _validate_conflict_row(result: ValidationResult, path: str, row: Any) -> None:
value = _expect_object(result, path, row)
if value is None:
return
_check_keys(result, path, value, ("id", "description", "impact", "status"))
evidence_id = value.get("id")
if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id):
result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}")
_check_text(result, f"{path}.description", value.get("description"), TEXT_LIMITS["conflictDescription"])
_check_text(result, f"{path}.impact", value.get("impact"), TEXT_LIMITS["conflictImpact"])
_check_enum(result, f"{path}.status", value.get("status"), CONFLICT_STATUSES)
def _validate_calculation_row(result: ValidationResult, path: str, row: Any) -> None:
value = _expect_object(result, path, row)
if value is None:
return
_check_keys(result, path, value, ("id", "label", "value", "source"))
evidence_id = value.get("id")
if not isinstance(evidence_id, str) or not EVIDENCE_ID_PATTERN.match(evidence_id):
result.add(f"{path}.id", f"invalid evidence id {evidence_id!r}")
_check_text(result, f"{path}.label", value.get("label"), TEXT_LIMITS["evidenceLabel"])
_check_text(result, f"{path}.value", value.get("value"), TEXT_LIMITS["evidenceValue"])
_check_text(result, f"{path}.source", value.get("source"), TEXT_LIMITS["evidenceSource"])
def _validate_evidence_appendix(result: ValidationResult, path: str, appendix: Any) -> None:
value = _expect_object(result, path, appendix)
if value is None:
return
_check_keys(
result,
path,
value,
("expandedByDefault", "techniqueAudit", "conflicts", "calculationEvidence", "blockedTechniques"),
)
if not isinstance(value.get("expandedByDefault"), bool):
result.add(f"{path}.expandedByDefault", "must be boolean")
rows = _check_array(result, f"{path}.techniqueAudit", value.get("techniqueAudit"), ARRAY_LIMITS["techniqueAudit"])
if rows is not None:
for index, row in enumerate(rows):
_validate_technique_row(result, f"{path}.techniqueAudit[{index}]", row)
rows = _check_array(result, f"{path}.conflicts", value.get("conflicts"), ARRAY_LIMITS["conflicts"])
if rows is not None:
for index, row in enumerate(rows):
_validate_conflict_row(result, f"{path}.conflicts[{index}]", row)
rows = _check_array(result, f"{path}.calculationEvidence", value.get("calculationEvidence"), ARRAY_LIMITS["calculationEvidence"])
if rows is not None:
for index, row in enumerate(rows):
_validate_calculation_row(result, f"{path}.calculationEvidence[{index}]", row)
blocked = _check_array(result, f"{path}.blockedTechniques", value.get("blockedTechniques"), ARRAY_LIMITS["blockedTechniques"])
if blocked is not None:
for index, technique in enumerate(blocked):
_check_text(result, f"{path}.blockedTechniques[{index}]", technique, TEXT_LIMITS["blockedTechnique"])
def _validate_chart_set(result: ValidationResult, charts: List[Any]) -> None:
"""Runtime chart-set semantics: exactly one D1, unique ids, D1 complete houses."""
seen_ids: List[str] = []
d1_chart: Optional[Dict[str, Any]] = None
for index, chart in enumerate(charts):
if not isinstance(chart, dict):
continue
chart_id = chart.get("id")
if isinstance(chart_id, str):
if chart_id in seen_ids:
result.add(f"charts[{index}].id", f"duplicate chart id {chart_id!r}")
seen_ids.append(chart_id)
if chart_id == "D1":
d1_chart = chart
d1_count = sum(1 for chart_id in seen_ids if chart_id == "D1")
if d1_count != 1:
result.add("charts", f"must contain exactly one D1 chart, found {d1_count}")
if d1_chart is not None:
houses = d1_chart.get("houses")
if not isinstance(houses, list):
result.add("charts[D1].houses", "D1 chart must declare houses")
return
numbers = []
for house in houses:
if isinstance(house, dict) and isinstance(house.get("houseNumber"), int) \
and not isinstance(house.get("houseNumber"), bool):
numbers.append(house["houseNumber"])
if sorted(numbers) != list(range(1, 13)):
result.add("charts[D1].houses", "D1 chart must contain all twelve house numbers 1..12 exactly once")
elif len(set(numbers)) != 12:
result.add("charts[D1].houses", "D1 chart contains duplicate house numbers")
def validate_report_document(document: Any) -> ValidationResult:
result = ValidationResult(valid=True)
if not isinstance(document, dict):
result.add("(root)", f"expected object, got {type(document).__name__}")
result.valid = False
return result
_check_keys(
result,
"(root)",
document,
(
"schemaVersion",
"reportId",
"reportType",
"presentationMode",
"generatedAt",
"subject",
"provenance",
"executiveSummary",
"charts",
"thematicNarrative",
"evidenceAppendix",
"disclaimer",
),
)
if document.get("schemaVersion") != SCHEMA_VERSION:
result.add("schemaVersion", f"must be {SCHEMA_VERSION!r}")
_check_uuid(result, "reportId", document.get("reportId"))
_check_enum(result, "reportType", document.get("reportType"), REPORT_TYPES)
_check_enum(result, "presentationMode", document.get("presentationMode"), PRESENTATION_MODES)
generated_at = document.get("generatedAt")
if not isinstance(generated_at, str) or not ISO8601_PATTERN.match(generated_at):
result.add("generatedAt", f"invalid ISO-8601 timestamp {generated_at!r}")
_validate_subject(result, "subject", document.get("subject"))
_validate_provenance(result, "provenance", document.get("provenance"))
_validate_executive_summary(result, "executiveSummary", document.get("executiveSummary"))
charts = _check_array(result, "charts", document.get("charts"), ARRAY_LIMITS["charts"], min_items=1)
if charts is not None:
for index, chart in enumerate(charts):
_validate_chart(result, f"charts[{index}]", chart)
_validate_chart_set(result, charts)
sections = _check_array(result, "thematicNarrative", document.get("thematicNarrative"), ARRAY_LIMITS["thematicNarrative"])
seen_section_ids: List[str] = []
if sections is not None:
for index, section in enumerate(sections):
_validate_thematic_section(result, f"thematicNarrative[{index}]", section)
section_id = section.get("id") if isinstance(section, dict) else None
if isinstance(section_id, str):
if section_id in seen_section_ids:
result.add(f"thematicNarrative[{index}].id", f"duplicate section id {section_id!r}")
seen_section_ids.append(section_id)
_validate_evidence_appendix(result, "evidenceAppendix", document.get("evidenceAppendix"))
_check_text(result, "disclaimer", document.get("disclaimer"), TEXT_LIMITS["disclaimer"])
# Semantic guards (only when the structural shape is usable; all access is
# defensive so arbitrary/malformed JSON can never raise).
if (
isinstance(document.get("evidenceAppendix"), dict)
and all(
isinstance(document["evidenceAppendix"].get(key), list)
for key in ("techniqueAudit", "conflicts", "calculationEvidence")
)
and isinstance(document.get("thematicNarrative"), list)
and isinstance(document.get("provenance"), dict)
and isinstance(document.get("charts"), list)
and isinstance(document.get("executiveSummary"), dict)
):
_evidence_refs(result, document)
expected_hash = compute_evidence_hash(document)
if document["provenance"].get("evidenceHash") != expected_hash:
result.add("provenance.evidenceHash", f"does not match computed evidence hash {expected_hash}")
blocked_texts: List[Tuple[str, str]] = []
summary = document["executiveSummary"]
if summary.get("overallClaimStatus") == "blocked":
blocked_texts.append(("headline", summary.get("headline")))
blocked_texts.append(("summary", summary.get("summary")))
for index, priority in enumerate(summary.get("priorities", [])):
blocked_texts.append((f"priorities[{index}]", priority))
for index, chart in enumerate(document["charts"]):
if isinstance(chart, dict) and chart.get("claimStatus") == "blocked":
blocked_texts.append((f"charts[{index}].title", chart.get("title")))
for index, section in enumerate(document["thematicNarrative"]):
if isinstance(section, dict) and section.get("claimStatus") == "blocked":
blocked_texts.append((f"thematicNarrative[{index}].title", section.get("title")))
blocked_texts.append((f"thematicNarrative[{index}].narrative", section.get("narrative")))
for action_index, action in enumerate(section.get("actions", [])):
blocked_texts.append((f"thematicNarrative[{index}].actions[{action_index}]", action))
for caveat_index, caveat in enumerate(section.get("caveats", [])):
blocked_texts.append((f"thematicNarrative[{index}].caveats[{caveat_index}]", caveat))
for path, text in blocked_texts:
_blocked_determinism(result, path, "blocked", [(path, text)])
size = serialized_bytes(document)
if size > MAX_SERIALIZED_BYTES:
result.add("(size)", f"serialized document is {size} bytes, exceeding {MAX_SERIALIZED_BYTES}")
result.valid = not result.errors
return result
def is_valid_report_document(document: Any) -> bool:
return validate_report_document(document).valid
def load_report_document(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
def parse_report_document_json(text: str) -> ValidationResult:
try:
document = json.loads(text)
except json.JSONDecodeError as error:
result = ValidationResult(valid=False)
result.add("(json)", f"invalid JSON: {error}")
return result
return validate_report_document(document)
def main(argv: Optional[List[str]] = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
if not args:
print("usage: python3 scripts/personal_report_contract.py <report-document.json>", file=sys.stderr)
return 2
path = args[0]
try:
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
except OSError as error:
print(f"unable to read {path}: {error}", file=sys.stderr)
return 2
result = parse_report_document_json(text)
if result.valid:
try:
size = serialized_bytes(json.loads(text))
except ValueError:
size = 0
print(f"valid: {path} ({size} bytes)")
return 0
print(f"invalid: {path}", file=sys.stderr)
for error in result.errors:
print(f" - {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+399
View File
@@ -0,0 +1,399 @@
{
"schemaVersion": "report_document.v1",
"reportId": "3f2b1c4a-8d6e-4f0a-9c2b-5a7e1d3f8b40",
"reportType": "personal_full",
"presentationMode": "default",
"generatedAt": "2026-08-06T08:00:00Z",
"subject": {
"displayName": "测试用户(合成资料)",
"birthTimeStatus": "confirmed",
"birthPlaceLabel": "北京(合成测试地点)"
},
"provenance": {
"skillSourceCommit": "9034e1967032d09c0a1b2c3d4e5f60718293a4b5",
"skillSnapshotSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"calculationHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"evidenceHash": "a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4",
"reportContractVersion": "1"
},
"executiveSummary": {
"headline": "事业与财富主题的多系统证据较一致",
"summary": "本报告基于服务端计算的本命盘与分盘证据。事业主题在 D10 与 A10 双层呈现一致信号,财富主题在 D2 与 D11 呈现中等强度信号;婚恋主题需用户历史事件核验;时机主题因外部参照未闭环而降级为 blocked,不给出确定性应期。",
"priorities": [
"先核验事业主题的三条历史事件证据",
"婚恋主题等待用户提供可核验的过往关系时间点",
"时机主题在外部参照闭环前不做确定性预测"
],
"overallClaimStatus": "multi_system_consensus"
},
"charts": [
{
"id": "D1",
"title": "本命盘 D1(Lahiri Ayanamsa)",
"claimStatus": "multi_system_consensus",
"houses": [
{
"houseNumber": 1,
"sign": "狮子座",
"occupants": [
"上升点"
]
},
{
"houseNumber": 2,
"sign": "处女座",
"occupants": []
},
{
"houseNumber": 3,
"sign": "天秤座",
"occupants": [
"水星"
]
},
{
"houseNumber": 4,
"sign": "天蝎座",
"occupants": [
"金星"
]
},
{
"houseNumber": 5,
"sign": "射手座",
"occupants": [
"太阳"
]
},
{
"houseNumber": 6,
"sign": "摩羯座",
"occupants": [
"火星"
]
},
{
"houseNumber": 7,
"sign": "水瓶座",
"occupants": []
},
{
"houseNumber": 8,
"sign": "双鱼座",
"occupants": [
"木星"
]
},
{
"houseNumber": 9,
"sign": "白羊座",
"occupants": [
"土星"
]
},
{
"houseNumber": 10,
"sign": "金牛座",
"occupants": [
"月亮"
]
},
{
"houseNumber": 11,
"sign": "双子座",
"occupants": []
},
{
"houseNumber": 12,
"sign": "巨蟹座",
"occupants": [
"罗睺"
]
}
],
"planets": [
{
"name": "太阳",
"sign": "射手座",
"longitudeDegrees": 248.5,
"houseNumber": 5,
"retrograde": false
},
{
"name": "月亮",
"sign": "金牛座",
"longitudeDegrees": 42.1,
"houseNumber": 10,
"retrograde": false
},
{
"name": "火星",
"sign": "摩羯座",
"longitudeDegrees": 288.3,
"houseNumber": 6,
"retrograde": false
},
{
"name": "水星",
"sign": "天秤座",
"longitudeDegrees": 190.7,
"houseNumber": 3,
"retrograde": false
},
{
"name": "木星",
"sign": "双鱼座",
"longitudeDegrees": 341.9,
"houseNumber": 8,
"retrograde": false
},
{
"name": "金星",
"sign": "天蝎座",
"longitudeDegrees": 222.4,
"houseNumber": 4,
"retrograde": false
},
{
"name": "土星",
"sign": "白羊座",
"longitudeDegrees": 11.8,
"houseNumber": 9,
"retrograde": true
},
{
"name": "罗睺",
"sign": "巨蟹座",
"longitudeDegrees": 102.6,
"houseNumber": 12,
"retrograde": true
},
{
"name": "计都",
"sign": "摩羯座",
"longitudeDegrees": 282.6,
"houseNumber": 6,
"retrograde": true
}
]
}
],
"thematicNarrative": [
{
"id": "career",
"title": "事业主题",
"narrative": "事业主题呈现中等偏强的信号:第十宫月亮与金牛座相关领域呼应,D10 与 A10 双层一致性较高。土星逆行提示职业节奏需要长期主义,不适合短期投机路径。",
"actions": [
"在金牛座相关行业或管理岗位方向收集更多历史证据",
"将晋升或转岗事件的时间点记录下来用于后续校准"
],
"caveats": [
"本主题结论依赖出生时间确认状态,当前为 confirmed",
"外部参照引擎未全部闭环,置信度上限为多系统一致而非绝对"
],
"claimStatus": "multi_system_consensus",
"evidenceRefs": [
"ev-career-d10-a10",
"ev-career-dasha-boundary",
"ev-shadbala-total"
]
},
{
"id": "wealth",
"title": "财富主题",
"narrative": "财富主题在 D2 与 D11 呈现中等强度信号,第二宫与第十一宫的证据链相互印证,但缺乏足够的过往财务事件校准,属于单系统推断加参数敏感的组合。",
"actions": [
"核对 D2 与 D11 的证据原始值是否与用户实际财务事件吻合"
],
"caveats": [
"财富结论不构成投资建议",
"未达到双系统一致时不得表述为确定结果"
],
"claimStatus": "parameter_sensitive",
"evidenceRefs": [
"ev-wealth-d2-d11",
"ev-ashtakavarga-wealth"
]
},
{
"id": "marriage",
"title": "婚恋主题",
"narrative": "婚恋主题已计算 D9 与 UL 相关证据,但本报告没有足够的用户历史关系事件来核验,需用户提供可核验时间点后重新评估。",
"actions": [
"提供过往重要关系事件的时间点以完成核验"
],
"caveats": [
"未经用户历史事件核验的婚恋结论不得视为最终结论"
],
"claimStatus": "user_history_verification_required",
"evidenceRefs": [
"ev-marriage-d9-ul"
]
},
{
"id": "timing",
"title": "时机主题",
"narrative": "时机主题需要 Vimshottari 与 Narayana Dasha 双轨交叉,但外部参照引擎尚未闭环,当前不给出具体应期,仅保留已计算的运限边界供后续校准使用。",
"actions": [
"等待外部参照闭环后重新评估应期"
],
"caveats": [
"当前不提供任何确定性时间预测",
"运限边界仅作为校准素材,不作为结论"
],
"claimStatus": "blocked",
"evidenceRefs": [
"ev-timing-vd-md-ad",
"ev-timing-narayana"
]
}
],
"evidenceAppendix": {
"expandedByDefault": false,
"techniqueAudit": [
{
"id": "ev-mevg-web",
"techniqueId": "mevg_global_web_evidence",
"techniqueName": "MEVG / Global Web Evidence",
"status": "partial",
"used": true,
"notes": "外部资料采集完成度 60%,来源分级已记录,冲突已进入 conflicts 列表"
},
{
"id": "ev-real-case",
"techniqueId": "real_case_calibration",
"techniqueName": "Real Case Calibration",
"status": "partial",
"used": true,
"notes": "10 个公开案例可回放:事业 5、婚恋 5;财富案例缺失"
},
{
"id": "ev-fbm",
"techniqueId": "functional_benefic_malefic",
"techniqueName": "Functional Benefic/Malefic",
"status": "verified",
"used": true
},
{
"id": "ev-vimshottari",
"techniqueId": "vimshottari_dasha",
"techniqueName": "Vimshottari Dasha",
"status": "verified",
"used": true
},
{
"id": "ev-narayana",
"techniqueId": "narayana_dasha",
"techniqueName": "Narayana Dasha",
"status": "verified",
"used": true,
"notes": "与 Vimshottari 双轨交叉"
},
{
"id": "ev-d10-a10",
"techniqueId": "d10_a10",
"techniqueName": "D10 + A10(事业分盘)",
"status": "verified",
"used": true
},
{
"id": "ev-d2-d11",
"techniqueId": "d2_d11",
"techniqueName": "D2 / D11(财富分盘)",
"status": "verified",
"used": true
},
{
"id": "ev-d9-ul",
"techniqueId": "d9_ul",
"techniqueName": "D9 + UL(婚恋分盘)",
"status": "verified",
"used": true
},
{
"id": "ev-shadbala",
"techniqueId": "shadbala",
"techniqueName": "Shadbala",
"status": "partial",
"used": true,
"notes": "内部总量一致;外部绝对数值对照未闭环"
},
{
"id": "ev-ashtakavarga",
"techniqueId": "ashtakavarga",
"techniqueName": "Ashtakavarga",
"status": "partial",
"used": true
}
],
"conflicts": [
{
"id": "ev-conflict-1",
"description": "Vimshottari 与 Narayana Dasha 在 2031 年前后的应期窗口存在分歧",
"impact": "时机主题降级为 blocked,不输出确定性应期",
"status": "unresolved"
},
{
"id": "ev-conflict-2",
"description": "Shadbala 内部总量与外部参照数值尚未对齐",
"impact": "Shadbala 行标记为 partial,不参与绝对强度结论",
"status": "partial"
}
],
"calculationEvidence": [
{
"id": "ev-career-d10-a10",
"label": "D10 与 A10 事业证据",
"value": "D10 月亮入第十宫,A10 同宫主星呼应;双盘一致",
"source": "服务端排盘 varga D10/A10(Lahiri)"
},
{
"id": "ev-career-dasha-boundary",
"label": "Vimshottari 大运边界",
"value": "当前大运:木星-土星;起始边界已记录",
"source": "服务端 Dasha 计算"
},
{
"id": "ev-wealth-d2-d11",
"label": "D2 与 D11 财富证据",
"value": "D2 第二宫与 D11 第十一宫证据链相互印证",
"source": "服务端排盘 varga D2/D11"
},
{
"id": "ev-ashtakavarga-wealth",
"label": "Ashtakavarga 财富相关宫位",
"value": "第二宫与第十一宫 Bhinna Ashtakavarga 点数高于均值",
"source": "服务端 Ashtakavarga 计算"
},
{
"id": "ev-marriage-d9-ul",
"label": "D9 与 UL 婚恋证据",
"value": "D9 第七宫状态与 UL 指示存在呼应,需用户核验",
"source": "服务端排盘 varga D9 + UL"
},
{
"id": "ev-timing-vd-md-ad",
"label": "Vimshottari 小运边界",
"value": "木星-土星-月亮 小运边界已计算,仅作校准素材",
"source": "服务端 Dasha 计算"
},
{
"id": "ev-timing-narayana",
"label": "Narayana Dasha 边界",
"value": "Narayana 大运边界已计算,与 Vimshottari 存在分歧",
"source": "服务端 Narayana Dasha 计算"
},
{
"id": "ev-shadbala-total",
"label": "Shadbala 总量",
"value": "各星 Shadbala 总量内部一致,外部对照 partial",
"source": "服务端 Shadbala 计算"
}
],
"blockedTechniques": [
"Sphuta 判定层(外部数值参照缺失)",
"Tajika 命名组合事件判定(无金标案例)"
]
},
"disclaimer": "本报告由计算引擎与模型共同生成,仅用于传统文化研究与个人参考,不构成医疗、法律或投资建议。任何涉及健康、法律、财务的决策请咨询对应领域的专业人士。报告中的时间预测均受证据完整度限制,blocked 内容不代表确定性结论。"
}
+295
View File
@@ -0,0 +1,295 @@
"""ReportDocument v1 contract validator tests (Python side).
Semantics are shared with contracts/personal-report/report-document.v1.schema.json
and frontend/src/lib/personal-report-contract.ts (Zod). Tests here pin the
runtime-enforced rules that JSON Schema draft-07 cannot express and prove the
validator never raises on arbitrary/malformed input.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from scripts.personal_report_contract import (
FAILURE_CODES,
MAX_SERIALIZED_BYTES,
compute_evidence_hash,
is_valid_report_document,
load_report_document,
main,
parse_report_document_json,
serialized_bytes,
validate_report_document,
)
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = ROOT / "tests" / "fixtures" / "personal_report_document.v1.json"
SUPABASE_MIGRATION = ROOT / "frontend" / "supabase" / "migrations" / "20260806010000_personal_reports.sql"
LOCAL_MIGRATION = ROOT / "frontend" / "db" / "migrations" / "20260806000000_personal_reports.sql"
@pytest.fixture(scope="module")
def fixture() -> dict:
return load_report_document(str(FIXTURE))
def test_fixture_is_valid_and_hash_is_recomputed(fixture: dict) -> None:
result = validate_report_document(fixture)
assert result.valid, result.errors
# evidenceHash is a deterministic recomputation, not a model self-report.
assert fixture["provenance"]["evidenceHash"] == compute_evidence_hash(fixture)
assert serialized_bytes(fixture) <= MAX_SERIALIZED_BYTES
def test_canonical_hash_is_cross_language_stable(fixture: dict) -> None:
# The fixture is read byte-for-byte by the TS tests too; both sides must
# compute the same sha256 over the canonical evidence appendix.
assert fixture["provenance"]["evidenceHash"] == (
"a830bcb22ce287d2637ffc91f9f951d5f24b19cb067712db2687fd23437f13f4"
)
@pytest.mark.parametrize(
"malformed",
[
None,
42,
"text",
[],
{},
{"schemaVersion": "report_document.v1"},
{"evidenceAppendix": {"techniqueAudit": ["not-a-row"], "conflicts": None, "calculationEvidence": [{"id": 5}]}},
{"evidenceAppendix": {"techniqueAudit": [{"id": "ev-a", "notes": {}}]}, "thematicNarrative": [{"id": 1}]},
{"charts": [{"id": "D1", "houses": "broken"}], "evidenceAppendix": {}, "thematicNarrative": "broken"},
],
)
def test_malformed_documents_return_invalid_never_raise(malformed: object) -> None:
result = validate_report_document(malformed)
assert result.valid is False
assert isinstance(result.errors, list)
def test_arbitrary_json_text_never_raises() -> None:
for text in ["", "not json", '{"a":', "[1,2,3]", '{"schemaVersion": 5}', "null", "42"]:
result = parse_report_document_json(text)
assert result.valid is False
def test_charts_require_exactly_one_d1(fixture: dict) -> None:
without_d1 = json.loads(json.dumps(fixture))
without_d1["charts"] = [chart for chart in without_d1["charts"] if chart["id"] != "D1"]
result = validate_report_document(without_d1)
assert result.valid is False
assert any("exactly one D1" in error for error in result.errors)
two_d1 = json.loads(json.dumps(fixture))
two_d1["charts"].append(json.loads(json.dumps(two_d1["charts"][0])))
result = validate_report_document(two_d1)
assert result.valid is False
assert any("duplicate chart id" in error for error in result.errors)
assert any("exactly one D1" in error for error in result.errors)
def test_d1_must_contain_all_twelve_houses(fixture: dict) -> None:
incomplete = json.loads(json.dumps(fixture))
incomplete["charts"][0]["houses"] = incomplete["charts"][0]["houses"][:11]
result = validate_report_document(incomplete)
assert result.valid is False
assert any("all twelve house numbers" in error for error in result.errors)
def test_duplicate_house_numbers_rejected(fixture: dict) -> None:
duplicated = json.loads(json.dumps(fixture))
duplicated["charts"][0]["houses"][11]["houseNumber"] = 1
result = validate_report_document(duplicated)
assert result.valid is False
assert any("duplicate houseNumber" in error for error in result.errors)
def test_longitude_is_half_open_interval(fixture: dict) -> None:
at_360 = json.loads(json.dumps(fixture))
at_360["charts"][0]["planets"][0]["longitudeDegrees"] = 360.0
result = validate_report_document(at_360)
assert result.valid is False
assert any("longitudeDegrees" in error for error in result.errors)
near_360 = json.loads(json.dumps(fixture))
near_360["charts"][0]["planets"][0]["longitudeDegrees"] = 359.999
near_360["provenance"]["evidenceHash"] = compute_evidence_hash(near_360)
assert validate_report_document(near_360).valid
def test_evidence_ids_must_be_globally_unique(fixture: dict) -> None:
duplicated = json.loads(json.dumps(fixture))
duplicated["evidenceAppendix"]["conflicts"][0]["id"] = duplicated["evidenceAppendix"]["techniqueAudit"][0]["id"]
duplicated["provenance"]["evidenceHash"] = compute_evidence_hash(duplicated)
result = validate_report_document(duplicated)
assert result.valid is False
assert any("duplicate evidence id" in error for error in result.errors)
def test_dangling_evidence_refs_rejected(fixture: dict) -> None:
dangling = json.loads(json.dumps(fixture))
dangling["thematicNarrative"][0]["evidenceRefs"] = ["ev-no-such-evidence"]
result = validate_report_document(dangling)
assert result.valid is False
assert any("unknown evidence id" in error for error in result.errors)
def test_evidence_hash_is_not_trusted_as_self_report(fixture: dict) -> None:
tampered = json.loads(json.dumps(fixture))
tampered["evidenceAppendix"]["calculationEvidence"][0]["value"] = "篡改后的证据值"
# Self-reported hash left unchanged: validator must recompute and reject.
result = validate_report_document(tampered)
assert result.valid is False
assert any("does not match computed evidence hash" in error for error in result.errors)
def test_blocked_sections_forbid_deterministic_predictions(fixture: dict) -> None:
deterministic = json.loads(json.dumps(fixture))
deterministic["thematicNarrative"][3]["narrative"] = "这个事件必然会发生在明年,一定会成功。"
result = validate_report_document(deterministic)
assert result.valid is False
assert any("blocked section contains deterministic prediction" in error for error in result.errors)
non_deterministic = json.loads(json.dumps(fixture))
non_deterministic["thematicNarrative"][3]["narrative"] = "需要更多历史事件校准后才能评估,具体应期暂不提供。"
non_deterministic["provenance"]["evidenceHash"] = compute_evidence_hash(non_deterministic)
assert validate_report_document(non_deterministic).valid
@pytest.mark.parametrize(
"poison",
[
"<script>alert(1)</script>",
"javascript:alert(1)",
"file:///Users/jesse/private/chart.json",
"参考 ${process.env.SUPABASE_SERVICE_ROLE_KEY}",
"onerror=alert(1)",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abc",
"node:internal/modules/cjs/loader",
"Traceback (most recent call last)",
"__dirname/secret",
"C:\\Users\\jesse\\chart.json",
"tool_call_id: call_123",
],
)
def test_forbidden_content_rejected(fixture: dict, poison: str) -> None:
poisoned = json.loads(json.dumps(fixture))
poisoned["disclaimer"] = poison
result = validate_report_document(poisoned)
assert result.valid is False
assert any("forbidden content" in error for error in result.errors)
def test_serialization_size_cap(fixture: dict) -> None:
oversized = json.loads(json.dumps(fixture))
oversized["disclaimer"] = "字" * (MAX_SERIALIZED_BYTES)
result = validate_report_document(oversized)
assert result.valid is False
assert any("exceeding" in error for error in result.errors)
def test_strict_keys_missing_and_extra(fixture: dict) -> None:
missing = json.loads(json.dumps(fixture))
del missing["disclaimer"]
result = validate_report_document(missing)
assert result.valid is False
assert any("missing required keys" in error for error in result.errors)
extra = json.loads(json.dumps(fixture))
extra["disclaimer"] = extra["disclaimer"]
extra["subject"]["hometown"] = "上海"
result = validate_report_document(extra)
assert result.valid is False
assert any("unexpected keys" in error for error in result.errors)
def test_json_object_key_order_is_not_validated(fixture: dict) -> None:
# JSON objects are unordered; fixed reader order is a display contract of
# the typed fields, never a key-order condition.
reordered = {key: fixture[key] for key in reversed(list(fixture.keys()))}
assert validate_report_document(reordered).valid
def test_failure_code_enum_matches_both_migrations() -> None:
for path in (SUPABASE_MIGRATION, LOCAL_MIGRATION):
sql = path.read_text(encoding="utf-8")
for code in FAILURE_CODES:
assert f"'{code}'" in sql, f"{code} missing from {path.name}"
assert sql.count("failure_code in") == 1
# Both migrations share the same stable enum.
supabase_codes = set(FAILURE_CODES)
local_sql = LOCAL_MIGRATION.read_text(encoding="utf-8")
assert all(f"'{code}'" in local_sql for code in supabase_codes)
def test_cli_exit_codes(fixture: dict) -> None:
assert main([str(FIXTURE)]) == 0
assert main([str(ROOT / "scripts" / "personal_report_contract.py")]) == 1
assert main([]) == 2
assert main([str(ROOT / "does-not-exist.json")]) == 2
def test_validator_accepts_synthetic_producer_output() -> None:
# A minimal-but-complete document produced without the fixture must pass.
from scripts.personal_report_contract import (
BIRTH_TIME_STATUSES,
CLAIM_STATUSES,
PRESENTATION_MODES,
REPORT_TYPES,
SCHEMA_VERSION,
)
houses = [
{"houseNumber": number, "sign": "狮子座", "occupants": []}
for number in range(1, 13)
]
document = {
"schemaVersion": SCHEMA_VERSION,
"reportId": "00000000-0000-4000-8000-000000000001",
"reportType": REPORT_TYPES[0],
"presentationMode": PRESENTATION_MODES[0],
"generatedAt": "2026-08-06T08:00:00Z",
"subject": {
"displayName": "合成用户",
"birthTimeStatus": BIRTH_TIME_STATUSES[0],
"birthPlaceLabel": "合成地点",
},
"provenance": {
"skillSourceCommit": None,
"skillSnapshotSha256": "c" * 64,
"calculationHash": "d" * 64,
"evidenceHash": "0" * 64,
"reportContractVersion": "1",
},
"executiveSummary": {
"headline": "摘要标题",
"summary": "摘要正文。",
"priorities": ["优先事项"],
"overallClaimStatus": CLAIM_STATUSES[0],
},
"charts": [{"id": "D1", "title": "本命盘", "houses": houses, "claimStatus": CLAIM_STATUSES[0]}],
"thematicNarrative": [],
"evidenceAppendix": {
"expandedByDefault": False,
"techniqueAudit": [
{
"id": "ev-audit",
"techniqueId": "d1_chart",
"techniqueName": "D1 本命盘",
"status": "verified",
"used": True,
}
],
"conflicts": [],
"calculationEvidence": [],
"blockedTechniques": [],
},
"disclaimer": "仅供研究参考。",
}
document["provenance"]["evidenceHash"] = compute_evidence_hash(document)
assert validate_report_document(document).valid