From 54ae12e6edc18a0ed6baa51c5fbd160631a89c37 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:13:55 +0900 Subject: [PATCH 1/6] test(state-checkpoint): specify durable procedural evaluation history --- test/procedural-evaluation-history.test.mjs | 293 ++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 test/procedural-evaluation-history.test.mjs diff --git a/test/procedural-evaluation-history.test.mjs b/test/procedural-evaluation-history.test.mjs new file mode 100644 index 000000000..964b68c7c --- /dev/null +++ b/test/procedural-evaluation-history.test.mjs @@ -0,0 +1,293 @@ +import { test } from "vitest"; +import assert from "node:assert/strict"; +import { createProceduralGraph } from "../src/agent-runtime/procedural-graph.ts"; +import { assessProceduralCandidate } from "../src/agent-runtime/procedural-evolution.ts"; +import { + admitProceduralEvaluationEvidence, + proceduralEvaluationEvidenceDigest, +} from "../src/agent-runtime/procedural-evaluation-authority.ts"; +import { verifyProceduralEvaluationHandoff } from "../src/agent-runtime/procedural-evaluation-handoff.ts"; +import { + DurableProceduralEvaluationHistoryRepository, + MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS, +} from "../src/state-checkpoint/procedural-evaluation-history.ts"; + +const digest = (character) => character.repeat(64); + +class Storage { + constructor() { + this.records = new Map(); + } + + async get(key) { + const value = this.records.get(key); + return value === undefined ? undefined : structuredClone(value); + } + + async put(key, value) { + this.records.set(key, structuredClone(value)); + } + + async transaction(callback) { + return callback(this); + } +} + +async function graphs() { + const baseline = await createProceduralGraph({ + schemaVersion: "noema.procedural-graph/v1", + tenantId: "tenant-a", + taskType: "repair", + graphId: "graph-a", + revision: 1, + parentDigest: null, + nodes: ["Start", "check"], + edges: [{ + from: "Start", + relation: "requires", + to: "check", + condition: "", + guidance: "Check evidence", + pitfalls: "", + }], + }); + const candidate = await createProceduralGraph({ + schemaVersion: "noema.procedural-graph/v1", + tenantId: "tenant-a", + taskType: "repair", + graphId: "graph-a", + revision: 2, + parentDigest: baseline.digest, + nodes: ["Start", "check"], + edges: [{ + from: "Start", + relation: "requires", + to: "check", + condition: "", + guidance: "Check exact-head evidence", + pitfalls: "", + }], + }); + return { baseline, candidate }; +} + +async function screenedDecision( + baseline, + candidate, + { rejectedKeys = [], candidateScore = 0.8, safetyViolations = 0 } = {}, +) { + const contextDigest = digest("c"); + return assessProceduralCandidate({ + baseline, + candidate, + plan: { + contextDigest, + minimumCases: 2, + trainingCaseIds: ["train-1"], + holdoutCaseIds: ["case-1", "case-2"], + }, + baselineReceipt: { + graphDigest: baseline.digest, + contextDigest, + observations: [ + { caseId: "case-1", score: 0.5, safetyViolations: 0 }, + { caseId: "case-2", score: 0.6, safetyViolations: 0 }, + ], + }, + candidateReceipt: { + graphDigest: candidate.digest, + contextDigest, + observations: [ + { caseId: "case-1", score: candidateScore, safetyViolations }, + { caseId: "case-2", score: candidateScore, safetyViolations }, + ], + }, + rejectedKeys, + }); +} + +const authorityInput = () => ({ + schemaVersion: "noema.procedural-evaluation-authority/v1", + evaluatorId: "evaluation-owner", + evaluatorVersion: "eval-v1", + policyVersion: "policy-v1", + datasetDigest: digest("1"), + rubricDigest: digest("2"), + modelIdentityDigest: digest("3"), + toolIdentityDigest: digest("4"), + protocolDigest: digest("5"), + validationPlanDigest: digest("6"), +}); + +async function evaluationEvidence(decision) { + const metadata = authorityInput(); + const expected = await proceduralEvaluationEvidenceDigest(decision, metadata); + return admitProceduralEvaluationEvidence(decision, metadata, expected); +} + +async function keyPair() { + return crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); +} + +function canonicalMessage(envelopeDigest, signerKeyId, issuedAtEpochSeconds, expiresAtEpochSeconds) { + return new TextEncoder().encode(JSON.stringify([ + "noema.procedural-evaluation-handoff/v1", + envelopeDigest, + signerKeyId, + issuedAtEpochSeconds, + expiresAtEpochSeconds, + ])); +} + +async function authenticatedEvaluation(decision, keys, sequence = 0) { + const evidence = await evaluationEvidence(decision); + const now = Math.floor(Date.now() / 1000); + const issuedAtEpochSeconds = now - 130 + sequence; + const expiresAtEpochSeconds = now + 130; + const signerKeyId = "keyverse:evaluator/procedural-v1"; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + keys.privateKey, + canonicalMessage(evidence.envelopeDigest, signerKeyId, issuedAtEpochSeconds, expiresAtEpochSeconds), + ); + return verifyProceduralEvaluationHandoff(evidence, { + schemaVersion: "noema.procedural-evaluation-handoff/v1", + envelopeDigest: evidence.envelopeDigest, + signerKeyId, + issuedAtEpochSeconds, + expiresAtEpochSeconds, + signature: Buffer.from(signature).toString("base64url"), + }, { + signerKeyId, + verificationKey: keys.publicKey, + }); +} + +test("persists an authenticated rejection and reconstructs its exact durable history after restart", async () => { + const { baseline, candidate } = await graphs(); + const decision = await screenedDecision(baseline, candidate, { safetyViolations: 1 }); + const keys = await keyPair(); + const authenticated = await authenticatedEvaluation(decision, keys); + const storage = new Storage(); + const repository = new DurableProceduralEvaluationHistoryRepository(storage); + + const appended = await repository.append(candidate, authenticated, 0); + assert.equal(appended.kind, "accepted"); + assert.equal(appended.snapshot.version, 1); + assert.deepEqual(appended.snapshot.rejectedKeys, [decision.rejectionKey]); + assert.equal(appended.event.candidateDigest, candidate.digest); + assert.equal(appended.event.handoffDigest, authenticated.handoffDigest); + assert.equal(appended.event.activationAuthorized, false); + + const restarted = new DurableProceduralEvaluationHistoryRepository(storage); + const recovered = await restarted.read(candidate); + assert.equal(recovered?.version, 1); + assert.equal(recovered?.events.length, 1); + assert.equal(recovered?.events[0].decisionReason, "safety_violation"); + assert.deepEqual(recovered?.rejectedKeys, [decision.rejectionKey]); +}); + +test("returns an exact authenticated replay without consuming a new CAS version", async () => { + const { baseline, candidate } = await graphs(); + const decision = await screenedDecision(baseline, candidate); + const keys = await keyPair(); + const authenticated = await authenticatedEvaluation(decision, keys); + const repository = new DurableProceduralEvaluationHistoryRepository(new Storage()); + + const first = await repository.append(candidate, authenticated, 0); + const replay = await repository.append(candidate, authenticated, 99); + + assert.equal(first.kind, "accepted"); + assert.equal(replay.kind, "replay"); + assert.equal(replay.snapshot.version, 1); + assert.equal(replay.event.eventDigest, first.event.eventDigest); +}); + +test("rejects stale writers and decisions that omit the canonical durable rejection context", async () => { + const { baseline, candidate } = await graphs(); + const rejected = await screenedDecision(baseline, candidate, { safetyViolations: 1 }); + const eligibleWithoutHistory = await screenedDecision(baseline, candidate); + const keys = await keyPair(); + const storage = new Storage(); + const repository = new DurableProceduralEvaluationHistoryRepository(storage); + + await repository.append(candidate, await authenticatedEvaluation(rejected, keys, 0), 0); + await assert.rejects( + repository.append(candidate, await authenticatedEvaluation(eligibleWithoutHistory, keys, 1), 1), + /decision does not reflect durable rejection history/, + ); + + const withHistory = await screenedDecision(baseline, candidate, { rejectedKeys: [rejected.rejectionKey] }); + assert.equal(withHistory.reason, "previously_rejected"); + const accepted = await repository.append(candidate, await authenticatedEvaluation(withHistory, keys, 2), 1); + assert.equal(accepted.snapshot.version, 2); + assert.deepEqual(accepted.snapshot.rejectedKeys, [rejected.rejectionKey]); + + const fresh = new DurableProceduralEvaluationHistoryRepository(new Storage()); + await fresh.append(candidate, await authenticatedEvaluation(eligibleWithoutHistory, keys, 3), 0); + await assert.rejects( + fresh.append(candidate, await authenticatedEvaluation(eligibleWithoutHistory, keys, 4), 0), + /expected history version lost the CAS race/, + ); +}); + +test("refuses unadmitted evaluator objects, wrong graph lineage, and corrupted retained bytes", async () => { + const { baseline, candidate } = await graphs(); + const decision = await screenedDecision(baseline, candidate, { candidateScore: 0.4 }); + const keys = await keyPair(); + const authenticated = await authenticatedEvaluation(decision, keys); + const storage = new Storage(); + const repository = new DurableProceduralEvaluationHistoryRepository(storage); + + await assert.rejects( + repository.append(candidate, structuredClone(authenticated), 0), + /unadmitted_authenticated_evaluation/, + ); + await assert.rejects( + repository.append(baseline, authenticated, 0), + /authenticated evaluation does not bind the admitted candidate graph/, + ); + await assert.rejects( + repository.append(candidate, authenticated, -1), + /expected history version is not canonical/, + ); + assert.equal(await repository.read(candidate), null); + + await repository.append(candidate, authenticated, 0); + const [key, retained] = storage.records.entries().next().value; + retained.headEventDigest = digest("f"); + storage.records.set(key, retained); + await assert.rejects(repository.read(candidate), /durable procedural history integrity check failed/); +}); + +test("fails closed at the bounded full-history capacity without discarding rejection evidence", async () => { + const { baseline, candidate } = await graphs(); + const decision = await screenedDecision(baseline, candidate); + const keys = await keyPair(); + const storage = new Storage(); + const repository = new DurableProceduralEvaluationHistoryRepository(storage); + + for (let index = 0; index < MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS; index += 1) { + const authenticated = await authenticatedEvaluation(decision, keys, index); + const result = await repository.append(candidate, authenticated, index); + assert.equal(result.snapshot.version, index + 1); + } + + const before = await repository.read(candidate); + assert.equal(before?.events.length, MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS); + await assert.rejects( + repository.append( + candidate, + await authenticatedEvaluation(decision, keys, MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS), + MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS, + ), + /durable procedural history capacity is exhausted/, + ); + const after = await repository.read(candidate); + assert.equal(after?.version, before?.version); + assert.deepEqual(after?.rejectedKeys, before?.rejectedKeys); +}); From b9306068e38c6fc12d406fef1b4a5cf86188dd98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:16:05 +0900 Subject: [PATCH 2/6] feat(state-checkpoint): persist procedural evaluation history --- .../procedural-evaluation-history.ts | 385 ++++++++++++++++++ 1 file changed, 385 insertions(+) create mode 100644 src/state-checkpoint/procedural-evaluation-history.ts diff --git a/src/state-checkpoint/procedural-evaluation-history.ts b/src/state-checkpoint/procedural-evaluation-history.ts new file mode 100644 index 000000000..e78ab2ab2 --- /dev/null +++ b/src/state-checkpoint/procedural-evaluation-history.ts @@ -0,0 +1,385 @@ +import { + assertAuthenticatedProceduralEvaluationEvidence, + type AuthenticatedProceduralEvaluationEvidence, +} from "../agent-runtime/procedural-evaluation-handoff"; +import { + assertProceduralGraph, + type ProceduralGraph, +} from "../agent-runtime/procedural-graph"; + +const HISTORY_SCHEMA_VERSION = "noema.procedural-evaluation-history/v1" as const; +const EVENT_SCHEMA_VERSION = "noema.procedural-evaluation-history-event/v1" as const; +const SHA256 = /^[0-9a-f]{64}$/u; +const DECISION_REASONS = new Set([ + "unchanged_graph", + "previously_rejected", + "safety_violation", + "score_regression", + "validation_non_regression", +]); + +/** + * Maximum complete evaluation events retained for one procedural graph lineage. The repository never + * evicts rejection evidence silently; exhaustion fails closed until an explicit rollover design exists. + */ +export const MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS = 128; + +/** + * Stable Noema-owned partition identity for durable procedural evaluation history. It names only the + * Agent Runtime graph scope and does not copy workflow, policy, provider, or foreign-domain truth. + */ +export interface ProceduralEvaluationHistoryStream { + readonly tenantId: string; + readonly taskType: string; + readonly graphId: string; +} + +/** + * Payload-minimized immutable receipt for one authenticated procedural evaluation. Digests and opaque + * signer identity are retained without signatures, graph text, prompts, provider credentials, or approval. + */ +export interface ProceduralEvaluationHistoryEvent { + readonly schemaVersion: typeof EVENT_SCHEMA_VERSION; + readonly version: number; + readonly candidateRevision: number; + readonly baselineDigest: string; + readonly candidateDigest: string; + readonly contextDigest: string; + readonly baselineReceiptDigest: string; + readonly candidateReceiptDigest: string; + readonly rejectionKey: string; + readonly decisionReason: string; + readonly envelopeDigest: string; + readonly signerKeyId: string; + readonly handoffDigest: string; + readonly issuedAtEpochSeconds: number; + readonly expiresAtEpochSeconds: number; + readonly eligibleForApproval: boolean; + readonly activationAuthorized: false; + readonly priorEventDigest: string | null; + readonly eventDigest: string; +} + +/** + * Verified durable State / Checkpoint view for one procedural graph lineage. The complete bounded event + * prefix and derived rejection-key projection are returned together so restart cannot invent lost history. + */ +export interface ProceduralEvaluationHistorySnapshot { + readonly schemaVersion: typeof HISTORY_SCHEMA_VERSION; + readonly stream: ProceduralEvaluationHistoryStream; + readonly version: number; + readonly headEventDigest: string; + readonly rejectedKeys: readonly string[]; + readonly events: readonly ProceduralEvaluationHistoryEvent[]; +} + +/** Result of one atomic append, distinguishing a new CAS winner from an exact authenticated replay. */ +export interface ProceduralEvaluationHistoryAppendResult { + readonly kind: "accepted" | "replay"; + readonly event: ProceduralEvaluationHistoryEvent; + readonly snapshot: ProceduralEvaluationHistorySnapshot; +} + +/** Raised when durable history, graph lineage, rejection context, capacity, or CAS authority is inconsistent. */ +export class ProceduralEvaluationHistoryConflictError extends Error { + constructor(message: string) { + super(message); + this.name = "ProceduralEvaluationHistoryConflictError"; + } +} + +type MutableHistory = { + schemaVersion: typeof HISTORY_SCHEMA_VERSION; + stream: ProceduralEvaluationHistoryStream; + version: number; + headEventDigest: string; + rejectedKeys: string[]; + events: ProceduralEvaluationHistoryEvent[]; +}; + +type HistoryStorage = Pick; +type HistoryTransaction = Pick; + +function requireHistory(condition: boolean, message: string): asserts condition { + if (!condition) throw new ProceduralEvaluationHistoryConflictError(message); +} + +async function sha256(value: unknown): Promise { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function streamFromGraph(graph: ProceduralGraph): ProceduralEvaluationHistoryStream { + return Object.freeze({ tenantId: graph.tenantId, taskType: graph.taskType, graphId: graph.graphId }); +} + +async function storageKey(stream: ProceduralEvaluationHistoryStream): Promise { + const digest = await sha256([HISTORY_SCHEMA_VERSION, stream.tenantId, stream.taskType, stream.graphId]); + return `procedural-evaluation-history:v1:${digest}`; +} + +function eventHashMaterial( + stream: ProceduralEvaluationHistoryStream, + event: Omit, +): unknown { + return [ + event.schemaVersion, + stream.tenantId, + stream.taskType, + stream.graphId, + event.version, + event.candidateRevision, + event.baselineDigest, + event.candidateDigest, + event.contextDigest, + event.baselineReceiptDigest, + event.candidateReceiptDigest, + event.rejectionKey, + event.decisionReason, + event.envelopeDigest, + event.signerKeyId, + event.handoffDigest, + event.issuedAtEpochSeconds, + event.expiresAtEpochSeconds, + event.eligibleForApproval, + event.activationAuthorized, + event.priorEventDigest, + ]; +} + +function frozenSnapshot(history: MutableHistory): ProceduralEvaluationHistorySnapshot { + return Object.freeze({ + schemaVersion: history.schemaVersion, + stream: Object.freeze({ ...history.stream }), + version: history.version, + headEventDigest: history.headEventDigest, + rejectedKeys: Object.freeze([...history.rejectedKeys]), + events: Object.freeze(history.events.map((event) => Object.freeze({ ...event }))), + }); +} + +function assertExpectedVersion(value: number): void { + requireHistory(Number.isSafeInteger(value), "expected history version is not canonical"); + requireHistory(value >= 0, "expected history version is not canonical"); + requireHistory(value <= MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS, "expected history version is not canonical"); +} + +async function verifyStoredHistory( + value: unknown, + expectedStream: ProceduralEvaluationHistoryStream, +): Promise { + requireHistory(value !== null, "durable procedural history integrity check failed"); + requireHistory(typeof value === "object", "durable procedural history integrity check failed"); + requireHistory(!Array.isArray(value), "durable procedural history integrity check failed"); + const history = value as MutableHistory; + requireHistory(history.schemaVersion === HISTORY_SCHEMA_VERSION, "durable procedural history integrity check failed"); + requireHistory(JSON.stringify(history.stream) === JSON.stringify(expectedStream), "durable procedural history integrity check failed"); + requireHistory(Number.isSafeInteger(history.version), "durable procedural history integrity check failed"); + requireHistory(history.version >= 1, "durable procedural history integrity check failed"); + requireHistory(history.version <= MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS, "durable procedural history integrity check failed"); + requireHistory(Array.isArray(history.events), "durable procedural history integrity check failed"); + requireHistory(history.events.length === history.version, "durable procedural history integrity check failed"); + requireHistory(Array.isArray(history.rejectedKeys), "durable procedural history integrity check failed"); + requireHistory(typeof history.headEventDigest === "string", "durable procedural history integrity check failed"); + requireHistory(SHA256.test(history.headEventDigest), "durable procedural history integrity check failed"); + + const rejected = new Set(); + let priorEventDigest: string | null = null; + for (let index = 0; index < history.events.length; index += 1) { + const event = history.events[index]; + requireHistory(event !== null, "durable procedural history integrity check failed"); + requireHistory(typeof event === "object", "durable procedural history integrity check failed"); + requireHistory(event.schemaVersion === EVENT_SCHEMA_VERSION, "durable procedural history integrity check failed"); + requireHistory(event.version === index + 1, "durable procedural history integrity check failed"); + requireHistory(Number.isSafeInteger(event.candidateRevision), "durable procedural history integrity check failed"); + requireHistory(event.candidateRevision >= 2, "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.baselineDigest), "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.candidateDigest), "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.contextDigest), "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.baselineReceiptDigest), "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.candidateReceiptDigest), "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.rejectionKey), "durable procedural history integrity check failed"); + requireHistory(DECISION_REASONS.has(event.decisionReason), "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.envelopeDigest), "durable procedural history integrity check failed"); + requireHistory(typeof event.signerKeyId === "string", "durable procedural history integrity check failed"); + requireHistory(event.signerKeyId.length >= 1, "durable procedural history integrity check failed"); + requireHistory(event.signerKeyId.length <= 128, "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.handoffDigest), "durable procedural history integrity check failed"); + requireHistory(Number.isSafeInteger(event.issuedAtEpochSeconds), "durable procedural history integrity check failed"); + requireHistory(Number.isSafeInteger(event.expiresAtEpochSeconds), "durable procedural history integrity check failed"); + requireHistory(event.expiresAtEpochSeconds > event.issuedAtEpochSeconds, "durable procedural history integrity check failed"); + requireHistory(typeof event.eligibleForApproval === "boolean", "durable procedural history integrity check failed"); + requireHistory(event.activationAuthorized === false, "durable procedural history integrity check failed"); + requireHistory(event.priorEventDigest === priorEventDigest, "durable procedural history integrity check failed"); + requireHistory(SHA256.test(event.eventDigest), "durable procedural history integrity check failed"); + const observedDigest = await sha256(eventHashMaterial(expectedStream, event)); + requireHistory(event.eventDigest === observedDigest, "durable procedural history integrity check failed"); + requireHistory(event.eligibleForApproval === (event.decisionReason === "validation_non_regression"), "durable procedural history integrity check failed"); + if (event.decisionReason === "previously_rejected") { + requireHistory(rejected.has(event.rejectionKey), "durable procedural history integrity check failed"); + } else { + requireHistory(!rejected.has(event.rejectionKey), "durable procedural history integrity check failed"); + } + if (!event.eligibleForApproval) rejected.add(event.rejectionKey); + priorEventDigest = event.eventDigest; + } + + const derivedRejectedKeys = [...rejected].sort(); + requireHistory(JSON.stringify(history.rejectedKeys) === JSON.stringify(derivedRejectedKeys), "durable procedural history integrity check failed"); + requireHistory(history.headEventDigest === priorEventDigest, "durable procedural history integrity check failed"); + return history; +} + +function replayMatches( + event: ProceduralEvaluationHistoryEvent, + candidate: ProceduralGraph, + authenticated: AuthenticatedProceduralEvaluationEvidence, +): boolean { + const evidence = authenticated.evidence; + return JSON.stringify([ + event.candidateRevision, + event.baselineDigest, + event.candidateDigest, + event.contextDigest, + event.baselineReceiptDigest, + event.candidateReceiptDigest, + event.rejectionKey, + event.decisionReason, + event.envelopeDigest, + event.signerKeyId, + event.handoffDigest, + event.issuedAtEpochSeconds, + event.expiresAtEpochSeconds, + event.eligibleForApproval, + ]) === JSON.stringify([ + candidate.revision, + evidence.baselineDigest, + evidence.candidateDigest, + evidence.contextDigest, + evidence.baselineReceiptDigest, + evidence.candidateReceiptDigest, + evidence.rejectionKey, + evidence.decisionReason, + evidence.envelopeDigest, + authenticated.signerKeyId, + authenticated.handoffDigest, + authenticated.issuedAtEpochSeconds, + authenticated.expiresAtEpochSeconds, + evidence.eligibleForApproval, + ]); +} + +/** + * State / Checkpoint repository for authenticated procedural evaluation and rejection evidence. It + * persists a bounded complete lineage under atomic CAS without creating workflow, policy, or activation truth. + */ +export class DurableProceduralEvaluationHistoryRepository { + constructor(private readonly storage: HistoryStorage) {} + + /** + * Reads and verifies the complete bounded history for one locally admitted procedural graph lineage. + * Missing state returns null; retained corruption, truncation, or projection drift fails closed. + * @param candidate Locally admitted graph whose tenant/task/graph identity selects the durable lineage. + * @returns Verified immutable history snapshot, or null when no evaluation has been retained yet. + */ + async read(candidate: ProceduralGraph): Promise { + assertProceduralGraph(candidate); + const stream = streamFromGraph(candidate); + const value = await this.storage.get(await storageKey(stream)); + if (value === undefined) return null; + return frozenSnapshot(await verifyStoredHistory(value, stream)); + } + + /** + * Atomically retains one still-current authenticated evaluator handoff for the exact candidate graph. + * Exact replay is idempotent; stale CAS, stale rejection context, full history, or lineage drift fails closed. + * @param candidate Locally admitted direct-child graph bound by the authenticated evaluation evidence. + * @param authenticated Still-current process-local signed evaluator authority produced by Agent Runtime. + * @param expectedVersion Exact durable history version observed by the caller before this append attempt. + * @returns Accepted append or exact replay together with a verified immutable State / Checkpoint snapshot. + */ + async append( + candidate: ProceduralGraph, + authenticated: AuthenticatedProceduralEvaluationEvidence, + expectedVersion: number, + ): Promise { + assertProceduralGraph(candidate); + assertAuthenticatedProceduralEvaluationEvidence(authenticated); + assertExpectedVersion(expectedVersion); + const evidence = authenticated.evidence; + requireHistory(candidate.parentDigest !== null, "authenticated evaluation does not bind the admitted candidate graph"); + requireHistory(evidence.candidateDigest === candidate.digest, "authenticated evaluation does not bind the admitted candidate graph"); + requireHistory(evidence.baselineDigest === candidate.parentDigest, "authenticated evaluation does not bind the admitted candidate graph"); + const stream = streamFromGraph(candidate); + const key = await storageKey(stream); + + return this.storage.transaction(async (transaction: HistoryTransaction) => { + const retained = await transaction.get(key); + let history: MutableHistory; + if (retained === undefined) { + requireHistory(expectedVersion === 0, "expected history version lost the CAS race"); + history = { + schemaVersion: HISTORY_SCHEMA_VERSION, + stream, + version: 0, + headEventDigest: "", + rejectedKeys: [], + events: [], + }; + } else { + history = await verifyStoredHistory(retained, stream); + const replay = history.events.find((event) => event.handoffDigest === authenticated.handoffDigest); + if (replay !== undefined) { + requireHistory(replayMatches(replay, candidate, authenticated), "authenticated replay names different durable semantics"); + return { kind: "replay" as const, event: Object.freeze({ ...replay }), snapshot: frozenSnapshot(history) }; + } + requireHistory(history.version === expectedVersion, "expected history version lost the CAS race"); + } + + requireHistory(history.events.length < MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS, "durable procedural history capacity is exhausted"); + const rejectionKeys = new Set(history.rejectedKeys); + if (evidence.decisionReason === "previously_rejected") { + requireHistory(rejectionKeys.has(evidence.rejectionKey), "decision does not reflect durable rejection history"); + } else { + requireHistory(!rejectionKeys.has(evidence.rejectionKey), "decision does not reflect durable rejection history"); + } + if (!evidence.eligibleForApproval) rejectionKeys.add(evidence.rejectionKey); + + const withoutDigest: Omit = { + schemaVersion: EVENT_SCHEMA_VERSION, + version: history.version + 1, + candidateRevision: candidate.revision, + baselineDigest: evidence.baselineDigest, + candidateDigest: evidence.candidateDigest, + contextDigest: evidence.contextDigest, + baselineReceiptDigest: evidence.baselineReceiptDigest, + candidateReceiptDigest: evidence.candidateReceiptDigest, + rejectionKey: evidence.rejectionKey, + decisionReason: evidence.decisionReason, + envelopeDigest: evidence.envelopeDigest, + signerKeyId: authenticated.signerKeyId, + handoffDigest: authenticated.handoffDigest, + issuedAtEpochSeconds: authenticated.issuedAtEpochSeconds, + expiresAtEpochSeconds: authenticated.expiresAtEpochSeconds, + eligibleForApproval: evidence.eligibleForApproval, + activationAuthorized: false, + priorEventDigest: history.version === 0 ? null : history.headEventDigest, + }; + const event: ProceduralEvaluationHistoryEvent = Object.freeze({ + ...withoutDigest, + eventDigest: await sha256(eventHashMaterial(stream, withoutDigest)), + }); + const next: MutableHistory = { + schemaVersion: HISTORY_SCHEMA_VERSION, + stream, + version: event.version, + headEventDigest: event.eventDigest, + rejectedKeys: [...rejectionKeys].sort(), + events: [...history.events, event], + }; + await transaction.put(key, next); + return { kind: "accepted" as const, event, snapshot: frozenSnapshot(next) }; + }); + } +} From 7e6b85573abe17db88437c25d85aa500f9113a5c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:24:20 +0900 Subject: [PATCH 3/6] test(state-checkpoint): make bounded history coverage deterministic --- test/procedural-evaluation-history.test.mjs | 82 +++++++++++++++++++-- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/test/procedural-evaluation-history.test.mjs b/test/procedural-evaluation-history.test.mjs index 964b68c7c..02e2fb8e9 100644 --- a/test/procedural-evaluation-history.test.mjs +++ b/test/procedural-evaluation-history.test.mjs @@ -33,6 +33,65 @@ class Storage { } } +async function hash(value) { + const bytes = new TextEncoder().encode(JSON.stringify(value)); + const result = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(result)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function eventMaterial(stream, event) { + return [ + event.schemaVersion, + stream.tenantId, + stream.taskType, + stream.graphId, + event.version, + event.candidateRevision, + event.baselineDigest, + event.candidateDigest, + event.contextDigest, + event.baselineReceiptDigest, + event.candidateReceiptDigest, + event.rejectionKey, + event.decisionReason, + event.envelopeDigest, + event.signerKeyId, + event.handoffDigest, + event.issuedAtEpochSeconds, + event.expiresAtEpochSeconds, + event.eligibleForApproval, + event.activationAuthorized, + event.priorEventDigest, + ]; +} + +async function extendRetainedHistory(storage, targetVersion, { duplicateHandoff = false } = {}) { + const [key, retained] = storage.records.entries().next().value; + const template = retained.events[0]; + let priorEventDigest = template.eventDigest; + for (let version = 2; version <= targetVersion; version += 1) { + const handoffDigest = duplicateHandoff && version === 2 + ? template.handoffDigest + : await hash(["capacity-handoff", version]); + const withoutDigest = { + ...template, + version, + handoffDigest, + priorEventDigest, + }; + delete withoutDigest.eventDigest; + const event = { + ...withoutDigest, + eventDigest: await hash(eventMaterial(retained.stream, withoutDigest)), + }; + retained.events.push(event); + priorEventDigest = event.eventDigest; + } + retained.version = targetVersion; + retained.headEventDigest = priorEventDigest; + storage.records.set(key, retained); +} + async function graphs() { const baseline = await createProceduralGraph({ schemaVersion: "noema.procedural-graph/v1", @@ -264,25 +323,34 @@ test("refuses unadmitted evaluator objects, wrong graph lineage, and corrupted r await assert.rejects(repository.read(candidate), /durable procedural history integrity check failed/); }); -test("fails closed at the bounded full-history capacity without discarding rejection evidence", async () => { +test("rejects duplicate authenticated handoff identities inside retained history", async () => { const { baseline, candidate } = await graphs(); const decision = await screenedDecision(baseline, candidate); const keys = await keyPair(); const storage = new Storage(); const repository = new DurableProceduralEvaluationHistoryRepository(storage); - for (let index = 0; index < MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS; index += 1) { - const authenticated = await authenticatedEvaluation(decision, keys, index); - const result = await repository.append(candidate, authenticated, index); - assert.equal(result.snapshot.version, index + 1); - } + await repository.append(candidate, await authenticatedEvaluation(decision, keys), 0); + await extendRetainedHistory(storage, 2, { duplicateHandoff: true }); + await assert.rejects(repository.read(candidate), /durable procedural history integrity check failed/); +}); + +test("fails closed at the bounded full-history capacity without discarding retained evidence", async () => { + const { baseline, candidate } = await graphs(); + const decision = await screenedDecision(baseline, candidate); + const keys = await keyPair(); + const storage = new Storage(); + const repository = new DurableProceduralEvaluationHistoryRepository(storage); + + await repository.append(candidate, await authenticatedEvaluation(decision, keys, 0), 0); + await extendRetainedHistory(storage, MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS); const before = await repository.read(candidate); assert.equal(before?.events.length, MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS); await assert.rejects( repository.append( candidate, - await authenticatedEvaluation(decision, keys, MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS), + await authenticatedEvaluation(decision, keys, 1), MAX_PROCEDURAL_EVALUATION_HISTORY_EVENTS, ), /durable procedural history capacity is exhausted/, From c2ee6ba195182a1a8fb2da7d55656e66e45207c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:25:41 +0900 Subject: [PATCH 4/6] fix(state-checkpoint): reject duplicate procedural handoff history --- src/state-checkpoint/procedural-evaluation-history.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/state-checkpoint/procedural-evaluation-history.ts b/src/state-checkpoint/procedural-evaluation-history.ts index e78ab2ab2..69e413311 100644 --- a/src/state-checkpoint/procedural-evaluation-history.ts +++ b/src/state-checkpoint/procedural-evaluation-history.ts @@ -185,6 +185,7 @@ async function verifyStoredHistory( requireHistory(SHA256.test(history.headEventDigest), "durable procedural history integrity check failed"); const rejected = new Set(); + const handoffDigests = new Set(); let priorEventDigest: string | null = null; for (let index = 0; index < history.events.length; index += 1) { const event = history.events[index]; @@ -206,6 +207,8 @@ async function verifyStoredHistory( requireHistory(event.signerKeyId.length >= 1, "durable procedural history integrity check failed"); requireHistory(event.signerKeyId.length <= 128, "durable procedural history integrity check failed"); requireHistory(SHA256.test(event.handoffDigest), "durable procedural history integrity check failed"); + requireHistory(!handoffDigests.has(event.handoffDigest), "durable procedural history integrity check failed"); + handoffDigests.add(event.handoffDigest); requireHistory(Number.isSafeInteger(event.issuedAtEpochSeconds), "durable procedural history integrity check failed"); requireHistory(Number.isSafeInteger(event.expiresAtEpochSeconds), "durable procedural history integrity check failed"); requireHistory(event.expiresAtEpochSeconds > event.issuedAtEpochSeconds, "durable procedural history integrity check failed"); From 943d06defa6df274cb3b25d20861012725202f60 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:37:07 +0900 Subject: [PATCH 5/6] test(state-checkpoint): verify retained rejection replay --- test/procedural-evaluation-history.test.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/procedural-evaluation-history.test.mjs b/test/procedural-evaluation-history.test.mjs index 02e2fb8e9..fa6b85f8c 100644 --- a/test/procedural-evaluation-history.test.mjs +++ b/test/procedural-evaluation-history.test.mjs @@ -286,6 +286,11 @@ test("rejects stale writers and decisions that omit the canonical durable reject assert.equal(accepted.snapshot.version, 2); assert.deepEqual(accepted.snapshot.rejectedKeys, [rejected.rejectionKey]); + const restarted = new DurableProceduralEvaluationHistoryRepository(storage); + const recovered = await restarted.read(candidate); + assert.equal(recovered?.version, 2); + assert.equal(recovered?.events[1]?.decisionReason, "previously_rejected"); + const fresh = new DurableProceduralEvaluationHistoryRepository(new Storage()); await fresh.append(candidate, await authenticatedEvaluation(eligibleWithoutHistory, keys, 3), 0); await assert.rejects( From 9e7237ed2fe92fca779aee2d7c30fce1f38b09b6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 10 Sep 2026 22:39:28 +0900 Subject: [PATCH 6/6] docs(state-checkpoint): record procedural evaluation history --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 796f84cb0..f8428f018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ - Add a Noema-owned exact-claim evidence receipt contract whose execution and research producers serialize one canonical artifact that binds every receipt semantic field, including command/result/isolation/network or source revision/excerpt/retrieval policy. Admission accepts only a receipt ID from untrusted model output. The owner API first verifies the exact authenticated OpenCode-handoff manifest digest, canonical envelope bytes, reviewed producer-to-kind policy, and repository/head/workflow/run/attempt identity before it can construct an immutable typed index; admission then reconstructs each canonical artifact and verifies time/claim/artifact identity. The version-2 manifest now binds a separate producer-authenticated `ClaimEvidenceRequirement` containing the exact claim, independently required evidence kind, and `context` or `finding` publication authority. Raw current-head source lines are context only: they are withheld from finding-reference prompts and cannot publish a finding or `request_changes`; an explicitly producer-authorized source finding remains usable and retains exact path/line checks. Finding-free model `request_changes` and `blocked` verdicts cannot bypass receipt admission to publish a vacuous blocking review. Requirement/receipt kind mismatch, fixed-artifact semantic substitution, caller-supplied receipt dictionaries, model self-classification, stale identities, cross-kind receipts, marker-only sandbox output, noncanonical artifact bytes, and expired receipts fail closed before the GitHub publisher. This remains the owner prerequisite for ContextualWisdomLab/.github#1641 and issue #555. The reviewed `sandboxed_verify` adapter exists in owner source, but its actual central stdout/stderr/marker-to-manifest wiring and the trusted research producer are not yet integrated; exact-head hosted GREEN, immutable release, and the verified central consumer bump remain required. ## Unreleased +- State / Checkpoint에 authenticated procedural evaluation/rejection history를 추가한다. 기존 Agent Runtime이 admit한 exact graph/evaluation/handoff identity만 tenant/task/graph lineage별 bounded complete event chain으로 저장하고 monotonic CAS·exact replay·digest-chain integrity·durable rejection projection을 검증한다. stale writer, rejection-context omission, structural forgery, wrong lineage, retained-byte corruption, duplicate handoff, 128-event capacity 초과는 실패-폐쇄하며 eviction은 하지 않는다. 이 저장소는 Workflow / Task lifecycle, Policy / Approval, graph publication/promotion 또는 activation authority를 만들지 않고 모든 event는 `activationAuthorized:false`를 유지한다. issue #584, PR #597. - Agent Runtime의 procedural evaluation envelope이 baseline/candidate/context/paired receipt identity뿐 아니라 deterministic `rejectionKey`, screening reason, `eligibleForApproval`까지 함께 해시한다. 동일 graph/evaluation evidence라도 durable rejection history에 따라 `validation_non_regression`과 `previously_rejected` 처분이 달라지면 envelope identity도 달라져, 하나의 signed evaluator handoff를 다른 approval-eligibility 결과에 재사용할 수 없다. 반환 evidence에도 rejection/disposition identity를 보존하며 `activationAuthorized:false`, Keyverse key custody, State / Checkpoint persistence, Policy / Approval, publication 경계는 그대로 유지한다. issue #584, PR #596. - Agent Runtime이 separately authenticated evaluator handoff의 P-256 ECDSA 서명을 composition root가 공급한 trusted public key/key id로 검증하고 exact evaluation envelope digest와 최대 300초 validity window를 결합한다. 검증된 evidence도 signed expiry 이후에는 process-local authority로 재사용할 수 없으며 structural copy·wrong key/signer/envelope·expired/future handoff는 실패-폐쇄한다. Keyverse/owner가 signer identity·key custody를 유지하고 Noema는 private key·credential discovery·provider routing·durable State / Checkpoint·Policy / Approval·publication·activation authority를 소유하지 않는다. issue #584, PR #594. - Agent Runtime의 screened procedural decision과 evaluator/profile 조건을 하나의 canonical envelope digest에 결합하고, separately authenticated evaluator handoff에서 받은 exact digest와 일치할 때만 process-local evaluation evidence로 admit한다. 이 adapter는 binding만 검증하며 digest 재계산 자체를 evaluator authentication으로 취급하지 않고, Keyverse/owner signer trust·credential·provider routing·durable State / Checkpoint·Policy / Approval·publication·activation authority를 소유하지 않는다. 모든 evidence는 `activationAuthorized:false`를 유지한다. issue #584, PR #593.