{"version":3,"file":"proposal-artifacts.d.ts","sourceRoot":"","sources":["../src/proposal-artifacts.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport { type FileHandle, link, lstat, mkdir, open, readFile, unlink } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport type { EvoPaths } from \"./paths.ts\";\nimport { ensureEvoLayout } from \"./paths.ts\";\nimport {\n\tappendJsonLine,\n\tatomicWriteJson,\n\tcanonicalJson,\n\treadJson,\n\tsha256,\n\ttruncateIncompleteFinalLine,\n\twithFileLock,\n} from \"./storage.ts\";\nimport type { EvaluationArtifactRef, EvidenceReference, Proposal, ProposalArtifactKind } from \"./types.ts\";\n\nconst PROPOSAL_ID_PATTERN = /^p-[A-Za-z0-9._-]+$/;\nconst DIGEST_PATTERN = /^[a-f0-9]{64}$/;\nconst ARTIFACT_KINDS = new Set<ProposalArtifactKind>([\"review\", \"replay\", \"validation\", \"comparison\", \"retrospective\"]);\nconst APPROVAL_ROLES = new Set<ApprovalTurnRole>([\"human\", \"meta\"]);\nconst APPROVAL_KINDS = new Set<ApprovalTurnKind>([\n\t\"question\",\n\t\"answer\",\n\t\"revision-request\",\n\t\"revision-result\",\n\t\"defer\",\n\t\"reopen\",\n]);\nconst APPROVAL_LOCK_TIMEOUT_MS = 30_000;\nconst APPROVAL_LOCK_STALE_MS = 10 * 60_000;\n\nexport type ApprovalTurnRole = \"human\" | \"meta\";\nexport type ApprovalTurnKind = \"question\" | \"answer\" | \"revision-request\" | \"revision-result\" | \"defer\" | \"reopen\";\n\nexport interface ApprovalTurn {\n\tschemaVersion: 1;\n\tproposalId: string;\n\tsequence: number;\n\ttimestamp: string;\n\trevision: number;\n\tdiffDigest: string;\n\trole: ApprovalTurnRole;\n\tkind: ApprovalTurnKind;\n\ttext: string;\n\tevidence?: EvidenceReference[];\n}\n\nexport type FixedEvaluationArtifactKind = Exclude<ProposalArtifactKind, \"comparison\" | \"retrospective\">;\n\nexport interface WriteEvaluationArtifactOptions {\n\tpaths: EvoPaths;\n\tproposalId: string;\n\trevision: number;\n\tdiffDigest: string;\n\tkind: FixedEvaluationArtifactKind;\n\tcontent: string;\n\tnow?: () => Date;\n}\nexport interface WriteEvidenceArtifactOptions {\n\tpaths: EvoPaths;\n\tproposalId: string;\n\trevision: number;\n\tdiffDigest: string;\n\tkind: \"comparison\" | \"retrospective\";\n\tcontent: string;\n\tevidenceDigest: string;\n\tevidenceCutoff: string;\n\tnow?: () => Date;\n}\n\nexport type WriteRetrospectiveArtifactOptions = Omit<WriteEvidenceArtifactOptions, \"kind\">;\nexport type WriteComparisonArtifactOptions = Omit<WriteEvidenceArtifactOptions, \"kind\"> & {\n\tmarkdownContent: string;\n};\n\nexport interface ReadEvaluationArtifactOptions {\n\tpaths: EvoPaths;\n\tproposalId: string;\n\trevision: number;\n\tdiffDigest: string;\n\tkind: ProposalArtifactKind;\n\treference: EvaluationArtifactRef;\n}\n\nexport interface AppendApprovalTurnOptions {\n\tpaths: EvoPaths;\n\tproposalId: string;\n\trevision: number;\n\tdiffDigest: string;\n\trole: ApprovalTurnRole;\n\tkind: ApprovalTurnKind;\n\ttext: string;\n\tevidence?: EvidenceReference[];\n\tnow?: () => Date;\n}\n\ninterface RevisionPaths {\n\tproposalDirectory: string;\n\trevisionsDirectory: string;\n\trevisionDirectory: string;\n\trevisionFile: string;\n}\n\nfunction errorCode(error: unknown): string | undefined {\n\tif (typeof error !== \"object\" || error === null || !(\"code\" in error)) return undefined;\n\treturn typeof error.code === \"string\" ? error.code : undefined;\n}\n\nasync function syncDirectory(path: string): Promise<void> {\n\tlet handle: FileHandle | undefined;\n\ttry {\n\t\thandle = await open(path, \"r\");\n\t\tawait handle.sync();\n\t} catch (error) {\n\t\tif (process.platform !== \"win32\") throw error;\n\t} finally {\n\t\tawait handle?.close();\n\t}\n}\n\nasync function durableWriteOnceFile(path: string, content: string): Promise<boolean> {\n\tconst directory = dirname(path);\n\tconst temporaryPath = `${path}.tmp-${process.pid}-${randomUUID()}`;\n\tlet handle: FileHandle | undefined;\n\tlet temporaryCreated = false;\n\ttry {\n\t\thandle = await open(temporaryPath, \"wx\", 0o600);\n\t\ttemporaryCreated = true;\n\t\tawait handle.writeFile(content, \"utf8\");\n\t\tawait handle.sync();\n\t\tawait handle.close();\n\t\thandle = undefined;\n\t\ttry {\n\t\t\tawait link(temporaryPath, path);\n\t\t\treturn true;\n\t\t} catch (error) {\n\t\t\tif (errorCode(error) === \"EEXIST\") return false;\n\t\t\tthrow error;\n\t\t}\n\t} finally {\n\t\tawait handle?.close().catch(() => {});\n\t\tif (temporaryCreated) {\n\t\t\tawait unlink(temporaryPath).catch((error: unknown) => {\n\t\t\t\tif (errorCode(error) !== \"ENOENT\") throw error;\n\t\t\t});\n\t\t}\n\t\tawait syncDirectory(directory);\n\t}\n}\n\nfunction assertProposalRevision(proposalId: string, revision: number): void {\n\tif (!PROPOSAL_ID_PATTERN.test(proposalId) || proposalId.length > 200) {\n\t\tthrow new Error(`Invalid proposal id: ${proposalId}`);\n\t}\n\tif (!Number.isSafeInteger(revision) || revision <= 0) {\n\t\tthrow new Error(\"revision must be a positive safe integer\");\n\t}\n}\n\nfunction assertDigest(digest: string, label: string): void {\n\tif (!DIGEST_PATTERN.test(digest)) throw new Error(`${label} must be a sha256 digest`);\n}\n\nfunction assertArtifactKind(kind: ProposalArtifactKind): void {\n\tif (!ARTIFACT_KINDS.has(kind)) throw new Error(`Unsupported proposal artifact kind: ${kind}`);\n}\n\nfunction revisionPaths(paths: EvoPaths, proposalId: string, revision: number): RevisionPaths {\n\tconst proposalDirectory = join(paths.proposals, proposalId);\n\tconst revisionsDirectory = join(proposalDirectory, \"revisions\");\n\tconst revisionDirectory = join(revisionsDirectory, String(revision));\n\treturn {\n\t\tproposalDirectory,\n\t\trevisionsDirectory,\n\t\trevisionDirectory,\n\t\trevisionFile: join(revisionDirectory, \"revision.json\"),\n\t};\n}\n\nexport function getEvaluationArtifactFile(revision: number, kind: FixedEvaluationArtifactKind): string {\n\tif (!Number.isSafeInteger(revision) || revision <= 0) {\n\t\tthrow new Error(\"revision must be a positive safe integer\");\n\t}\n\tassertArtifactKind(kind);\n\treturn `revisions/${revision}/${kind}.md`;\n}\n\nfunction getEvidenceArtifactFile(\n\trevision: number,\n\tkind: \"comparison\" | \"retrospective\",\n\tevidenceDigest: string,\n): string {\n\tif (!Number.isSafeInteger(revision) || revision <= 0) {\n\t\tthrow new Error(\"revision must be a positive safe integer\");\n\t}\n\tassertDigest(evidenceDigest, `${kind} evidence digest`);\n\tconst extension = kind === \"comparison\" ? \"json\" : \"md\";\n\treturn `revisions/${revision}/${kind}s/${evidenceDigest}.${extension}`;\n}\n\nexport function getRetrospectiveArtifactFile(revision: number, evidenceDigest: string): string {\n\treturn getEvidenceArtifactFile(revision, \"retrospective\", evidenceDigest);\n}\n\nfunction getComparisonArtifactFile(revision: number, evidenceDigest: string): string {\n\treturn getEvidenceArtifactFile(revision, \"comparison\", evidenceDigest);\n}\n\nfunction getComparisonMarkdownArtifactFile(revision: number, evidenceDigest: string): string {\n\treturn getComparisonArtifactFile(revision, evidenceDigest).replace(/\\.json$/, \".md\");\n}\n\nasync function assertPlainDirectory(path: string, label: string): Promise<void> {\n\tconst pathStat = await lstat(path);\n\tif (!pathStat.isDirectory() || pathStat.isSymbolicLink()) {\n\t\tthrow new Error(`${label} must be a real directory`);\n\t}\n}\n\nasync function ensurePlainDirectory(path: string, label: string): Promise<void> {\n\tlet created = false;\n\ttry {\n\t\tawait mkdir(path, { mode: 0o700 });\n\t\tcreated = true;\n\t} catch (error) {\n\t\tif (errorCode(error) !== \"EEXIST\") throw error;\n\t}\n\tawait assertPlainDirectory(path, label);\n\tif (created) {\n\t\tawait syncDirectory(path);\n\t\tawait syncDirectory(dirname(path));\n\t}\n}\n\nasync function ensureRevisionDirectory(paths: EvoPaths, proposalId: string, revision: number): Promise<RevisionPaths> {\n\tawait ensureEvoLayout(paths);\n\tawait assertPlainDirectory(paths.proposals, \"Proposal root\");\n\tconst resolved = revisionPaths(paths, proposalId, revision);\n\tawait ensurePlainDirectory(resolved.proposalDirectory, \"Proposal directory\");\n\tawait ensurePlainDirectory(resolved.revisionsDirectory, \"Proposal revisions directory\");\n\tawait ensurePlainDirectory(resolved.revisionDirectory, \"Proposal revision directory\");\n\treturn resolved;\n}\n\nasync function assertRevisionDirectory(paths: EvoPaths, proposalId: string, revision: number): Promise<RevisionPaths> {\n\tconst resolved = revisionPaths(paths, proposalId, revision);\n\tawait assertPlainDirectory(paths.proposals, \"Proposal root\");\n\tawait assertPlainDirectory(resolved.proposalDirectory, \"Proposal directory\");\n\tawait assertPlainDirectory(resolved.revisionsDirectory, \"Proposal revisions directory\");\n\tawait assertPlainDirectory(resolved.revisionDirectory, \"Proposal revision directory\");\n\treturn resolved;\n}\n\nasync function assertRegularFile(path: string, label: string): Promise<void> {\n\tconst pathStat = await lstat(path);\n\tif (!pathStat.isFile() || pathStat.isSymbolicLink()) throw new Error(`${label} must be a regular file`);\n}\n\nasync function assertWritableFilePath(path: string, label: string): Promise<void> {\n\ttry {\n\t\tawait assertRegularFile(path, label);\n\t} catch (error) {\n\t\tif (errorCode(error) !== \"ENOENT\") throw error;\n\t}\n}\n\nfunction assertArtifactReference(\n\treference: EvaluationArtifactRef,\n\texpectedFile: string,\n\trevision: number,\n\tdiffDigest: string,\n\tkind: ProposalArtifactKind,\n): void {\n\tif (reference.revision !== revision) throw new Error(\"Artifact reference belongs to a different proposal revision\");\n\tif (reference.diffDigest !== diffDigest)\n\t\tthrow new Error(\"Artifact reference diff digest does not match the revision\");\n\tif (reference.file !== expectedFile) throw new Error(\"Artifact reference does not match its fixed revision path\");\n\tif (reference.evidence) {\n\t\tif (kind !== \"comparison\" && kind !== \"retrospective\") {\n\t\t\tthrow new Error(\"Only comparison and retrospective artifacts may bind an evidence snapshot\");\n\t\t}\n\t\tassertDigest(reference.evidence.digest, \"Artifact evidence digest\");\n\t\tif (!reference.evidence.cutoff || !Number.isFinite(Date.parse(reference.evidence.cutoff))) {\n\t\t\tthrow new Error(\"Artifact evidence cutoff is invalid\");\n\t\t}\n\t}\n\tassertDigest(reference.sha256, \"Artifact reference sha256\");\n\tif (!reference.createdAt || Number.isNaN(Date.parse(reference.createdAt))) {\n\t\tthrow new Error(\"Artifact reference createdAt is invalid\");\n\t}\n}\n\nexport async function writeEvaluationArtifact(options: WriteEvaluationArtifactOptions): Promise<EvaluationArtifactRef> {\n\tassertProposalRevision(options.proposalId, options.revision);\n\tassertDigest(options.diffDigest, \"diffDigest\");\n\tassertArtifactKind(options.kind);\n\tconst resolved = await ensureRevisionDirectory(options.paths, options.proposalId, options.revision);\n\tconst file = getEvaluationArtifactFile(options.revision, options.kind);\n\tconst absolutePath = join(resolved.proposalDirectory, file);\n\tconst contentDigest = sha256(options.content);\n\tif (!(await durableWriteOnceFile(absolutePath, options.content))) {\n\t\tawait assertRegularFile(absolutePath, \"Proposal artifact\");\n\t\tif (sha256(await readFile(absolutePath)) !== contentDigest) {\n\t\t\tthrow new Error(`Proposal ${options.kind} artifact is write-once for revision ${options.revision}`);\n\t\t}\n\t}\n\tawait assertRegularFile(absolutePath, \"Proposal artifact\");\n\treturn {\n\t\tfile,\n\t\tsha256: contentDigest,\n\t\trevision: options.revision,\n\t\tdiffDigest: options.diffDigest,\n\t\tcreatedAt: (options.now?.() ?? new Date()).toISOString(),\n\t};\n}\n\nasync function writeEvidenceArtifact(options: WriteEvidenceArtifactOptions): Promise<EvaluationArtifactRef> {\n\tassertProposalRevision(options.proposalId, options.revision);\n\tassertDigest(options.diffDigest, \"diffDigest\");\n\tassertDigest(options.evidenceDigest, `${options.kind} evidence digest`);\n\tif (!options.evidenceCutoff || !Number.isFinite(Date.parse(options.evidenceCutoff))) {\n\t\tthrow new Error(`${options.kind} evidence cutoff is invalid`);\n\t}\n\tconst resolved = await ensureRevisionDirectory(options.paths, options.proposalId, options.revision);\n\tconst directory = `${options.kind}s`;\n\tawait ensurePlainDirectory(join(resolved.revisionDirectory, directory), `${options.kind} snapshot directory`);\n\tconst file = getEvidenceArtifactFile(options.revision, options.kind, options.evidenceDigest);\n\tconst absolutePath = join(resolved.proposalDirectory, file);\n\tconst contentDigest = sha256(options.content);\n\tif (!(await durableWriteOnceFile(absolutePath, options.content))) {\n\t\tawait assertRegularFile(absolutePath, `${options.kind} snapshot`);\n\t\tif (sha256(await readFile(absolutePath)) !== contentDigest) {\n\t\t\tthrow new Error(`${options.kind} snapshot is write-once for evidence ${options.evidenceDigest}`);\n\t\t}\n\t}\n\tawait assertRegularFile(absolutePath, `${options.kind} snapshot`);\n\treturn {\n\t\tfile,\n\t\tsha256: contentDigest,\n\t\trevision: options.revision,\n\t\tdiffDigest: options.diffDigest,\n\t\tcreatedAt: (options.now?.() ?? new Date()).toISOString(),\n\t\tevidence: {\n\t\t\tdigest: options.evidenceDigest,\n\t\t\tcutoff: options.evidenceCutoff,\n\t\t},\n\t};\n}\n\nexport function writeRetrospectiveArtifact(options: WriteRetrospectiveArtifactOptions): Promise<EvaluationArtifactRef> {\n\treturn writeEvidenceArtifact({ ...options, kind: \"retrospective\" });\n}\n\nexport async function writeComparisonArtifact(options: WriteComparisonArtifactOptions): Promise<EvaluationArtifactRef> {\n\tconst reference = await writeEvidenceArtifact({ ...options, kind: \"comparison\" });\n\tconst markdownFile = getComparisonMarkdownArtifactFile(options.revision, options.evidenceDigest);\n\tconst markdownPath = join(options.paths.proposals, options.proposalId, markdownFile);\n\tconst markdownDigest = sha256(options.markdownContent);\n\tif (!(await durableWriteOnceFile(markdownPath, options.markdownContent))) {\n\t\tawait assertRegularFile(markdownPath, \"comparison markdown snapshot\");\n\t\tif (sha256(await readFile(markdownPath)) !== markdownDigest) {\n\t\t\tthrow new Error(`comparison markdown snapshot is write-once for evidence ${options.evidenceDigest}`);\n\t\t}\n\t}\n\tawait assertRegularFile(markdownPath, \"comparison markdown snapshot\");\n\treturn reference;\n}\n\nexport async function readEvaluationArtifact(options: ReadEvaluationArtifactOptions): Promise<string> {\n\tassertProposalRevision(options.proposalId, options.revision);\n\tassertDigest(options.diffDigest, \"diffDigest\");\n\tassertArtifactKind(options.kind);\n\tlet expectedFile: string;\n\tif (options.kind === \"comparison\" || options.kind === \"retrospective\") {\n\t\tif (!options.reference.evidence) throw new Error(`${options.kind} artifact has no evidence binding`);\n\t\texpectedFile = getEvidenceArtifactFile(options.revision, options.kind, options.reference.evidence.digest);\n\t} else {\n\t\texpectedFile = getEvaluationArtifactFile(options.revision, options.kind);\n\t}\n\tassertArtifactReference(options.reference, expectedFile, options.revision, options.diffDigest, options.kind);\n\tconst resolved = await assertRevisionDirectory(options.paths, options.proposalId, options.revision);\n\tconst absolutePath = join(resolved.proposalDirectory, expectedFile);\n\tawait assertRegularFile(absolutePath, \"Proposal artifact\");\n\tconst content = await readFile(absolutePath, \"utf8\");\n\tif (sha256(content) !== options.reference.sha256)\n\t\tthrow new Error(\"Proposal artifact sha256 does not match its reference\");\n\treturn content;\n}\n\nexport async function validateEvaluationArtifact(options: ReadEvaluationArtifactOptions): Promise<void> {\n\tawait readEvaluationArtifact(options);\n}\n\nexport async function saveProposalRevisionSnapshot(paths: EvoPaths, proposal: Proposal): Promise<string> {\n\tassertProposalRevision(proposal.id, proposal.revision);\n\tassertDigest(proposal.diffDigest, \"proposal.diffDigest\");\n\tconst resolved = await ensureRevisionDirectory(paths, proposal.id, proposal.revision);\n\tconst changeFile = join(resolved.revisionDirectory, \"change.json\");\n\tconst changeContent = `${canonicalJson({\n\t\tschemaVersion: 1,\n\t\tproposalId: proposal.id,\n\t\trevision: proposal.revision,\n\t\tparentBundleDigest: proposal.parentBundleDigest,\n\t\t...(proposal.candidateDigest ? { candidateDigest: proposal.candidateDigest } : {}),\n\t\tkind: proposal.kind,\n\t\ttier: proposal.tier,\n\t\tmotivation: proposal.motivation,\n\t\texpectedEffect: proposal.expectedEffect,\n\t\trisk: proposal.risk,\n\t\tverifyPlan: proposal.verifyPlan,\n\t\ttrialPlan: proposal.trialPlan,\n\t\tchangedPaths: proposal.changedPaths,\n\t\tdiff: proposal.diff,\n\t\tdiffDigest: proposal.diffDigest,\n\t\tapprovalDigest: proposal.approvalDigest,\n\t})}\\n`;\n\tconst changeDigest = sha256(changeContent);\n\tif (!(await durableWriteOnceFile(changeFile, changeContent))) {\n\t\tawait assertRegularFile(changeFile, \"Proposal change snapshot\");\n\t\tif (sha256(await readFile(changeFile)) !== changeDigest) {\n\t\t\tthrow new Error(`Proposal change snapshot is write-once for revision ${proposal.revision}`);\n\t\t}\n\t}\n\tawait assertWritableFilePath(resolved.revisionFile, \"Proposal revision snapshot\");\n\tawait atomicWriteJson(resolved.revisionFile, proposal);\n\tawait assertRegularFile(resolved.revisionFile, \"Proposal revision snapshot\");\n\treturn `revisions/${proposal.revision}/revision.json`;\n}\n\nexport async function readProposalRevisionSnapshot(\n\tpaths: EvoPaths,\n\tproposalId: string,\n\trevision: number,\n): Promise<Proposal> {\n\tassertProposalRevision(proposalId, revision);\n\tconst resolved = await assertRevisionDirectory(paths, proposalId, revision);\n\tawait assertRegularFile(resolved.revisionFile, \"Proposal revision snapshot\");\n\tconst value = await readJson<unknown>(resolved.revisionFile);\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(\"Proposal revision snapshot must be an object\");\n\t}\n\tconst record = value as Record<string, unknown>;\n\tif (record.schemaVersion !== 2 || record.id !== proposalId || record.revision !== revision) {\n\t\tthrow new Error(\"Proposal revision snapshot identity does not match its path\");\n\t}\n\tif (typeof record.diffDigest !== \"string\") throw new Error(\"Proposal revision snapshot has no diff digest\");\n\tassertDigest(record.diffDigest, \"Proposal revision snapshot diffDigest\");\n\treturn value as Proposal;\n}\n\nfunction assertEvidence(evidence: readonly EvidenceReference[] | undefined): void {\n\tif (!evidence) return;\n\tfor (const reference of evidence) {\n\t\tif (!reference.sessionId || !Number.isSafeInteger(reference.sequence) || reference.sequence <= 0) {\n\t\t\tthrow new Error(\"Approval evidence must contain a sessionId and positive sequence\");\n\t\t}\n\t\tif (reference.quote !== undefined && typeof reference.quote !== \"string\") {\n\t\t\tthrow new Error(\"Approval evidence quote must be a string\");\n\t\t}\n\t}\n}\n\nfunction parseApprovalTurn(value: unknown, proposalId: string, expectedSequence: number): ApprovalTurn {\n\tif (typeof value !== \"object\" || value === null || Array.isArray(value)) {\n\t\tthrow new Error(`Approval turn ${expectedSequence} must be an object`);\n\t}\n\tconst record = value as Record<string, unknown>;\n\tif (record.schemaVersion !== 1 || record.proposalId !== proposalId || record.sequence !== expectedSequence) {\n\t\tthrow new Error(`Approval turn sequence ${expectedSequence} is invalid`);\n\t}\n\tif (!Number.isSafeInteger(record.revision) || (record.revision as number) <= 0) {\n\t\tthrow new Error(`Approval turn ${expectedSequence} has an invalid revision`);\n\t}\n\tif (typeof record.diffDigest !== \"string\") throw new Error(`Approval turn ${expectedSequence} has no diff digest`);\n\tassertDigest(record.diffDigest, `Approval turn ${expectedSequence} diffDigest`);\n\tif (typeof record.timestamp !== \"string\" || Number.isNaN(Date.parse(record.timestamp))) {\n\t\tthrow new Error(`Approval turn ${expectedSequence} has an invalid timestamp`);\n\t}\n\tif (typeof record.role !== \"string\" || !APPROVAL_ROLES.has(record.role as ApprovalTurnRole)) {\n\t\tthrow new Error(`Approval turn ${expectedSequence} has an invalid role`);\n\t}\n\tif (typeof record.kind !== \"string\" || !APPROVAL_KINDS.has(record.kind as ApprovalTurnKind)) {\n\t\tthrow new Error(`Approval turn ${expectedSequence} has an invalid kind`);\n\t}\n\tif (typeof record.text !== \"string\" || !record.text.trim()) {\n\t\tthrow new Error(`Approval turn ${expectedSequence} has empty text`);\n\t}\n\tif (record.evidence !== undefined && !Array.isArray(record.evidence)) {\n\t\tthrow new Error(`Approval turn ${expectedSequence} evidence must be an array`);\n\t}\n\tconst evidence = record.evidence as EvidenceReference[] | undefined;\n\tassertEvidence(evidence);\n\treturn {\n\t\tschemaVersion: 1,\n\t\tproposalId,\n\t\tsequence: expectedSequence,\n\t\ttimestamp: record.timestamp,\n\t\trevision: record.revision as number,\n\t\tdiffDigest: record.diffDigest,\n\t\trole: record.role as ApprovalTurnRole,\n\t\tkind: record.kind as ApprovalTurnKind,\n\t\ttext: record.text,\n\t\t...(evidence ? { evidence: evidence.map((reference) => ({ ...reference })) } : {}),\n\t};\n}\n\nasync function readApprovalTurnsUnlocked(paths: EvoPaths, proposalId: string): Promise<ApprovalTurn[]> {\n\tconst approvalFile = join(paths.proposals, proposalId, \"approval.jsonl\");\n\tawait truncateIncompleteFinalLine(approvalFile);\n\ttry {\n\t\tawait assertRegularFile(approvalFile, \"Proposal approval log\");\n\t} catch (error) {\n\t\tif (errorCode(error) === \"ENOENT\") return [];\n\t\tthrow error;\n\t}\n\tconst content = await readFile(approvalFile, \"utf8\");\n\tif (!content) return [];\n\tif (!content.endsWith(\"\\n\")) throw new Error(\"Proposal approval log has an incomplete final line\");\n\tconst lines = content.slice(0, -1).split(\"\\n\");\n\treturn lines.map((line, index) => {\n\t\tlet value: unknown;\n\t\ttry {\n\t\t\tvalue = JSON.parse(line);\n\t\t} catch {\n\t\t\tthrow new Error(`Proposal approval log line ${index + 1} is invalid JSON`);\n\t\t}\n\t\treturn parseApprovalTurn(value, proposalId, index + 1);\n\t});\n}\n\nfunction withApprovalLock<T>(paths: EvoPaths, proposalId: string, operation: () => Promise<T>): Promise<T> {\n\treturn withFileLock(paths, `approval-${proposalId}`, operation, {\n\t\ttimeoutMs: APPROVAL_LOCK_TIMEOUT_MS,\n\t\tstaleAfterMs: APPROVAL_LOCK_STALE_MS,\n\t});\n}\n\nexport async function appendApprovalTurn(options: AppendApprovalTurnOptions): Promise<ApprovalTurn> {\n\tassertProposalRevision(options.proposalId, options.revision);\n\tassertDigest(options.diffDigest, \"diffDigest\");\n\tif (!APPROVAL_ROLES.has(options.role)) throw new Error(`Unsupported approval role: ${options.role}`);\n\tif (!APPROVAL_KINDS.has(options.kind)) throw new Error(`Unsupported approval turn kind: ${options.kind}`);\n\tif (!options.text.trim()) throw new Error(\"Approval turn text must not be empty\");\n\tassertEvidence(options.evidence);\n\treturn withApprovalLock(options.paths, options.proposalId, async () => {\n\t\tconst resolved = await ensureRevisionDirectory(options.paths, options.proposalId, options.revision);\n\t\tconst approvalFile = join(resolved.proposalDirectory, \"approval.jsonl\");\n\t\tawait assertWritableFilePath(approvalFile, \"Proposal approval log\");\n\t\tconst previous = await readApprovalTurnsUnlocked(options.paths, options.proposalId);\n\t\tconst turn: ApprovalTurn = {\n\t\t\tschemaVersion: 1,\n\t\t\tproposalId: options.proposalId,\n\t\t\tsequence: previous.length + 1,\n\t\t\ttimestamp: (options.now?.() ?? new Date()).toISOString(),\n\t\t\trevision: options.revision,\n\t\t\tdiffDigest: options.diffDigest,\n\t\t\trole: options.role,\n\t\t\tkind: options.kind,\n\t\t\ttext: options.text,\n\t\t\t...(options.evidence ? { evidence: options.evidence.map((reference) => ({ ...reference })) } : {}),\n\t\t};\n\t\tawait appendJsonLine(approvalFile, turn);\n\t\treturn turn;\n\t});\n}\n\nexport async function readApprovalTurns(paths: EvoPaths, proposalId: string): Promise<ApprovalTurn[]> {\n\tassertProposalRevision(proposalId, 1);\n\treturn withApprovalLock(paths, proposalId, () => readApprovalTurnsUnlocked(paths, proposalId));\n}\n"]}