feat: add decision center document contract

This commit is contained in:
Codex
2026-05-21 07:40:35 +00:00
parent f1e5f21caf
commit 9911a9e736
11 changed files with 1259 additions and 87 deletions
@@ -0,0 +1,221 @@
export type DecisionDocumentType = "DCSN" | "GOAL" | "PLAN" | "RPRT" | "ACTN" | "ISSU" | "RETR" | "RQST" | "RESP" | "MINS";
export type DecisionDocumentPriority = "P0" | "P1" | "P2" | "P3";
export interface DecisionDocumentNumber {
docNo: string;
docType: DecisionDocumentType;
docPriority: DecisionDocumentPriority;
docYear: number;
docSeq: number;
}
export interface LegacyDocumentSource {
id?: string;
title?: string;
body?: string;
tags?: unknown;
}
export class DocumentContractError extends Error {
readonly detail: Record<string, string | number | boolean | null | string[]>;
constructor(message: string, detail: Record<string, string | number | boolean | null | string[]> = {}) {
super(message);
this.name = "DocumentContractError";
this.detail = detail;
}
}
export const decisionDocumentTypes: readonly DecisionDocumentType[] = ["DCSN", "GOAL", "PLAN", "RPRT", "ACTN", "ISSU", "RETR", "RQST", "RESP", "MINS"];
export const decisionDocumentPriorities: readonly DecisionDocumentPriority[] = ["P0", "P1", "P2", "P3"];
export const legacyDocumentNumberMap: Readonly<Record<string, string>> = {
dc_decision_thesis_unidesk_integration_rule: "DC-DCSN-P0-2026-001",
dc_goal_big_paper_submission: "DC-GOAL-P0-2026-001",
dc_goal_small_paper_submission_gate: "DC-GOAL-P0-2026-002",
};
const documentTypeValues = new Set<string>(decisionDocumentTypes);
const documentPriorityValues = new Set<string>(decisionDocumentPriorities);
const docNoSearchPattern = /\bDC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-(P[0-3])-(\d{4})-(\d{1,9})\b/iu;
function textValue(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value.trim();
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value).trim();
return "";
}
function legacyKey(value: string): string {
return value.trim().toLowerCase();
}
export function buildDocumentNumber(docType: DecisionDocumentType, docPriority: DecisionDocumentPriority, docYear: number, docSeq: number): string {
return `DC-${docType}-${docPriority}-${docYear}-${String(docSeq).padStart(3, "0")}`;
}
export function parseDocumentNumber(value: unknown, field = "docNo"): DecisionDocumentNumber {
const raw = textValue(value).toUpperCase();
const match = raw.match(/^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-(P[0-3])-(\d{4})-(\d{1,9})$/u);
if (match === null) {
throw new DocumentContractError(`${field} must match DC-<TYPE>-<PRIORITY>-<YEAR>-<SEQ>`, {
field,
value: textValue(value),
allowedTypes: [...decisionDocumentTypes],
allowedPriorities: [...decisionDocumentPriorities],
example: "DC-GOAL-P0-2026-001",
});
}
const docSeq = Number(match[4]);
const docYear = Number(match[3]);
if (!Number.isInteger(docSeq) || docSeq <= 0) {
throw new DocumentContractError(`${field} sequence must be a positive integer`, { field, value: textValue(value) });
}
return {
docNo: buildDocumentNumber(match[1] as DecisionDocumentType, match[2] as DecisionDocumentPriority, docYear, docSeq),
docType: match[1] as DecisionDocumentType,
docPriority: match[2] as DecisionDocumentPriority,
docYear,
docSeq,
};
}
export function tryParseDocumentNumber(value: unknown): DecisionDocumentNumber | null {
try {
return parseDocumentNumber(value);
} catch {
return null;
}
}
export function parseDocumentType(value: unknown, field = "docType"): DecisionDocumentType {
const raw = textValue(value).toUpperCase();
if (!documentTypeValues.has(raw)) {
throw new DocumentContractError(`${field} must be one of: ${decisionDocumentTypes.join(", ")}`, {
field,
value: textValue(value),
allowed: [...decisionDocumentTypes],
});
}
return raw as DecisionDocumentType;
}
export function parseOptionalDocumentType(value: unknown, field = "docType"): DecisionDocumentType | "" {
const raw = textValue(value);
return raw ? parseDocumentType(raw, field) : "";
}
export function parseDocumentPriority(value: unknown, field = "docPriority"): DecisionDocumentPriority {
const raw = textValue(value).toUpperCase();
if (!documentPriorityValues.has(raw)) {
throw new DocumentContractError(`${field} must be one of: ${decisionDocumentPriorities.join(", ")}`, {
field,
value: textValue(value),
allowed: [...decisionDocumentPriorities],
});
}
return raw as DecisionDocumentPriority;
}
export function parseOptionalDocumentPriority(value: unknown, field = "docPriority"): DecisionDocumentPriority | "" {
const raw = textValue(value);
return raw ? parseDocumentPriority(raw, field) : "";
}
export function parseDocumentYear(value: unknown, field = "docYear"): number {
const raw = textValue(value);
const year = Number(raw);
if (!/^\d{4}$/u.test(raw) || !Number.isInteger(year) || year < 1970 || year > 2100) {
throw new DocumentContractError(`${field} must be a four-digit year between 1970 and 2100`, { field, value: raw });
}
return year;
}
export function parseDocumentSequence(value: unknown, field = "docSeq"): number {
const raw = textValue(value);
const seq = Number(raw);
if (!/^\d+$/u.test(raw) || !Number.isInteger(seq) || seq <= 0 || seq > 999_999_999) {
throw new DocumentContractError(`${field} must be a positive integer`, { field, value: raw });
}
return seq;
}
export function parseIssuedAt(value: unknown, field = "issuedAt"): string {
const raw = textValue(value);
if (!raw) return "";
const date = /^\d{4}-\d{2}-\d{2}$/u.test(raw) ? new Date(`${raw}T00:00:00.000Z`) : new Date(raw);
if (Number.isNaN(date.getTime())) {
throw new DocumentContractError(`${field} must be an ISO timestamp or YYYY-MM-DD date`, { field, value: raw });
}
return date.toISOString();
}
export function normalizeDocumentNumberList(value: unknown, field: string): string[] {
if (value === null || value === undefined || value === "") return [];
const rawItems = typeof value === "string"
? value.split(",")
: Array.isArray(value)
? value
: null;
if (rawItems === null) throw new DocumentContractError(`${field} must be a document number or array of document numbers`, { field });
const normalized = rawItems
.map((item) => textValue(item))
.filter(Boolean)
.map((item) => parseDocumentNumber(item, field).docNo);
return [...new Set(normalized)].slice(0, 100);
}
function docNoFromText(text: string, options: { prefixOnly?: boolean } = {}): DecisionDocumentNumber | null {
const trimmed = text.trim();
if (!trimmed) return null;
const docNoTagged = trimmed.match(/^doc-no\s*:\s*(DC-[A-Z0-9-]+)/iu);
if (docNoTagged !== null) return tryParseDocumentNumber(docNoTagged[1] ?? "");
const prefix = trimmed.match(/^\[?\s*(DC-[A-Z0-9-]+)\s*\]?/iu);
if (prefix !== null) return tryParseDocumentNumber(prefix[1] ?? "");
if (options.prefixOnly === true) return null;
const match = trimmed.match(docNoSearchPattern);
return match === null ? null : tryParseDocumentNumber(match[0]);
}
function mappedLegacyDocNo(value: string): DecisionDocumentNumber | null {
const mapped = legacyDocumentNumberMap[legacyKey(value)];
return mapped === undefined ? null : parseDocumentNumber(mapped);
}
function firstParagraph(body: string): string {
return body
.replace(/\r\n?/gu, "\n")
.split(/\n\s*\n/gu)
.map((part) => part.trim())
.find(Boolean) ?? "";
}
export function extractDocumentNumberFromLegacy(source: LegacyDocumentSource): DecisionDocumentNumber | null {
const id = textValue(source.id);
const idMapped = mappedLegacyDocNo(id);
if (idMapped !== null) return idMapped;
const tags = Array.isArray(source.tags) ? source.tags.map((tag) => textValue(tag)).filter(Boolean) : [];
for (const tag of tags) {
const mapped = mappedLegacyDocNo(tag);
if (mapped !== null) return mapped;
const tagged = tag.match(/^doc-no\s*:\s*(.+)$/iu);
if (tagged !== null) {
const parsed = tryParseDocumentNumber(tagged[1] ?? "");
if (parsed !== null) return parsed;
}
const parsed = docNoFromText(tag);
if (parsed !== null) return parsed;
}
const title = textValue(source.title);
const titleMapped = mappedLegacyDocNo(title.split(/\s+/u)[0] ?? "");
if (titleMapped !== null) return titleMapped;
const titleDocNo = docNoFromText(title, { prefixOnly: true });
if (titleDocNo !== null) return titleDocNo;
const paragraph = firstParagraph(textValue(source.body));
const paragraphMapped = Object.entries(legacyDocumentNumberMap).find(([legacy]) => paragraph.toLowerCase().includes(legacy));
if (paragraphMapped !== undefined) return parseDocumentNumber(paragraphMapped[1]);
return docNoFromText(paragraph);
}
@@ -1,6 +1,24 @@
import { createHash, randomUUID } from "node:crypto";
import postgres from "postgres";
import { createHourlyJsonlWriter, logRetentionBytesForService } from "../../../shared/src/rotating-jsonl";
import {
DocumentContractError,
buildDocumentNumber,
decisionDocumentPriorities,
decisionDocumentTypes,
extractDocumentNumberFromLegacy,
normalizeDocumentNumberList,
parseDocumentNumber,
parseDocumentPriority,
parseDocumentSequence,
parseDocumentType,
parseDocumentYear,
parseIssuedAt,
tryParseDocumentNumber,
type DecisionDocumentNumber,
type DecisionDocumentPriority,
type DecisionDocumentType,
} from "./document-contract";
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
type JsonRecord = Record<string, JsonValue>;
@@ -9,6 +27,7 @@ type DecisionRecordType = "meeting" | "decision" | "goal" | "external_goal" | "i
type RequirementRecordType = Exclude<DecisionRecordType, "meeting">;
type DecisionRecordLevel = "G0" | "G1" | "G2" | "G3" | "P0" | "P1" | "P2" | "P3" | "none";
type DecisionRecordStatus = "active" | "blocked" | "parked" | "done";
type SqlExecutor = postgres.Sql | postgres.TransactionSql;
interface RuntimeConfig {
host: string;
@@ -28,6 +47,16 @@ interface DecisionRecordRow {
linked_goal_id: string | null;
tags: JsonValue;
evidence_links: JsonValue;
doc_no: string;
doc_type: string;
doc_priority: string;
doc_year: number | null;
doc_seq: number | null;
signer: string;
issued_at: Date | string | null;
effective_scope: string;
supersedes: JsonValue;
superseded_by: JsonValue;
source: string;
source_session: string;
issue_id: string;
@@ -49,6 +78,16 @@ interface DecisionRecord extends JsonRecord {
linkedGoalId: string | null;
tags: string[];
evidenceLinks: string[];
docNo: string;
docType: string;
docPriority: string;
docYear: number | null;
docSeq: number | null;
signer: string;
issuedAt: string;
effectiveScope: string;
supersedes: string[];
supersededBy: string[];
source: string;
sourceSession: string;
issueId: string;
@@ -194,16 +233,50 @@ function jsonResponse(body: JsonValue, status = 200): Response {
function errorToJson(error: unknown): JsonRecord {
if (error instanceof HttpError) return { name: error.name, message: error.message, status: error.status, detail: error.detail };
if (error instanceof DocumentContractError) return { name: error.name, message: error.message, status: 400, detail: error.detail };
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack || "" };
return { message: String(error) };
}
function databaseErrorToHttp(error: unknown): HttpError | null {
const record = typeof error === "object" && error !== null ? error as Record<string, unknown> : {};
const code = typeof record.code === "string" ? record.code : "";
const constraint = typeof record.constraint_name === "string"
? record.constraint_name
: typeof record.constraint === "string"
? record.constraint
: "";
const message = error instanceof Error ? error.message : String(error);
if (code === "23505" && /doc_no|document/iu.test(`${constraint} ${message}`)) {
return new HttpError(409, "document number already exists", {
code: "doc_no_conflict",
constraint,
});
}
return null;
}
function responseError(error: unknown): HttpError | DocumentContractError | unknown {
return databaseErrorToHttp(error) ?? error;
}
function errorResponse(error: unknown): Response {
const status = error instanceof HttpError ? error.status : 500;
const body = error instanceof HttpError
? { ok: false, error: error.message, ...error.detail }
: { ok: false, error: error instanceof Error ? error.message : String(error) };
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(error) });
const normalized = responseError(error);
const status = normalized instanceof HttpError ? normalized.status : normalized instanceof DocumentContractError ? 400 : 500;
const detail = normalized instanceof HttpError || normalized instanceof DocumentContractError ? normalized.detail : {};
const message = normalized instanceof Error ? normalized.message : String(normalized);
const body = {
ok: false,
error: message,
...detail,
errorInfo: {
name: normalized instanceof Error ? normalized.name : "Error",
message,
status,
detail,
},
};
log(status >= 500 ? "error" : "warn", "request_failed", { status, error: errorToJson(normalized) });
return jsonResponse(body, status);
}
@@ -271,6 +344,182 @@ function asStringArray(value: unknown, field: string): string[] {
return [...new Set(items)];
}
function jsonStringArray(value: JsonValue): string[] {
return Array.isArray(value) ? value.map(String).filter(Boolean) : [];
}
function hasAny(input: Record<string, unknown>, fields: string[]): boolean {
return fields.some((field) => field in input);
}
function currentUtcYear(): number {
return new Date().getUTCFullYear();
}
function priorityFromLevel(level: DecisionRecordLevel): DecisionDocumentPriority | "" {
return level === "P0" || level === "P1" || level === "P2" || level === "P3" ? level : "";
}
function documentTypeFromRecordType(type: DecisionRecordType): DecisionDocumentType {
if (type === "decision") return "DCSN";
if (type === "goal" || type === "external_goal" || type === "internal_goal") return "GOAL";
if (type === "meeting") return "MINS";
if (type === "blocker" || type === "debt") return "ISSU";
return "RPRT";
}
function existingDocumentNumber(record: DecisionRecord): DecisionDocumentNumber | null {
return record.docNo ? tryParseDocumentNumber(record.docNo) : null;
}
function inputDocumentNumber(input: Record<string, unknown>): DecisionDocumentNumber | null {
const raw = asString(input.docNo ?? input.docNumber ?? input.documentNo ?? input.documentNumber);
return raw ? parseDocumentNumber(raw, "docNo") : null;
}
function assertDocumentComponentsMatch(input: Record<string, unknown>, doc: DecisionDocumentNumber): void {
if ("docType" in input && parseDocumentType(input.docType) !== doc.docType) throw new HttpError(400, "docType does not match docNo", { docNo: doc.docNo, docType: asString(input.docType), expected: doc.docType });
if ("docPriority" in input && parseDocumentPriority(input.docPriority) !== doc.docPriority) throw new HttpError(400, "docPriority does not match docNo", { docNo: doc.docNo, docPriority: asString(input.docPriority), expected: doc.docPriority });
if ("docYear" in input && parseDocumentYear(input.docYear) !== doc.docYear) throw new HttpError(400, "docYear does not match docNo", { docNo: doc.docNo, docYear: asString(input.docYear), expected: doc.docYear });
if ("docSeq" in input && parseDocumentSequence(input.docSeq) !== doc.docSeq) throw new HttpError(400, "docSeq does not match docNo", { docNo: doc.docNo, docSeq: asString(input.docSeq), expected: doc.docSeq });
}
async function nextDocumentSequence(client: SqlExecutor, docType: DecisionDocumentType, docPriority: DecisionDocumentPriority, docYear: number): Promise<number> {
await client`LOCK TABLE decision_center_records IN SHARE ROW EXCLUSIVE MODE`;
const rows = await client<{ next_seq: number }[]>`
SELECT COALESCE(max(doc_seq), 0)::int + 1 AS next_seq
FROM decision_center_records
WHERE doc_type = ${docType}
AND doc_priority = ${docPriority}
AND doc_year = ${docYear}
`;
return Number(rows[0]?.next_seq ?? 1);
}
function documentInputPresent(input: Record<string, unknown>): boolean {
return hasAny(input, ["docNo", "docNumber", "documentNo", "documentNumber", "docType", "docPriority", "docYear", "docSeq"]);
}
async function documentNumberForCreate(
client: SqlExecutor,
input: Record<string, unknown>,
draft: { id: string; type: DecisionRecordType; level: DecisionRecordLevel; title: string; body: string; tags: string[] },
): Promise<DecisionDocumentNumber | null> {
const explicitDocNo = inputDocumentNumber(input);
if (explicitDocNo !== null) {
assertDocumentComponentsMatch(input, explicitDocNo);
return explicitDocNo;
}
const legacyDocNo = extractDocumentNumberFromLegacy(draft);
if (legacyDocNo !== null && !documentInputPresent(input)) return legacyDocNo;
if (!documentInputPresent(input)) return null;
const docType = "docType" in input ? parseDocumentType(input.docType) : documentTypeFromRecordType(draft.type);
const fallbackPriority = priorityFromLevel(draft.level);
if (!("docPriority" in input) && !fallbackPriority) {
throw new HttpError(400, "docPriority is required when assigning a document number without docNo", { allowed: [...decisionDocumentPriorities] });
}
const docPriority = "docPriority" in input ? parseDocumentPriority(input.docPriority) : fallbackPriority as DecisionDocumentPriority;
const docYear = "docYear" in input ? parseDocumentYear(input.docYear) : currentUtcYear();
const docSeq = "docSeq" in input ? parseDocumentSequence(input.docSeq) : await nextDocumentSequence(client, docType, docPriority, docYear);
return parseDocumentNumber(buildDocumentNumber(docType, docPriority, docYear, docSeq));
}
async function documentNumberForUpdate(
client: SqlExecutor,
input: Record<string, unknown>,
existing: DecisionRecord,
nextType: DecisionRecordType,
nextLevel: DecisionRecordLevel,
): Promise<DecisionDocumentNumber | null> {
const explicitDocNo = inputDocumentNumber(input);
if (explicitDocNo !== null) {
assertDocumentComponentsMatch(input, explicitDocNo);
return explicitDocNo;
}
const existingDoc = existingDocumentNumber(existing);
if (!documentInputPresent(input)) return existingDoc;
const docType = "docType" in input
? parseDocumentType(input.docType)
: existingDoc?.docType ?? documentTypeFromRecordType(nextType);
const fallbackPriority = existingDoc?.docPriority ?? priorityFromLevel(nextLevel);
if (!("docPriority" in input) && !fallbackPriority) {
throw new HttpError(400, "docPriority is required when assigning a document number without docNo", { allowed: [...decisionDocumentPriorities] });
}
const docPriority = "docPriority" in input ? parseDocumentPriority(input.docPriority) : fallbackPriority as DecisionDocumentPriority;
const docYear = "docYear" in input ? parseDocumentYear(input.docYear) : existingDoc?.docYear ?? currentUtcYear();
const keepsExistingSeq = existingDoc !== null
&& existingDoc.docType === docType
&& existingDoc.docPriority === docPriority
&& existingDoc.docYear === docYear;
const docSeq = "docSeq" in input
? parseDocumentSequence(input.docSeq)
: keepsExistingSeq
? existingDoc.docSeq
: await nextDocumentSequence(client, docType, docPriority, docYear);
return parseDocumentNumber(buildDocumentNumber(docType, docPriority, docYear, docSeq));
}
function parseDocumentDateColumn(value: string): string | null {
return value ? parseIssuedAt(value) : null;
}
function documentColumnsForInsert(doc: DecisionDocumentNumber | null, input: Record<string, unknown>): {
docNo: string;
docType: string;
docPriority: string;
docYear: number | null;
docSeq: number | null;
signer: string;
issuedAt: string | null;
effectiveScope: string;
supersedes: string[];
supersededBy: string[];
} {
return {
docNo: doc?.docNo ?? "",
docType: doc?.docType ?? "",
docPriority: doc?.docPriority ?? "",
docYear: doc?.docYear ?? null,
docSeq: doc?.docSeq ?? null,
signer: asString(input.signer),
issuedAt: parseDocumentDateColumn(asString(input.issuedAt)),
effectiveScope: asString(input.effectiveScope),
supersedes: normalizeDocumentNumberList(input.supersedes, "supersedes"),
supersededBy: normalizeDocumentNumberList(input.supersededBy, "supersededBy"),
};
}
function documentColumnsForUpdate(doc: DecisionDocumentNumber | null, input: Record<string, unknown>, existing: DecisionRecord): {
docNo: string;
docType: string;
docPriority: string;
docYear: number | null;
docSeq: number | null;
signer: string;
issuedAt: string | null;
effectiveScope: string;
supersedes: string[];
supersededBy: string[];
} {
return {
docNo: doc?.docNo ?? "",
docType: doc?.docType ?? "",
docPriority: doc?.docPriority ?? "",
docYear: doc?.docYear ?? null,
docSeq: doc?.docSeq ?? null,
signer: "signer" in input ? asString(input.signer) : existing.signer,
issuedAt: "issuedAt" in input ? parseDocumentDateColumn(asString(input.issuedAt)) : existing.issuedAt || null,
effectiveScope: "effectiveScope" in input ? asString(input.effectiveScope) : existing.effectiveScope,
supersedes: "supersedes" in input ? normalizeDocumentNumberList(input.supersedes, "supersedes") : existing.supersedes,
supersededBy: "supersededBy" in input ? normalizeDocumentNumberList(input.supersededBy, "supersededBy") : existing.supersededBy,
};
}
function summaryFromBody(body: string): string {
return body
.split("\n")
@@ -294,8 +543,18 @@ function recordFromRow(row: DecisionRecordRow, options: { includeBody?: boolean
body: includeBody ? body : "",
priority: row.level,
linkedGoalId: row.linked_goal_id,
tags: Array.isArray(row.tags) ? row.tags.map(String) : [],
evidenceLinks: Array.isArray(row.evidence_links) ? row.evidence_links.map(String) : [],
tags: jsonStringArray(row.tags),
evidenceLinks: jsonStringArray(row.evidence_links),
docNo: row.doc_no || "",
docType: row.doc_type || "",
docPriority: row.doc_priority || "",
docYear: row.doc_year === null ? null : Number(row.doc_year),
docSeq: row.doc_seq === null ? null : Number(row.doc_seq),
signer: row.signer || "",
issuedAt: iso(row.issued_at),
effectiveScope: row.effective_scope || "",
supersedes: jsonStringArray(row.supersedes),
supersededBy: jsonStringArray(row.superseded_by),
source: row.source,
sourceSession: row.source_session,
issueId: row.issue_id,
@@ -377,6 +636,16 @@ async function ensureSchema(): Promise<void> {
linked_goal_id TEXT,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
evidence_links JSONB NOT NULL DEFAULT '[]'::jsonb,
doc_no TEXT NOT NULL DEFAULT '',
doc_type TEXT NOT NULL DEFAULT '',
doc_priority TEXT NOT NULL DEFAULT '',
doc_year INTEGER,
doc_seq INTEGER,
signer TEXT NOT NULL DEFAULT '',
issued_at TIMESTAMPTZ,
effective_scope TEXT NOT NULL DEFAULT '',
supersedes JSONB NOT NULL DEFAULT '[]'::jsonb,
superseded_by JSONB NOT NULL DEFAULT '[]'::jsonb,
source TEXT NOT NULL DEFAULT '',
source_session TEXT NOT NULL DEFAULT '',
issue_id TEXT NOT NULL DEFAULT '',
@@ -391,16 +660,54 @@ async function ensureSchema(): Promise<void> {
`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT ''`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS issue_id TEXT NOT NULL DEFAULT ''`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_no TEXT NOT NULL DEFAULT ''`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_type TEXT NOT NULL DEFAULT ''`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_priority TEXT NOT NULL DEFAULT ''`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_year INTEGER`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS doc_seq INTEGER`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS signer TEXT NOT NULL DEFAULT ''`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS issued_at TIMESTAMPTZ`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS effective_scope TEXT NOT NULL DEFAULT ''`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS supersedes JSONB NOT NULL DEFAULT '[]'::jsonb`;
await sql`ALTER TABLE decision_center_records ADD COLUMN IF NOT EXISTS superseded_by JSONB NOT NULL DEFAULT '[]'::jsonb`;
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_type_check`;
await sql`ALTER TABLE decision_center_records DROP CONSTRAINT IF EXISTS decision_center_records_doc_shape_check`;
await clearInvalidDocumentNumbers();
await sql`
ALTER TABLE decision_center_records
ADD CONSTRAINT decision_center_records_type_check
CHECK (type IN ('meeting', 'decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
`;
await sql`
ALTER TABLE decision_center_records
ADD CONSTRAINT decision_center_records_doc_shape_check
CHECK (
(
doc_no = ''
AND doc_type = ''
AND doc_priority = ''
AND doc_year IS NULL
AND doc_seq IS NULL
)
OR (
doc_no ~ '^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-[0-9]{4}-[0-9]{3,9}$'
AND doc_type IN ('DCSN', 'GOAL', 'PLAN', 'RPRT', 'ACTN', 'ISSU', 'RETR', 'RQST', 'RESP', 'MINS')
AND doc_priority IN ('P0', 'P1', 'P2', 'P3')
AND doc_year BETWEEN 1970 AND 2100
AND doc_seq > 0
AND doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')
)
)
`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_type_status_level ON decision_center_records(type, status, level)`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_linked_goal ON decision_center_records(linked_goal_id)`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_updated ON decision_center_records(updated_at DESC)`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_issue_id ON decision_center_records(issue_id)`;
await clearDuplicateDocumentNumbers();
await backfillLegacyDocumentNumbers();
await clearDuplicateDocumentNumbers();
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_decision_center_records_doc_no_unique ON decision_center_records(doc_no) WHERE doc_no <> ''`;
await sql`CREATE INDEX IF NOT EXISTS idx_decision_center_records_doc_filter ON decision_center_records(doc_type, doc_priority, doc_year, doc_seq) WHERE doc_no <> ''`;
await sql`
CREATE TABLE IF NOT EXISTS decision_center_diary_entries (
id TEXT PRIMARY KEY,
@@ -506,33 +813,167 @@ function health(): JsonRecord {
};
}
async function clearInvalidDocumentNumbers(): Promise<void> {
const rows = await sql<{ id: string; doc_no: string }[]>`
UPDATE decision_center_records
SET
doc_no = '',
doc_type = '',
doc_priority = '',
doc_year = NULL,
doc_seq = NULL,
updated_at = updated_at
WHERE NOT (
(
doc_no = ''
AND doc_type = ''
AND doc_priority = ''
AND doc_year IS NULL
AND doc_seq IS NULL
)
OR (
doc_no ~ '^DC-(DCSN|GOAL|PLAN|RPRT|ACTN|ISSU|RETR|RQST|RESP|MINS)-P[0-3]-[0-9]{4}-[0-9]{3,9}$'
AND doc_type IN ('DCSN', 'GOAL', 'PLAN', 'RPRT', 'ACTN', 'ISSU', 'RETR', 'RQST', 'RESP', 'MINS')
AND doc_priority IN ('P0', 'P1', 'P2', 'P3')
AND doc_year BETWEEN 1970 AND 2100
AND doc_seq > 0
AND doc_no = 'DC-' || doc_type || '-' || doc_priority || '-' || doc_year::text || '-' || lpad(doc_seq::text, 3, '0')
)
)
RETURNING id, doc_no
`;
if (rows.length > 0) {
log("warn", "invalid_document_numbers_cleared_for_schema_check", {
clearedCount: rows.length,
examples: rows.slice(0, 20).map((row) => ({ id: row.id, docNo: row.doc_no })),
});
}
}
async function clearDuplicateDocumentNumbers(): Promise<void> {
const rows = await sql<{ id: string; doc_no: string }[]>`
WITH ranked AS (
SELECT
id,
doc_no,
row_number() OVER (PARTITION BY doc_no ORDER BY updated_at DESC, created_at DESC, id ASC) AS rank
FROM decision_center_records
WHERE doc_no <> ''
)
UPDATE decision_center_records record
SET
doc_no = '',
doc_type = '',
doc_priority = '',
doc_year = NULL,
doc_seq = NULL,
updated_at = updated_at
FROM ranked
WHERE record.id = ranked.id
AND ranked.rank > 1
RETURNING record.id, ranked.doc_no
`;
if (rows.length > 0) {
log("warn", "duplicate_document_numbers_cleared_for_unique_index", {
clearedCount: rows.length,
docNos: [...new Set(rows.map((row) => row.doc_no))].slice(0, 20),
});
}
}
async function backfillLegacyDocumentNumbers(): Promise<void> {
const rows = await sql<DecisionRecordRow[]>`
SELECT *
FROM decision_center_records
WHERE doc_no = ''
ORDER BY created_at ASC
LIMIT 5000
`;
let updatedCount = 0;
let skippedCount = 0;
for (const row of rows) {
const doc = extractDocumentNumberFromLegacy({
id: row.id,
title: row.title,
body: row.body,
tags: row.tags,
});
if (doc === null) continue;
const updated = await sql<{ id: string }[]>`
UPDATE decision_center_records
SET
doc_no = ${doc.docNo},
doc_type = ${doc.docType},
doc_priority = ${doc.docPriority},
doc_year = ${doc.docYear},
doc_seq = ${doc.docSeq},
updated_at = updated_at
WHERE id = ${row.id}
AND doc_no = ''
AND NOT EXISTS (
SELECT 1
FROM decision_center_records existing
WHERE existing.doc_no = ${doc.docNo}
AND existing.id <> ${row.id}
)
RETURNING id
`;
if (updated.length > 0) updatedCount += 1;
else skippedCount += 1;
}
if (updatedCount > 0 || skippedCount > 0) {
log("info", "legacy_document_numbers_backfilled", { updatedCount, skippedCount });
}
}
async function createRecord(input: Record<string, unknown>): Promise<DecisionRecord> {
const body = asText(input.body ?? input.summary ?? input.markdown);
const title = asString(input.title) || titleFromMarkdown(body, "Untitled decision record");
validateRecordDraft(title, body);
const id = asString(input.id) || `dc_${randomUUID()}`;
const rows = await withDatabaseRecovery("create_record", () => sql<DecisionRecordRow[]>`
INSERT INTO decision_center_records (
id, type, level, status, title, body, linked_goal_id, tags, evidence_links, source, source_session, issue_id, task_id, commit_id
) VALUES (
${id},
${parseRecordType(input.type, "meeting")},
${parsePriority(input, "none")},
${parseStatus(input.status, "active")},
${title},
${body},
${asString(input.linkedGoalId) || null},
${sql.json(asStringArray(input.tags, "tags"))},
${sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks"))},
${asString(input.source)},
${asString(input.sourceSession)},
${asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId)},
${asString(input.taskId)},
${asString(input.commitId)}
)
RETURNING *
`);
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "" });
const type = parseRecordType(input.type, "meeting");
const level = parsePriority(input, "none");
const status = parseStatus(input.status, "active");
const tags = asStringArray(input.tags, "tags");
const evidenceLinks = asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks");
const rows = await withDatabaseRecovery("create_record", () => sql.begin(async (client) => {
const doc = await documentNumberForCreate(client, input, { id, type, level, title, body, tags });
const documentColumns = documentColumnsForInsert(doc, input);
return await client<DecisionRecordRow[]>`
INSERT INTO decision_center_records (
id, type, level, status, title, body, linked_goal_id, tags, evidence_links,
doc_no, doc_type, doc_priority, doc_year, doc_seq, signer, issued_at, effective_scope, supersedes, superseded_by,
source, source_session, issue_id, task_id, commit_id
) VALUES (
${id},
${type},
${level},
${status},
${title},
${body},
${asString(input.linkedGoalId) || null},
${client.json(tags)},
${client.json(evidenceLinks)},
${documentColumns.docNo},
${documentColumns.docType},
${documentColumns.docPriority},
${documentColumns.docYear},
${documentColumns.docSeq},
${documentColumns.signer},
${documentColumns.issuedAt},
${documentColumns.effectiveScope},
${client.json(documentColumns.supersedes)},
${client.json(documentColumns.supersededBy)},
${asString(input.source)},
${asString(input.sourceSession)},
${asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask ?? input.taskId)},
${asString(input.taskId)},
${asString(input.commitId)}
)
RETURNING *
`;
}));
log("info", "record_created", { id: rows[0]?.id ?? id, type: rows[0]?.type ?? "", level: rows[0]?.level ?? "", docNo: rows[0]?.doc_no ?? "" });
return recordFromRow(rows[0]!);
}
@@ -540,39 +981,57 @@ async function updateRecord(id: string, input: Record<string, unknown>): Promise
const existing = await getRecord(id);
const title = "title" in input ? asString(input.title) : existing.title;
const body = "body" in input || "summary" in input || "markdown" in input ? asText(input.body ?? input.summary ?? input.markdown) : existing.body;
const nextType = "type" in input ? parseRecordType(input.type, existing.type) : existing.type;
const nextLevel = "level" in input || "priority" in input ? parsePriority(input, existing.level) : existing.level;
const nextStatus = "status" in input ? parseStatus(input.status, existing.status) : existing.status;
validateRecordDraft(title, body);
const rows = await withDatabaseRecovery("update_record", () => sql<DecisionRecordRow[]>`
UPDATE decision_center_records
SET
type = ${"type" in input ? parseRecordType(input.type, existing.type) : existing.type},
level = ${"level" in input || "priority" in input ? parsePriority(input, existing.level) : existing.level},
status = ${"status" in input ? parseStatus(input.status, existing.status) : existing.status},
title = ${title},
body = ${body},
linked_goal_id = ${"linkedGoalId" in input ? asString(input.linkedGoalId) || null : existing.linkedGoalId},
tags = ${"tags" in input ? sql.json(asStringArray(input.tags, "tags")) : sql.json(existing.tags)},
evidence_links = ${"evidenceLinks" in input || "evidence" in input ? sql.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks")) : sql.json(existing.evidenceLinks)},
source = ${"source" in input ? asString(input.source) : existing.source},
source_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
issue_id = ${"issueId" in input || "issue" in input || "linkedIssue" in input || "linkedTask" in input ? asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask) : existing.issueId},
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
updated_at = now()
WHERE id = ${id}
RETURNING *
`);
const rows = await withDatabaseRecovery("update_record", () => sql.begin(async (client) => {
const doc = await documentNumberForUpdate(client, input, existing, nextType, nextLevel);
const documentColumns = documentColumnsForUpdate(doc, input, existing);
return await client<DecisionRecordRow[]>`
UPDATE decision_center_records
SET
type = ${nextType},
level = ${nextLevel},
status = ${nextStatus},
title = ${title},
body = ${body},
linked_goal_id = ${"linkedGoalId" in input ? asString(input.linkedGoalId) || null : existing.linkedGoalId},
tags = ${"tags" in input ? client.json(asStringArray(input.tags, "tags")) : client.json(existing.tags)},
evidence_links = ${"evidenceLinks" in input || "evidence" in input ? client.json(asStringArray(input.evidenceLinks ?? input.evidence, "evidenceLinks")) : client.json(existing.evidenceLinks)},
doc_no = ${documentColumns.docNo},
doc_type = ${documentColumns.docType},
doc_priority = ${documentColumns.docPriority},
doc_year = ${documentColumns.docYear},
doc_seq = ${documentColumns.docSeq},
signer = ${documentColumns.signer},
issued_at = ${documentColumns.issuedAt},
effective_scope = ${documentColumns.effectiveScope},
supersedes = ${client.json(documentColumns.supersedes)},
superseded_by = ${client.json(documentColumns.supersededBy)},
source = ${"source" in input ? asString(input.source) : existing.source},
source_session = ${"sourceSession" in input ? asString(input.sourceSession) : existing.sourceSession},
issue_id = ${"issueId" in input || "issue" in input || "linkedIssue" in input || "linkedTask" in input ? asString(input.issueId ?? input.issue ?? input.linkedIssue ?? input.linkedTask) : existing.issueId},
task_id = ${"taskId" in input ? asString(input.taskId) : existing.taskId},
commit_id = ${"commitId" in input ? asString(input.commitId) : existing.commitId},
updated_at = now()
WHERE id = ${existing.id}
RETURNING *
`;
}));
return recordFromRow(rows[0]!);
}
async function upsertRequirementRecord(input: Record<string, unknown>): Promise<JsonRecord> {
const id = asString(input.id);
if (id) {
const docNo = inputDocumentNumber(input)?.docNo ?? "";
if (id || docNo) {
try {
const existing = await getRecord(id);
const existing = await getRecordByIdOrDocNo(id, docNo);
if (!requirementRecordTypes.has(existing.type as RequirementRecordType)) {
throw new HttpError(409, "existing record is not a requirement-management record", { id, type: existing.type });
throw new HttpError(409, "existing record is not a requirement-management record", { id: existing.id, docNo: existing.docNo, type: existing.type });
}
const record = await updateRecord(id, {
const record = await updateRecord(existing.id, {
...input,
type: "type" in input ? parseRequirementRecordType(input.type, existing.type as RequirementRecordType) : existing.type,
});
@@ -591,13 +1050,41 @@ async function upsertRequirementRecord(input: Record<string, unknown>): Promise<
return { ok: true, action: "created", record };
}
async function getRecordByIdOrDocNo(id: string, docNo: string): Promise<DecisionRecord> {
const rows = await withDatabaseRecovery("get_record_by_id_or_doc_no", () => sql<DecisionRecordRow[]>`
SELECT *
FROM decision_center_records
WHERE (${id || null}::text IS NOT NULL AND id = ${id || null})
OR (${docNo || null}::text IS NOT NULL AND doc_no = ${docNo || null})
ORDER BY
CASE WHEN id = ${id || ""} THEN 0 ELSE 1 END,
updated_at DESC
LIMIT 1
`, { retryRead: true });
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id, docNo });
return recordFromRow(rows[0]!);
}
async function getRecord(id: string): Promise<DecisionRecord> {
const rows = await withDatabaseRecovery("get_record", () => sql<DecisionRecordRow[]>`SELECT * FROM decision_center_records WHERE id = ${id}`, { retryRead: true });
const docNo = tryParseDocumentNumber(id)?.docNo ?? "";
const rows = await withDatabaseRecovery("get_record", () => sql<DecisionRecordRow[]>`
SELECT *
FROM decision_center_records
WHERE id = ${id}
OR (${docNo || null}::text IS NOT NULL AND doc_no = ${docNo || null})
ORDER BY CASE WHEN id = ${id} THEN 0 ELSE 1 END
LIMIT 1
`, { retryRead: true });
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
return recordFromRow(rows[0]!);
}
async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}): Promise<DecisionRecord[]> {
const docNoRaw = asString(url.searchParams.get("docNo") ?? url.searchParams.get("doc-no") ?? url.searchParams.get("documentNo") ?? url.searchParams.get("documentNumber"));
const docNo = docNoRaw ? parseDocumentNumber(docNoRaw, "docNo").docNo : "";
const docType = asString(url.searchParams.get("docType") ?? url.searchParams.get("doc-type"));
const docPriority = asString(url.searchParams.get("docPriority") ?? url.searchParams.get("doc-priority"));
const docYearRaw = asString(url.searchParams.get("docYear") ?? url.searchParams.get("doc-year") ?? url.searchParams.get("year"));
const type = asString(url.searchParams.get("type"));
const status = asString(url.searchParams.get("status"));
const level = asString(url.searchParams.get("level"));
@@ -609,6 +1096,11 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
const requirementOnly = options.requirementOnly === true || url.searchParams.get("requirementOnly") === "true";
const includeBody = url.searchParams.get("includeBody") === "true";
const limit = Math.max(1, Math.min(500, Number(url.searchParams.get("limit") || 200) || 200));
if (docType && !decisionDocumentTypes.includes(docType.toUpperCase() as DecisionDocumentType)) throw new HttpError(400, "unsupported docType filter", { docType, allowed: [...decisionDocumentTypes] });
if (docPriority && !decisionDocumentPriorities.includes(docPriority.toUpperCase() as DecisionDocumentPriority)) throw new HttpError(400, "unsupported docPriority filter", { docPriority, allowed: [...decisionDocumentPriorities] });
const normalizedDocType = docType ? parseDocumentType(docType, "docType") : "";
const normalizedDocPriority = docPriority ? parseDocumentPriority(docPriority, "docPriority") : "";
const docYear = docYearRaw ? parseDocumentYear(docYearRaw, "docYear") : null;
if (type && !recordTypes.has(type as DecisionRecordType)) throw new HttpError(400, "unsupported type filter", { type });
if (requirementOnly && type && !requirementRecordTypes.has(type as RequirementRecordType)) throw new HttpError(400, "unsupported requirement type filter", { type });
if (status && !recordStatuses.has(status as DecisionRecordStatus)) throw new HttpError(400, "unsupported status filter", { status });
@@ -628,6 +1120,16 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
linked_goal_id,
tags,
evidence_links,
doc_no,
doc_type,
doc_priority,
doc_year,
doc_seq,
signer,
issued_at,
effective_scope,
supersedes,
superseded_by,
source,
source_session,
issue_id,
@@ -636,7 +1138,11 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
created_at,
updated_at
FROM decision_center_records
WHERE (${type || null}::text IS NULL OR type = ${type || null})
WHERE (${docNo || null}::text IS NULL OR doc_no = ${docNo || null})
AND (${normalizedDocType || null}::text IS NULL OR doc_type = ${normalizedDocType || null})
AND (${normalizedDocPriority || null}::text IS NULL OR doc_priority = ${normalizedDocPriority || null})
AND (${docYear}::int IS NULL OR doc_year = ${docYear})
AND (${type || null}::text IS NULL OR type = ${type || null})
AND (${requirementOnly}::boolean IS FALSE OR type IN ('decision', 'goal', 'external_goal', 'internal_goal', 'blocker', 'debt', 'experiment'))
AND (${status || null}::text IS NULL OR status = ${status || null})
AND (${level || null}::text IS NULL OR level = ${level || null})
@@ -646,6 +1152,17 @@ async function listRecords(url: URL, options: { requirementOnly?: boolean } = {}
AND (${tag || null}::text IS NULL OR tags ? ${tag || ""})
AND (${query || null}::text IS NULL OR title ILIKE ${query ? `%${query}%` : ""} OR body ILIKE ${query ? `%${query}%` : ""})
ORDER BY
CASE WHEN doc_no = '' THEN 1 ELSE 0 END ASC,
doc_type ASC NULLS LAST,
CASE doc_priority
WHEN 'P0' THEN 0
WHEN 'P1' THEN 1
WHEN 'P2' THEN 2
WHEN 'P3' THEN 3
ELSE 4
END ASC,
doc_year ASC NULLS LAST,
doc_seq ASC NULLS LAST,
CASE level
WHEN 'G0' THEN 0
WHEN 'P0' THEN 1
@@ -718,12 +1235,20 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
taskId: asString(input.taskId),
commitId: asString(input.commitId),
};
const documentFields = ["docNo", "docNumber", "documentNo", "documentNumber", "docType", "docPriority", "docYear", "docSeq", "signer", "issuedAt", "effectiveScope", "supersedes", "supersededBy"];
for (const field of documentFields) {
if (field in input) base[field] = input[field];
}
const meeting = await createRecord(base);
const decisionInputs = normalizeDecisionDrafts(input.decisions);
const decisions: DecisionRecord[] = [];
for (const decision of decisionInputs) {
const inheritedBase = { ...base };
for (const field of documentFields) {
if (!(field in decision)) delete inheritedBase[field];
}
decisions.push(await createRecord({
...base,
...inheritedBase,
...decision,
type: "decision",
linkedGoalId: asString(decision.linkedGoalId) || meeting.linkedGoalId,
@@ -734,7 +1259,8 @@ async function importMeeting(input: Record<string, unknown>): Promise<JsonRecord
}
async function deleteRecord(id: string): Promise<JsonRecord> {
const rows = await withDatabaseRecovery("delete_record", () => sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${id} RETURNING *`);
const existing = await getRecord(id);
const rows = await withDatabaseRecovery("delete_record", () => sql<DecisionRecordRow[]>`DELETE FROM decision_center_records WHERE id = ${existing.id} RETURNING *`);
if (rows.length === 0) throw new HttpError(404, "decision record not found", { id });
log("info", "record_deleted", { id });
return { ok: true, deleted: recordFromRow(rows[0]!) };