From 2dd3a74d48d835aa59ff700d0c118cd7c34050aa Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:10:06 +0900 Subject: [PATCH 01/47] feat(core): add judgment contract types, parsers and intention transition (F1-A) --- packages/lina-core/src/agents/index.ts | 21 + .../src/agents/judgment-validation.ts | 911 ++++++++++++++++++ packages/lina-core/src/agents/judgment.ts | 206 ++++ .../lina-core/test/judgment-contract.test.ts | 758 +++++++++++++++ 4 files changed, 1896 insertions(+) create mode 100644 packages/lina-core/src/agents/judgment-validation.ts create mode 100644 packages/lina-core/src/agents/judgment.ts create mode 100644 packages/lina-core/test/judgment-contract.test.ts diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 4ae5a22..69db4c1 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -38,3 +38,24 @@ export { parseVisualReference, visualIdentityDigest, } from "./visual-validation.ts"; + +// Judgment contracts. + +export * from "./judgment.ts"; +export { + assessmentInputDigest, + INTENTION_TRANSITIONS, + intentionDigest, + judgmentDigest, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseIntentionTransition, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseOptionAssessment, + parseResolutionRecord, + parseSelectionSpec, + snapshotDigest, + transitionIntention, +} from "./judgment-validation.ts"; diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts new file mode 100644 index 0000000..0000eef --- /dev/null +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -0,0 +1,911 @@ +import { createHash } from "node:crypto"; +import { + ACCEPTED_BY, + type Assessment, + type AssessmentSet, + EXCLUSION_STAGES, + INTENTION_KINDS, + INTENTION_RELATIONS, + INTENTION_STATUSES, + type IntentionRecord, + type IntentionStatus, + type IntentionTransition, + type JsonObject, + type JsonValue, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + type ObjectiveProfileRef, + type OptionAssessment, + type ResolutionRecord, + SEVERITIES, + type SelectionSpec, + SITUATIONS, + STANCES, +} from "./judgment.ts"; +import { boundedId, boundedText } from "./validation.ts"; + +const MAX_LIST = 256; + +function object(value: unknown, label: string): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + return value as Record; +} + +function fields( + value: unknown, + keys: readonly string[], + label: string, + versioned = false, +): Record { + const row = object(value, label); + if (versioned && row["schemaVersion"] !== 1) + throw Error(`Unsupported ${label} schema version`); + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); + return row; +} + +function enumeration( + value: unknown, + values: readonly T[], + label: string, +): T { + const text = boundedId(value, label); + const member = values.find((item) => item === text); + if (member === undefined) throw Error(`invalid ${label}`); + return member; +} + +function revision(value: unknown, label: string, min = 0): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) + throw Error(`invalid ${label}`); + return value; +} + +function finite(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) + throw Error(`invalid ${label}`); + return value; +} + +function nullableId(value: unknown, label: string): string | null { + return value === null ? null : boundedId(value, label); +} + +function nullableText(value: unknown, label: string): string | null { + return value === null ? null : boundedText(value, label); +} + +function timestamp(value: unknown, label: string): string { + const text = boundedText(value, label); + if (!Number.isFinite(Date.parse(text))) throw Error(`invalid ${label}`); + return text; +} + +function list( + value: unknown, + parse: (item: unknown) => T, + label: string, +): T[] { + if (!Array.isArray(value) || value.length > MAX_LIST) + throw Error(`invalid ${label}`); + return Array.from(value, parse); +} + +/** Set-like lists canonicalize keys; semantic order (policy/ranking) is preserved. */ +function uniqueSorted( + items: T[], + key: (item: T) => string, + label: string, + sort = true, +): T[] { + if (new Set(items.map(key)).size !== items.length) + throw Error(`duplicate ${label}`); + return sort + ? [...items].sort((a, b) => + key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, + ) + : items; +} + +function strings(value: unknown, label: string, parse = boundedId): string[] { + return uniqueSorted( + list(value, (item) => parse(item, label), label), + (item) => item, + label, + ); +} + +function moduleRecord( + value: unknown, + parse: (item: unknown) => T, + label: string, +): Record { + const row = fields(value, MODULE_KINDS, label); + return { + clotho: parse(row["clotho"]), + lachesis: parse(row["lachesis"]), + atropos: parse(row["atropos"]), + }; +} + +function jsonValue(value: unknown): JsonValue { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return boundedText(value, "JSON text"); + if (typeof value === "number") return finite(value, "JSON number"); + if (Array.isArray(value)) return list(value, jsonValue, "JSON array"); + return jsonObject(value); +} + +function jsonObject(value: unknown): JsonObject { + const row = object(value, "JSON object"); + return Object.fromEntries( + Object.entries(row).map(([key, item]) => [ + boundedId(key, "JSON key"), + jsonValue(item), + ]), + ); +} + +export function judgmentDigest(value: unknown): string { + const canonical = (item: unknown): unknown => + Array.isArray(item) + ? item.map(canonical) + : item !== null && typeof item === "object" + ? Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [ + key, + canonical((item as Record)[key]), + ]), + ) + : item; + return createHash("sha256") + .update(JSON.stringify(canonical(value))) + .digest("hex"); +} + +export function assessmentInputDigest(input: { + snapshotDigest: string; + objectiveRef: ObjectiveProfileRef; + mechanismRevision: number; +}): string { + return judgmentDigest({ + snapshotDigest: input.snapshotDigest, + objectiveRef: input.objectiveRef, + mechanismRevision: input.mechanismRevision, + }); +} +export function snapshotDigest(ref: JudgmentSnapshotRef): string { + return judgmentDigest(ref); +} +export function intentionDigest(record: IntentionRecord): string { + return judgmentDigest(record); +} + +function objectiveProfileRef(value: unknown): ObjectiveProfileRef { + const row = fields( + value, + ["objectiveId", "revision", "digest"], + "objective profile ref", + ); + return { + objectiveId: boundedId(row["objectiveId"], "objective id"), + revision: revision(row["revision"], "objective revision", 1), + digest: boundedId(row["digest"], "objective digest"), + }; +} + +export function parseObjectiveProfile(value: unknown): ObjectiveProfile { + const row = fields( + value, + [ + "schemaVersion", + "objectiveId", + "moduleKind", + "revision", + "objective", + "comparisonCriteria", + "reconsiderationConditions", + ], + "objective profile", + true, + ); + return { + schemaVersion: 1, + objectiveId: boundedId(row["objectiveId"], "objective id"), + moduleKind: enumeration(row["moduleKind"], MODULE_KINDS, "module kind"), + revision: revision(row["revision"], "objective revision", 1), + objective: boundedText(row["objective"], "objective"), + comparisonCriteria: strings( + row["comparisonCriteria"], + "comparison criteria", + boundedText, + ), + reconsiderationConditions: strings( + row["reconsiderationConditions"], + "reconsideration conditions", + boundedText, + ), + }; +} + +export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "agentId", + "scopeId", + "sourceRefs", + "workingRevision", + "instructionRevision", + "policyRevision", + "identityRevision", + "domainRevisions", + "intentionRevision", + "objectiveProfileRefs", + "observationRef", + "frozenNeuralRef", + "situation", + "clockId", + "sequence", + "bindingGeneration", + ], + "judgment snapshot ref", + true, + ); + const sourceRefs = uniqueSorted( + list( + row["sourceRefs"], + (value) => { + const source = fields(value, ["kind", "id", "revision"], "source ref"); + return { + kind: boundedId(source["kind"], "source kind"), + id: boundedId(source["id"], "source id"), + revision: revision(source["revision"], "source revision"), + }; + }, + "source refs", + ), + (source) => JSON.stringify([source.kind, source.id, source.revision]), + "source refs", + ); + const domains = object(row["domainRevisions"], "domain revisions"); + return { + schemaVersion: 1, + roundId: boundedId(row["roundId"], "round id"), + agentId: boundedId(row["agentId"], "agent id"), + scopeId: boundedId(row["scopeId"], "scope id"), + sourceRefs, + workingRevision: revision(row["workingRevision"], "working revision"), + instructionRevision: revision( + row["instructionRevision"], + "instruction revision", + ), + policyRevision: revision(row["policyRevision"], "policy revision"), + identityRevision: revision(row["identityRevision"], "identity revision"), + domainRevisions: Object.fromEntries( + Object.keys(domains) + .sort() + .map((key) => [ + boundedId(key, "domain id"), + revision(domains[key], "domain revision"), + ]), + ), + intentionRevision: revision(row["intentionRevision"], "intention revision"), + objectiveProfileRefs: moduleRecord( + row["objectiveProfileRefs"], + objectiveProfileRef, + "objective profile refs", + ), + observationRef: nullableId(row["observationRef"], "observation ref"), + frozenNeuralRef: nullableId(row["frozenNeuralRef"], "frozen neural ref"), + situation: enumeration(row["situation"], SITUATIONS, "situation"), + clockId: boundedId(row["clockId"], "clock id"), + sequence: revision(row["sequence"], "sequence"), + bindingGeneration: revision(row["bindingGeneration"], "binding generation"), + }; +} + +export function parseOptionAssessment(value: unknown): OptionAssessment { + const row = fields( + value, + [ + "optionKey", + "stance", + "severity", + "unavailableReason", + "gain", + "loss", + "uncertainty", + "evidenceRefs", + ], + "option assessment", + ); + const stance = enumeration(row["stance"], STANCES, "stance"); + const severity = + row["severity"] === null + ? null + : enumeration(row["severity"], SEVERITIES, "severity"); + const unavailableReason = nullableText( + row["unavailableReason"], + "unavailable reason", + ); + if ((stance === "oppose") !== (severity !== null)) + throw Error("severity requires oppose stance"); + if ((stance === "unavailable") !== (unavailableReason !== null)) + throw Error("unavailable stance requires reason"); + return { + optionKey: boundedId(row["optionKey"], "option key"), + stance, + severity, + unavailableReason, + gain: boundedText(row["gain"], "gain"), + loss: boundedText(row["loss"], "loss"), + uncertainty: boundedText(row["uncertainty"], "uncertainty"), + evidenceRefs: strings(row["evidenceRefs"], "evidence refs"), + }; +} + +export function parseAssessment(value: unknown): Assessment { + const row = fields( + value, + [ + "schemaVersion", + "moduleKind", + "snapshotId", + "snapshotDigest", + "inputDigest", + "objectiveRef", + "mechanismRevision", + "completeText", + "evidenceRefs", + "proposedOptionKeys", + "objectiveAssessments", + "recommendedOptionKeys", + "detail", + "diagnostics", + ], + "assessment", + true, + ); + const moduleKind = enumeration( + row["moduleKind"], + MODULE_KINDS, + "module kind", + ); + const detail = fields(row["detail"], ["kind", "body"], "assessment detail"); + const kind = enumeration( + detail["kind"], + ["forecasts", "values", "continuity"] as const, + "assessment detail kind", + ); + const expected = { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }; + if (kind !== expected[moduleKind]) + throw Error("assessment detail kind mismatch"); + const body = jsonObject(detail["body"]); + const objectiveAssessments = uniqueSorted( + list( + row["objectiveAssessments"], + parseOptionAssessment, + "objective assessments", + ), + (item) => item.optionKey, + "assessment option keys", + ); + const recommendedOptionKeys = strings( + row["recommendedOptionKeys"], + "recommended option keys", + ); + const assessed = new Set(objectiveAssessments.map((item) => item.optionKey)); + if (recommendedOptionKeys.some((key) => !assessed.has(key))) + throw Error("recommended option is not assessed"); + const result = { + schemaVersion: 1 as const, + moduleKind, + snapshotId: boundedId(row["snapshotId"], "snapshot id"), + snapshotDigest: boundedId(row["snapshotDigest"], "snapshot digest"), + inputDigest: boundedId(row["inputDigest"], "input digest"), + objectiveRef: objectiveProfileRef(row["objectiveRef"]), + mechanismRevision: revision(row["mechanismRevision"], "mechanism revision"), + completeText: boundedText(row["completeText"], "complete text"), + evidenceRefs: strings(row["evidenceRefs"], "evidence refs"), + proposedOptionKeys: strings( + row["proposedOptionKeys"], + "proposed option keys", + ), + objectiveAssessments, + recommendedOptionKeys, + detail: { kind, body }, + diagnostics: jsonObject(row["diagnostics"]), + }; + if (result.inputDigest !== assessmentInputDigest(result)) + throw Error("assessment input digest mismatch"); + switch (moduleKind) { + case "clotho": + return { ...result, moduleKind, detail: { kind: "forecasts", body } }; + case "lachesis": + return { ...result, moduleKind, detail: { kind: "values", body } }; + case "atropos": + return { ...result, moduleKind, detail: { kind: "continuity", body } }; + } +} + +export function parseAssessmentSet(value: unknown): AssessmentSet { + const row = fields( + value, + ["schemaVersion", "roundId", "snapshotDigest", "assessments"], + "assessment set", + true, + ); + const roundId = boundedId(row["roundId"], "round id"); + const digest = boundedId(row["snapshotDigest"], "snapshot digest"); + const assessments = list(row["assessments"], parseAssessment, "assessments"); + if ( + assessments.length !== MODULE_KINDS.length || + assessments.some((item, index) => item.moduleKind !== MODULE_KINDS[index]) + ) + throw Error("assessment set requires each module in declared order"); + if ( + assessments.some( + (item) => item.snapshotId !== roundId || item.snapshotDigest !== digest, + ) + ) + throw Error("assessment set snapshot mismatch"); + return { schemaVersion: 1, roundId, snapshotDigest: digest, assessments }; +} + +export function parseResolutionRecord(value: unknown): ResolutionRecord { + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "policyId", + "policyRevision", + "situation", + "order", + "recommendations", + "conflicts", + "excluded", + "abstentions", + "ranking", + "conceded", + "status", + "holdReason", + ], + "resolution record", + true, + ); + const status = enumeration( + row["status"], + ["resolved", "deferred", "held"] as const, + "resolution status", + ); + const holdReason = nullableText(row["holdReason"], "hold reason"); + if ((status !== "resolved") !== (holdReason !== null)) + throw Error("unresolved status requires hold reason"); + const order = uniqueSorted( + list( + row["order"], + (item) => enumeration(item, MODULE_KINDS, "module order"), + "module order", + ), + (item) => item, + "module order", + false, + ); + if (order.length !== MODULE_KINDS.length) + throw Error("resolution order requires each module"); + return { + schemaVersion: 1, + roundId: boundedId(row["roundId"], "round id"), + policyId: boundedId(row["policyId"], "policy id"), + policyRevision: revision(row["policyRevision"], "policy revision"), + situation: enumeration(row["situation"], SITUATIONS, "situation"), + order, + recommendations: moduleRecord( + row["recommendations"], + (item) => strings(item, "recommendation keys"), + "recommendations", + ), + conflicts: uniqueSorted( + list( + row["conflicts"], + (value) => { + const item = fields(value, ["optionKey", "stances"], "conflict"); + return { + optionKey: boundedId(item["optionKey"], "option key"), + stances: moduleRecord( + item["stances"], + (stance) => enumeration(stance, STANCES, "stance"), + "conflict stances", + ), + }; + }, + "conflicts", + ), + (item) => item.optionKey, + "conflicts", + ), + excluded: uniqueSorted( + list( + row["excluded"], + (value) => { + const item = fields( + value, + ["optionKey", "stage", "byModule", "reason"], + "exclusion", + ); + return { + optionKey: boundedId(item["optionKey"], "option key"), + stage: enumeration( + item["stage"], + EXCLUSION_STAGES, + "exclusion stage", + ), + byModule: + item["byModule"] === null + ? null + : enumeration( + item["byModule"], + MODULE_KINDS, + "exclusion module", + ), + reason: boundedText(item["reason"], "exclusion reason"), + }; + }, + "exclusions", + ), + (item) => item.optionKey, + "exclusions", + ), + abstentions: uniqueSorted( + list( + row["abstentions"], + (value) => { + const item = fields( + value, + ["optionKey", "moduleKind", "reason"], + "abstention", + ); + return { + optionKey: boundedId(item["optionKey"], "option key"), + moduleKind: enumeration( + item["moduleKind"], + MODULE_KINDS, + "module kind", + ), + reason: boundedText(item["reason"], "abstention reason"), + }; + }, + "abstentions", + ), + (item) => JSON.stringify([item.optionKey, item.moduleKind]), + "abstentions", + ), + ranking: uniqueSorted( + list( + row["ranking"], + (value) => { + const item = fields(value, ["optionKey", "rank"], "ranking"); + return { + optionKey: boundedId(item["optionKey"], "option key"), + rank: revision(item["rank"], "rank", 1), + }; + }, + "ranking", + ), + (item) => item.optionKey, + "ranking", + false, + ), + conceded: uniqueSorted( + list( + row["conceded"], + (value) => { + const item = fields(value, ["moduleKind", "optionKey"], "concession"); + return { + moduleKind: enumeration( + item["moduleKind"], + MODULE_KINDS, + "module kind", + ), + optionKey: boundedId(item["optionKey"], "option key"), + }; + }, + "concessions", + ), + (item) => JSON.stringify([item.moduleKind, item.optionKey]), + "concessions", + ), + status, + holdReason, + }; +} + +export function parseSelectionSpec(value: unknown): SelectionSpec { + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "snapshotDigest", + "assessmentSetDigest", + "objectiveProfileRefs", + "resolutionDigest", + "policyId", + "policyRevision", + "situation", + "lambda", + "candidates", + "eligibleDigest", + "specDigest", + ], + "selection spec", + true, + ); + const candidates = uniqueSorted( + list( + row["candidates"], + (value) => { + const item = fields( + value, + ["optionKey", "p0", "b"], + "selection candidate", + ); + const p0 = finite(item["p0"], "p0"); + if (p0 < 0) throw Error("invalid p0"); + return { + optionKey: boundedId(item["optionKey"], "option key"), + p0, + b: item["b"] === null ? null : finite(item["b"], "bias"), + }; + }, + "selection candidates", + ), + (item) => item.optionKey, + "selection candidates", + false, + ); + for (const [index, candidate] of candidates.entries()) { + const previous = candidates[index - 1]; + if (previous && previous.optionKey > candidate.optionKey) + throw Error("unsorted selection candidates"); + } + if ( + !candidates.some((item) => item.p0 > 0) || + Math.abs(candidates.reduce((sum, item) => sum + item.p0, 0) - 1) > 1e-12 + ) + throw Error("invalid selection mass"); + const lambda = finite(row["lambda"], "lambda"); + if (lambda < 0) throw Error("invalid lambda"); + const result: SelectionSpec = { + schemaVersion: 1, + roundId: boundedId(row["roundId"], "round id"), + snapshotDigest: boundedId(row["snapshotDigest"], "snapshot digest"), + assessmentSetDigest: boundedId( + row["assessmentSetDigest"], + "assessment set digest", + ), + objectiveProfileRefs: moduleRecord( + row["objectiveProfileRefs"], + objectiveProfileRef, + "objective profile refs", + ), + resolutionDigest: boundedId(row["resolutionDigest"], "resolution digest"), + policyId: boundedId(row["policyId"], "policy id"), + policyRevision: revision(row["policyRevision"], "policy revision"), + situation: enumeration(row["situation"], SITUATIONS, "situation"), + lambda, + candidates, + eligibleDigest: boundedId(row["eligibleDigest"], "eligible digest"), + specDigest: boundedId(row["specDigest"], "spec digest"), + }; + if ( + result.eligibleDigest !== + judgmentDigest( + candidates.filter((item) => item.p0 > 0).map((item) => item.optionKey), + ) + ) + throw Error("eligible digest mismatch"); + if ( + result.specDigest !== judgmentDigest({ ...result, specDigest: undefined }) + ) + throw Error("selection spec digest mismatch"); + return result; +} + +export const INTENTION_TRANSITIONS: Record< + IntentionStatus, + readonly IntentionStatus[] +> = { + proposed: ["adopted", "cancelled"], + adopted: ["active", "suspended", "cancelled"], + active: ["suspended", "completed", "cancelled"], + suspended: ["active", "cancelled"], + completed: [], + cancelled: [], +}; + +export function parseIntentionTransition(value: unknown): IntentionTransition { + const row = fields( + value, + ["from", "to", "reason", "evidenceRef", "at"], + "intention transition", + ); + return { + from: enumeration(row["from"], INTENTION_STATUSES, "transition from"), + to: enumeration(row["to"], INTENTION_STATUSES, "transition to"), + reason: boundedText(row["reason"], "transition reason"), + evidenceRef: nullableId(row["evidenceRef"], "transition evidence ref"), + at: timestamp(row["at"], "transition at"), + }; +} + +function validateTransition( + transition: IntentionTransition, + acceptanceSourceRef: string, +): void { + const { from, to, evidenceRef } = transition; + if (!INTENTION_TRANSITIONS[from].includes(to)) + throw Error(`invalid intention transition: ${from} -> ${to}`); + if (to === "completed" && evidenceRef === null) + throw Error("completed intention requires outcome ref"); + if ( + (to === "cancelled" || to === "suspended") && + evidenceRef !== acceptanceSourceRef + ) + throw Error("intention change requires original acceptance ref"); +} + +export function parseIntentionRecord(value: unknown): IntentionRecord { + const row = fields( + value, + [ + "schemaVersion", + "intentionId", + "agentId", + "scopeId", + "revision", + "kind", + "purposeRef", + "text", + "acceptance", + "priority", + "deadline", + "completionCondition", + "abortConditions", + "relatedIntentions", + "status", + "history", + ], + "intention record", + true, + ); + const acceptance = fields( + row["acceptance"], + ["sourceRef", "acceptedBy", "policyRevision", "acceptedAt"], + "intention acceptance", + ); + const sourceRef = boundedId(acceptance["sourceRef"], "acceptance source ref"); + const history = list( + row["history"], + parseIntentionTransition, + "intention history", + ); + const recordRevision = revision(row["revision"], "intention revision"); + const status = enumeration( + row["status"], + INTENTION_STATUSES, + "intention status", + ); + if (history.length !== recordRevision) + throw Error("intention history revision mismatch"); + let previous: IntentionStatus = "proposed"; + for (const transition of history) { + if (transition.from !== previous) + throw Error("noncontiguous intention history"); + validateTransition(transition, sourceRef); + previous = transition.to; + } + if (previous !== status) throw Error("intention history status mismatch"); + return { + schemaVersion: 1, + intentionId: boundedId(row["intentionId"], "intention id"), + agentId: boundedId(row["agentId"], "agent id"), + scopeId: boundedId(row["scopeId"], "scope id"), + revision: recordRevision, + kind: enumeration(row["kind"], INTENTION_KINDS, "intention kind"), + purposeRef: boundedId(row["purposeRef"], "purpose ref"), + text: boundedText(row["text"], "intention text"), + acceptance: { + sourceRef, + acceptedBy: enumeration( + acceptance["acceptedBy"], + ACCEPTED_BY, + "accepted by", + ), + policyRevision: revision( + acceptance["policyRevision"], + "acceptance policy revision", + ), + acceptedAt: timestamp(acceptance["acceptedAt"], "accepted at"), + }, + priority: revision(row["priority"], "intention priority"), + deadline: + row["deadline"] === null + ? null + : timestamp(row["deadline"], "intention deadline"), + completionCondition: boundedText( + row["completionCondition"], + "completion condition", + ), + abortConditions: strings( + row["abortConditions"], + "abort conditions", + boundedText, + ), + relatedIntentions: uniqueSorted( + list( + row["relatedIntentions"], + (value) => { + const item = fields( + value, + ["intentionId", "relation"], + "related intention", + ); + return { + intentionId: boundedId(item["intentionId"], "related intention id"), + relation: enumeration( + item["relation"], + INTENTION_RELATIONS, + "intention relation", + ), + }; + }, + "related intentions", + ), + (item) => JSON.stringify([item.intentionId, item.relation]), + "related intentions", + ), + status, + history, + }; +} + +export function transitionIntention( + record: IntentionRecord, + transition: Omit, +): IntentionRecord { + const entry = parseIntentionTransition({ + from: record.status, + ...transition, + }); + validateTransition(entry, record.acceptance.sourceRef); + if (record.history.length >= MAX_LIST) + throw Error("invalid intention history"); + return { + ...record, + revision: record.revision + 1, + status: entry.to, + history: [...record.history, entry], + }; +} diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts new file mode 100644 index 0000000..5d4ea92 --- /dev/null +++ b/packages/lina-core/src/agents/judgment.ts @@ -0,0 +1,206 @@ +export const MODULE_KINDS = ["clotho", "lachesis", "atropos"] as const; +export type ModuleKind = (typeof MODULE_KINDS)[number]; +export const STANCES = ["prefer", "accept", "oppose", "unavailable"] as const; +export type Stance = (typeof STANCES)[number]; +export const SEVERITIES = [ + "commitment_breach", + "infeasible", + "preference", +] as const; +export type Severity = (typeof SEVERITIES)[number]; +export const SITUATIONS = ["user_request", "autonomous", "transition"] as const; +export type Situation = (typeof SITUATIONS)[number]; +export const INTENTION_KINDS = [ + "user_commitment", + "autonomous_goal", + "task_binding", +] as const; +export type IntentionKind = (typeof INTENTION_KINDS)[number]; +export const INTENTION_STATUSES = [ + "proposed", + "adopted", + "active", + "suspended", + "completed", + "cancelled", +] as const; +export type IntentionStatus = (typeof INTENTION_STATUSES)[number]; +export const INTENTION_RELATIONS = [ + "depends", + "conflicts", + "supersedes", +] as const; +export type IntentionRelation = (typeof INTENTION_RELATIONS)[number]; +export const ACCEPTED_BY = ["user", "host_autonomy"] as const; +export type AcceptedBy = (typeof ACCEPTED_BY)[number]; +export const ROUND_STATUSES = ["open", "resolved", "deferred", "held"] as const; +export type RoundStatus = (typeof ROUND_STATUSES)[number]; +export const EXCLUSION_STAGES = [ + "host_eligibility", + "commitment_protection", + "infeasible", +] as const; +export type ExclusionStage = (typeof EXCLUSION_STAGES)[number]; + +export type ObjectiveProfile = { + schemaVersion: 1; + objectiveId: string; + moduleKind: ModuleKind; + revision: number; + objective: string; + comparisonCriteria: string[]; + reconsiderationConditions: string[]; +}; +export type ObjectiveProfileRef = { + objectiveId: string; + revision: number; + digest: string; +}; +export type SourceRef = { kind: string; id: string; revision: number }; +export type JudgmentSnapshotRef = { + schemaVersion: 1; + roundId: string; + agentId: string; + scopeId: string; + sourceRefs: SourceRef[]; + workingRevision: number; + instructionRevision: number; + policyRevision: number; + identityRevision: number; + domainRevisions: Record; + intentionRevision: number; + objectiveProfileRefs: Record; + observationRef: string | null; + frozenNeuralRef: string | null; + situation: Situation; + clockId: string; + sequence: number; + bindingGeneration: number; +}; + +/** Opaque until produced by a versioned option catalog. */ +export type OptionKey = string; +/** Nested shape, versioned by Assessment. */ +export type OptionAssessment = { + optionKey: OptionKey; + stance: Stance; + severity: Severity | null; + unavailableReason: string | null; + gain: string; + loss: string; + uncertainty: string; + evidenceRefs: string[]; +}; +export type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | JsonObject; +export type JsonObject = { [key: string]: JsonValue }; +export type AssessmentDetail = { + kind: "forecasts" | "values" | "continuity"; + body: JsonObject; +}; +export type Assessment = { + schemaVersion: 1; + snapshotId: string; + snapshotDigest: string; + inputDigest: string; + objectiveRef: ObjectiveProfileRef; + mechanismRevision: number; + completeText: string; + evidenceRefs: string[]; + proposedOptionKeys: OptionKey[]; + objectiveAssessments: OptionAssessment[]; + recommendedOptionKeys: OptionKey[]; + diagnostics: JsonObject; +} & ( + | { moduleKind: "clotho"; detail: { kind: "forecasts"; body: JsonObject } } + | { moduleKind: "lachesis"; detail: { kind: "values"; body: JsonObject } } + | { moduleKind: "atropos"; detail: { kind: "continuity"; body: JsonObject } } +); +export type AssessmentSet = { + schemaVersion: 1; + roundId: string; + snapshotDigest: string; + assessments: Assessment[]; +}; +export type ResolutionRecord = { + schemaVersion: 1; + roundId: string; + policyId: string; + policyRevision: number; + situation: Situation; + order: ModuleKind[]; + recommendations: Record; + conflicts: Array<{ + optionKey: OptionKey; + stances: Record; + }>; + excluded: Array<{ + optionKey: OptionKey; + stage: ExclusionStage; + byModule: ModuleKind | null; + reason: string; + }>; + abstentions: Array<{ + optionKey: OptionKey; + moduleKind: ModuleKind; + reason: string; + }>; + ranking: Array<{ optionKey: OptionKey; rank: number }>; + conceded: Array<{ moduleKind: ModuleKind; optionKey: OptionKey }>; + status: Exclude; + holdReason: string | null; +}; +export type SelectionSpec = { + schemaVersion: 1; + roundId: string; + snapshotDigest: string; + assessmentSetDigest: string; + objectiveProfileRefs: Record; + resolutionDigest: string; + policyId: string; + policyRevision: number; + situation: Situation; + lambda: number; + candidates: Array<{ optionKey: OptionKey; p0: number; b: number | null }>; + eligibleDigest: string; + specDigest: string; +}; +/** Nested shape, versioned by IntentionRecord. */ +export type IntentionTransition = { + from: IntentionStatus; + to: IntentionStatus; + reason: string; + evidenceRef: string | null; + at: string; +}; +export type IntentionRecord = { + schemaVersion: 1; + intentionId: string; + agentId: string; + scopeId: string; + revision: number; + kind: IntentionKind; + purposeRef: string; + text: string; + acceptance: { + sourceRef: string; + acceptedBy: AcceptedBy; + policyRevision: number; + acceptedAt: string; + }; + priority: number; + deadline: string | null; + completionCondition: string; + abortConditions: string[]; + relatedIntentions: Array<{ + intentionId: string; + relation: IntentionRelation; + }>; + status: IntentionStatus; + history: IntentionTransition[]; +}; diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts new file mode 100644 index 0000000..68ffcb6 --- /dev/null +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -0,0 +1,758 @@ +import { expect, test } from "bun:test"; +import { + ACCEPTED_BY, + type Assessment, + assessmentInputDigest, + EXCLUSION_STAGES, + INTENTION_KINDS, + INTENTION_RELATIONS, + INTENTION_STATUSES, + INTENTION_TRANSITIONS, + type IntentionRecord, + intentionDigest, + type JudgmentSnapshotRef, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + type OptionAssessment, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseIntentionTransition, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseOptionAssessment, + parseResolutionRecord, + parseSelectionSpec, + type ResolutionRecord, + ROUND_STATUSES, + SEVERITIES, + type SelectionSpec, + SITUATIONS, + STANCES, + snapshotDigest, + transitionIntention, +} from "../src/agents/index.ts"; + +const profile: ObjectiveProfile = { + schemaVersion: 1, + objectiveId: "objective-clotho", + moduleKind: "clotho", + revision: 1, + objective: "Compare fixture outcomes", + comparisonCriteria: ["cost", "outcome"], + reconsiderationConditions: ["new evidence"], +}; +const snapshot: JudgmentSnapshotRef = { + schemaVersion: 1, + roundId: "round-1", + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [{ kind: "request", id: "request-1", revision: 0 }], + workingRevision: 2, + instructionRevision: 1, + policyRevision: 1, + identityRevision: 1, + domainRevisions: { life: 0 }, + intentionRevision: 0, + objectiveProfileRefs: { + clotho: { + objectiveId: "objective-clotho", + revision: 1, + digest: "profile-digest", + }, + lachesis: { + objectiveId: "objective-lachesis", + revision: 1, + digest: "profile-digest", + }, + atropos: { + objectiveId: "objective-atropos", + revision: 1, + digest: "profile-digest", + }, + }, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence: 0, + bindingGeneration: 0, +}; +const option: OptionAssessment = { + optionKey: "a", + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "fixture gain", + loss: "fixture loss", + uncertainty: "fixture uncertainty", + evidenceRefs: ["evidence-1"], +}; +const assessment: Assessment = { + schemaVersion: 1, + moduleKind: "clotho", + snapshotId: "round-1", + snapshotDigest: "snapshot-digest", + inputDigest: + "8a5ceb71d411139b4ad10b5d5a8e78045ae9b788fd3fb9d64bf4c440094504e7", + objectiveRef: { + objectiveId: "objective-clotho", + revision: 1, + digest: "profile-digest", + }, + mechanismRevision: 1, + completeText: "Fixture forecast", + evidenceRefs: ["evidence-1"], + proposedOptionKeys: ["a"], + objectiveAssessments: [ + { + optionKey: "a", + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "fixture gain", + loss: "fixture loss", + uncertainty: "fixture uncertainty", + evidenceRefs: ["evidence-1"], + }, + ], + recommendedOptionKeys: ["a"], + detail: { + kind: "forecasts", + body: { forecast: [null, true, 1, "outcome", { possible: false }] }, + }, + diagnostics: {}, +}; +const spec: SelectionSpec = { + schemaVersion: 1, + roundId: "round-1", + snapshotDigest: "snapshot-digest", + assessmentSetDigest: "set-digest", + objectiveProfileRefs: { + clotho: { + objectiveId: "objective-clotho", + revision: 1, + digest: "profile-digest", + }, + lachesis: { + objectiveId: "objective-lachesis", + revision: 1, + digest: "profile-digest", + }, + atropos: { + objectiveId: "objective-atropos", + revision: 1, + digest: "profile-digest", + }, + }, + resolutionDigest: "resolution-digest", + policyId: "personal.v1", + policyRevision: 1, + situation: "user_request", + lambda: 0, + candidates: [ + { optionKey: "a", p0: 0.5, b: null }, + { optionKey: "b", p0: 0.5, b: 1 }, + ], + eligibleDigest: + "0473ef2dc0d324ab659d3580c1134e9d812035905c4781fdd6d529b0c6860e13", + specDigest: + "1ec632276bd8febd508ce2d47ae110e4fb029b94777d97aeafdfff43e00aecc7", +}; +const intention: IntentionRecord = { + schemaVersion: 1, + intentionId: "intention-1", + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete fixture task", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: ["request withdrawn"], + relatedIntentions: [{ intentionId: "intention-2", relation: "depends" }], + status: "proposed", + history: [], +}; +const transition = { + from: "proposed", + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: "2026-09-11T01:00:00.000Z", +} as const; +const resolution: ResolutionRecord = { + schemaVersion: 1, + roundId: "round-1", + policyId: "personal.v1", + policyRevision: 1, + situation: "user_request", + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: ["a"], lachesis: [], atropos: ["b"] }, + conflicts: [ + { + optionKey: "a", + stances: { clotho: "prefer", lachesis: "unavailable", atropos: "oppose" }, + }, + ], + excluded: [ + { + optionKey: "b", + stage: "commitment_protection", + byModule: "atropos", + reason: "acceptance protected", + }, + ], + abstentions: [ + { optionKey: "a", moduleKind: "lachesis", reason: "missing observation" }, + ], + ranking: [{ optionKey: "a", rank: 1 }], + conceded: [{ moduleKind: "atropos", optionKey: "b" }], + status: "resolved", + holdReason: null, +}; +function moduleAssessment(moduleKind: ModuleKind): Assessment { + const objectiveRef = snapshot.objectiveProfileRefs[moduleKind]; + return parseAssessment({ + ...assessment, + moduleKind, + objectiveRef, + inputDigest: assessmentInputDigest({ + snapshotDigest: assessment.snapshotDigest, + objectiveRef, + mechanismRevision: 1, + }), + detail: { + kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ + moduleKind + ], + body: {}, + }, + }); +} +function assessmentSet() { + return { + schemaVersion: 1, + roundId: "round-1", + snapshotDigest: "snapshot-digest", + assessments: MODULE_KINDS.map(moduleAssessment), + }; +} +function signedSpec(changes: Partial): SelectionSpec { + const changed = { ...spec, ...changes }; + return { + ...changed, + eligibleDigest: judgmentDigest( + changed.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + specDigest: judgmentDigest({ + ...changed, + eligibleDigest: judgmentDigest( + changed.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + specDigest: undefined, + }), + }; +} +const topLevel: Array<[string, (value: unknown) => unknown, () => object]> = [ + ["objective profile", parseObjectiveProfile, () => profile], + ["judgment snapshot ref", parseJudgmentSnapshotRef, () => snapshot], + ["assessment", parseAssessment, () => assessment], + ["assessment set", parseAssessmentSet, assessmentSet], + ["resolution record", parseResolutionRecord, () => resolution], + ["selection spec", parseSelectionSpec, () => spec], + ["intention record", parseIntentionRecord, () => intention], +]; +for (const [label, parse, golden] of topLevel) { + test(`${label}: golden JSON round-trip and version-first rejection`, () => { + const json = JSON.stringify(golden()); + expect(JSON.stringify(parse(JSON.parse(json)))).toBe(json); + for (const schemaVersion of [0, 2]) + expect(() => parse({ ...golden(), schemaVersion, extra: true })).toThrow( + /Unsupported .* schema version/, + ); + expect(() => parse({ ...golden(), extra: true })).toThrow( + /unknown .* field extra/, + ); + for (const bad of [null, [], "record", 1]) + expect(() => parse(bad)).toThrow(); + }); +} + +test("nested records have no schema version", () => { + for (const [parse, value] of [ + [parseOptionAssessment, option], + [parseIntentionTransition, transition], + ] as const) { + expect(parse(value)).toEqual(value); + expect(() => parse({ ...value, schemaVersion: 1 })).toThrow( + /unknown .* field schemaVersion/, + ); + expect(() => parse(null)).toThrow(); + } +}); + +test("declared vocabulary is exported without adding numeric assessment scores", () => { + expect(MODULE_KINDS).toEqual(["clotho", "lachesis", "atropos"]); + expect(STANCES).toEqual(["prefer", "accept", "oppose", "unavailable"]); + expect(SEVERITIES).toEqual(["commitment_breach", "infeasible", "preference"]); + expect(SITUATIONS).toEqual(["user_request", "autonomous", "transition"]); + expect(INTENTION_KINDS).toEqual([ + "user_commitment", + "autonomous_goal", + "task_binding", + ]); + expect(INTENTION_STATUSES).toEqual([ + "proposed", + "adopted", + "active", + "suspended", + "completed", + "cancelled", + ]); + expect(INTENTION_RELATIONS).toEqual(["depends", "conflicts", "supersedes"]); + expect(ACCEPTED_BY).toEqual(["user", "host_autonomy"]); + expect(ROUND_STATUSES).toEqual(["open", "resolved", "deferred", "held"]); + expect(EXCLUSION_STAGES).toEqual([ + "host_eligibility", + "commitment_protection", + "infeasible", + ]); + expect(() => parseOptionAssessment({ ...option, score: 1 })).toThrow( + /unknown/, + ); +}); + +test("digests canonicalize nested object keys, preserve arrays, and omit undefined fields", () => { + expect(judgmentDigest({ a: 1, b: [{ d: 1, c: 2 }] })).toBe( + judgmentDigest({ b: [{ c: 2, d: 1 }], a: 1 }), + ); + expect(judgmentDigest([1, 2])).not.toBe(judgmentDigest([2, 1])); + expect(judgmentDigest({ a: 1, b: undefined })).toBe(judgmentDigest({ a: 1 })); + expect(snapshotDigest(snapshot)).toBe(judgmentDigest(snapshot)); + expect(intentionDigest(intention)).toBe(judgmentDigest(intention)); + expect(judgmentDigest(profile)).toMatch(/^[a-f0-9]{64}$/); + expect(assessmentInputDigest(assessment)).toBe(assessment.inputDigest); +}); + +test("snapshot opaque refs and independently bounded revisions", () => { + for (const field of ["frozenNeuralRef", "observationRef"] as const) { + for (const ref of [null, "any-opaque-id"]) + expect( + parseJudgmentSnapshotRef({ ...snapshot, [field]: ref })[field], + ).toBe(ref); + for (const ref of [42, "", "x".repeat(161)]) + expect(() => + parseJudgmentSnapshotRef({ ...snapshot, [field]: ref }), + ).toThrow(); + } + for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => + parseJudgmentSnapshotRef({ ...snapshot, workingRevision: value }), + ).toThrow(); + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + domainRevisions: { life: value }, + }), + ).toThrow(); + } + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + objectiveProfileRefs: { ...snapshot.objectiveProfileRefs, fourth: {} }, + }), + ).toThrow(/unknown/); + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [{ ...snapshot.sourceRefs[0], extra: 1 }], + }), + ).toThrow(/unknown/); +}); + +test("bounded text, ids, lists, uniqueness and canonical list order", () => { + for (const objective of ["", " ", "x\u0000y", "x".repeat(1001)]) + expect(() => parseObjectiveProfile({ ...profile, objective })).toThrow(); + expect(() => + parseObjectiveProfile({ ...profile, objectiveId: "x".repeat(161) }), + ).toThrow(); + expect(() => parseObjectiveProfile({ ...profile, revision: 0 })).toThrow(); + expect(() => + parseObjectiveProfile({ ...profile, comparisonCriteria: ["a", "a"] }), + ).toThrow(); + expect(() => + parseObjectiveProfile({ + ...profile, + comparisonCriteria: Array.from( + { length: 257 }, + (_, i) => `criterion-${i}`, + ), + }), + ).toThrow(); + expect( + parseObjectiveProfile({ ...profile, comparisonCriteria: ["b", "a"] }) + .comparisonCriteria, + ).toEqual(["a", "b"]); + expect( + parseObjectiveProfile({ + ...profile, + comparisonCriteria: Array.from( + { length: 256 }, + (_, i) => `criterion-${i}`, + ), + }).comparisonCriteria, + ).toHaveLength(256); +}); + +test("assessment enforces module detail, input digest, JSON, and recommendation membership", () => { + for (const moduleKind of MODULE_KINDS) + expect(moduleAssessment(moduleKind).moduleKind).toBe(moduleKind); + expect(() => + parseAssessment({ ...assessment, detail: { kind: "values", body: {} } }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, inputDigest: "wrong" }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, recommendedOptionKeys: ["missing"] }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, objectiveAssessments: [option, option] }), + ).toThrow(); + expect(() => + parseAssessment({ ...assessment, proposedOptionKeys: ["a", "a"] }), + ).toThrow(); + for (const body of [ + [], + { bad: undefined }, + { bad: Infinity }, + { bad: () => 1 }, + ]) + expect(() => + parseAssessment({ ...assessment, detail: { kind: "forecasts", body } }), + ).toThrow(); + expect(() => + parseAssessment({ + ...assessment, + detail: { ...assessment.detail, extra: true }, + }), + ).toThrow(/unknown/); +}); + +test("option stance requires exactly the corresponding severity or unavailable reason", () => { + for (const severity of SEVERITIES) + expect( + parseOptionAssessment({ ...option, stance: "oppose", severity }).severity, + ).toBe(severity); + expect( + parseOptionAssessment({ + ...option, + stance: "unavailable", + unavailableReason: "no source", + }).stance, + ).toBe("unavailable"); + for (const change of [ + { stance: "oppose", severity: null }, + { stance: "unavailable", unavailableReason: null }, + { stance: "accept", severity: "preference" }, + { unavailableReason: "unexpected" }, + { stance: "unavailable", unavailableReason: " " }, + { stance: "neutral" }, + ]) + expect(() => parseOptionAssessment({ ...option, ...change })).toThrow(); +}); + +test("assessment sets require one of each module in declared order and matching snapshot", () => { + const set = assessmentSet(); + expect(parseAssessmentSet(set).assessments.map((a) => a.moduleKind)).toEqual([ + ...MODULE_KINDS, + ]); + for (const assessments of [ + set.assessments.slice(0, 2), + [assessment, assessment, moduleAssessment("atropos")], + [...set.assessments].reverse(), + set.assessments.map((a) => ({ ...a, snapshotId: "other" })), + ]) + expect(() => parseAssessmentSet({ ...set, assessments })).toThrow(); + expect(() => + parseAssessmentSet({ ...set, snapshotDigest: "other" }), + ).toThrow(); +}); + +test("selection rejects invalid mass, lambda, bias, order, duplicates, and tampered digests", () => { + for (const candidates of [ + [], + [{ optionKey: "a", p0: 0, b: null }], + [{ optionKey: "a", p0: 0.9, b: null }], + [ + { optionKey: "a", p0: -1, b: null }, + { optionKey: "b", p0: 2, b: null }, + ], + [{ optionKey: "a", p0: Infinity, b: null }], + [{ optionKey: "a", p0: 1, b: NaN }], + [...spec.candidates].reverse(), + [ + { optionKey: "a", p0: 0.5, b: null }, + { optionKey: "a", p0: 0.5, b: null }, + ], + ]) + expect(() => parseSelectionSpec(signedSpec({ candidates }))).toThrow(); + for (const lambda of [-1, Infinity, NaN]) + expect(() => parseSelectionSpec(signedSpec({ lambda }))).toThrow(); + expect(() => + parseSelectionSpec({ ...spec, eligibleDigest: "wrong" }), + ).toThrow(); + expect(() => parseSelectionSpec({ ...spec, specDigest: "wrong" })).toThrow(); + const withExcluded = signedSpec({ + candidates: [ + { optionKey: "a", p0: 1, b: -2 }, + { optionKey: "b", p0: 0, b: null }, + ], + }); + expect(parseSelectionSpec(withExcluded)).toEqual(withExcluded); + expect(withExcluded.eligibleDigest).toBe(judgmentDigest(["a"])); + expect( + parseSelectionSpec( + signedSpec({ candidates: [{ optionKey: "a", p0: 1 - 5e-13, b: null }] }), + ).candidates, + ).toHaveLength(1); +}); + +test("resolution hold reason is present exactly when unresolved", () => { + for (const status of ["held", "deferred"] as const) { + expect( + parseResolutionRecord({ + ...resolution, + status, + holdReason: "no eligible candidate", + }).status, + ).toBe(status); + expect(() => parseResolutionRecord({ ...resolution, status })).toThrow(); + } + expect(() => + parseResolutionRecord({ ...resolution, holdReason: "unexpected" }), + ).toThrow(); + expect(() => + parseResolutionRecord({ ...resolution, status: "open" }), + ).toThrow(); + expect(() => + parseResolutionRecord({ ...resolution, order: ["clotho", "clotho"] }), + ).toThrow(); + expect(() => + parseResolutionRecord({ + ...resolution, + ranking: [{ optionKey: "a", rank: 0 }], + }), + ).toThrow(); +}); + +function adopt(record = intention): IntentionRecord { + return transitionIntention(record, { + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: transition.at, + }); +} +function activate(): IntentionRecord { + return transitionIntention(adopt(), { + to: "active", + reason: "started", + evidenceRef: null, + at: transition.at, + }); +} + +test("intention transitions complete the legal lifecycle without mutating input", () => { + let record = intention; + for (const to of [ + "adopted", + "active", + "suspended", + "active", + "completed", + ] as const) { + const before = structuredClone(record); + const evidenceRef = + to === "completed" + ? "outcome-1" + : to === "suspended" + ? intention.acceptance.sourceRef + : null; + const next = transitionIntention(record, { + to, + reason: "fixture transition", + evidenceRef, + at: transition.at, + }); + expect(record).toEqual(before); + expect(next).not.toBe(record); + expect(next.history).not.toBe(record.history); + expect(next.revision).toBe(record.revision + 1); + expect(next.status).toBe(to); + expect(next.history.at(-1)).toEqual({ + from: record.status, + to, + reason: "fixture transition", + evidenceRef, + at: transition.at, + }); + expect(parseIntentionRecord(next)).toEqual(next); + record = next; + } + for (const to of INTENTION_STATUSES) + expect(() => + transitionIntention(record, { + to, + reason: "attempt", + evidenceRef: "outcome-1", + at: transition.at, + }), + ).toThrow(`invalid intention transition: completed -> ${to}`); +}); + +test("intention table and all prohibited edges are explicit", () => { + expect(INTENTION_TRANSITIONS).toEqual({ + proposed: ["adopted", "cancelled"], + adopted: ["active", "suspended", "cancelled"], + active: ["suspended", "completed", "cancelled"], + suspended: ["active", "cancelled"], + completed: [], + cancelled: [], + }); + const active = activate(); + const suspended = transitionIntention(active, { + to: "suspended", + reason: "pause", + evidenceRef: intention.acceptance.sourceRef, + at: transition.at, + }); + const completed = transitionIntention(active, { + to: "completed", + reason: "done", + evidenceRef: "outcome-1", + at: transition.at, + }); + const cancelled = transitionIntention(intention, { + to: "cancelled", + reason: "withdrawn", + evidenceRef: intention.acceptance.sourceRef, + at: transition.at, + }); + for (const record of [ + intention, + adopt(), + active, + suspended, + completed, + cancelled, + ]) { + for (const to of INTENTION_STATUSES) { + const change = { + to, + reason: "fixture edge", + evidenceRef: + to === "completed" ? "outcome-1" : intention.acceptance.sourceRef, + at: transition.at, + }; + if (INTENTION_TRANSITIONS[record.status].includes(to)) + expect( + parseIntentionRecord(transitionIntention(record, change)).status, + ).toBe(to); + else + expect(() => transitionIntention(record, change)).toThrow( + `invalid intention transition: ${record.status} -> ${to}`, + ); + } + } +}); + +test("intention transitions require outcome, original acceptance and nonempty reason", () => { + const active = activate(); + for (const evidenceRef of [null, "", " "]) + expect(() => + transitionIntention(active, { + to: "completed", + reason: "done", + evidenceRef, + at: transition.at, + }), + ).toThrow(); + for (const to of ["cancelled", "suspended"] as const) + for (const evidenceRef of [null, "other-request"]) + expect(() => + transitionIntention(active, { + to, + reason: "changed", + evidenceRef, + at: transition.at, + }), + ).toThrow(); + for (const reason of ["", " "]) + expect(() => + transitionIntention(intention, { + to: "adopted", + reason, + evidenceRef: null, + at: transition.at, + }), + ).toThrow(); +}); + +test("intention history revision, continuity, edges, final status and timestamps are validated", () => { + const active = activate(); + for (const change of [ + { revision: 1 }, + { status: "proposed" }, + { history: [] }, + { + history: active.history.map((h, i) => + i === 0 ? { ...h, from: "adopted" } : h, + ), + }, + { + history: active.history.map((h, i) => + i === 1 ? { ...h, from: "suspended" } : h, + ), + }, + { revision: 1, history: [{ ...transition, to: "active" }] }, + ]) + expect(() => parseIntentionRecord({ ...active, ...change })).toThrow(); + expect(() => + parseIntentionRecord({ ...intention, status: "active" }), + ).toThrow(); + for (const at of ["not-a-date", ""]) + expect(() => parseIntentionTransition({ ...transition, at })).toThrow(); + expect(() => + parseIntentionRecord({ ...intention, deadline: "not-a-date" }), + ).toThrow(); + expect(() => + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, acceptedAt: "not-a-date" }, + }), + ).toThrow(); + expect(() => + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, extra: true }, + }), + ).toThrow(/unknown/); + expect(() => + parseIntentionRecord({ + ...active, + history: active.history.map((h) => ({ ...h, schemaVersion: 1 })), + }), + ).toThrow(/unknown .* field schemaVersion/); +}); From 4335e94fb27bab984849f7975ae109655fd310f3 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:19:40 +0900 Subject: [PATCH 02/47] feat(core): export parseObjectiveProfileRef for judgment store re-parsing --- packages/lina-core/src/agents/index.ts | 1 + .../src/agents/judgment-validation.ts | 30 +++++++++++-------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 69db4c1..3b0cfc0 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -53,6 +53,7 @@ export { parseIntentionTransition, parseJudgmentSnapshotRef, parseObjectiveProfile, + parseObjectiveProfileRef, parseOptionAssessment, parseResolutionRecord, parseSelectionSpec, diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 0000eef..89b577e 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -113,8 +113,8 @@ function uniqueSorted( throw Error(`duplicate ${label}`); return sort ? [...items].sort((a, b) => - key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, - ) + key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, + ) : items; } @@ -163,13 +163,13 @@ export function judgmentDigest(value: unknown): string { ? item.map(canonical) : item !== null && typeof item === "object" ? Object.fromEntries( - Object.keys(item) - .sort() - .map((key) => [ - key, - canonical((item as Record)[key]), - ]), - ) + Object.keys(item) + .sort() + .map((key) => [ + key, + canonical((item as Record)[key]), + ]), + ) : item; return createHash("sha256") .update(JSON.stringify(canonical(value))) @@ -207,6 +207,10 @@ function objectiveProfileRef(value: unknown): ObjectiveProfileRef { }; } +export function parseObjectiveProfileRef(value: unknown): ObjectiveProfileRef { + return objectiveProfileRef(value); +} + export function parseObjectiveProfile(value: unknown): ObjectiveProfile { const row = fields( value, @@ -565,10 +569,10 @@ export function parseResolutionRecord(value: unknown): ResolutionRecord { item["byModule"] === null ? null : enumeration( - item["byModule"], - MODULE_KINDS, - "exclusion module", - ), + item["byModule"], + MODULE_KINDS, + "exclusion module", + ), reason: boundedText(item["reason"], "exclusion reason"), }; }, From 17db2c119977c59fcf991544395879aa7900bccd Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:04:08 +0900 Subject: [PATCH 03/47] feat(core): add PersonaSchema v1 contract and LifeDefinition derivation (F1-E2) --- .../lina-core/src/agents/behavior-types.ts | 17 + packages/lina-core/src/agents/index.ts | 19 + .../lina-core/src/agents/persona-schema.ts | 369 +++++++++++++++ .../lina-core/test/persona-schema.test.ts | 434 ++++++++++++++++++ 4 files changed, 839 insertions(+) create mode 100644 packages/lina-core/src/agents/persona-schema.ts create mode 100644 packages/lina-core/test/persona-schema.test.ts diff --git a/packages/lina-core/src/agents/behavior-types.ts b/packages/lina-core/src/agents/behavior-types.ts index 36190ee..e8e682c 100644 --- a/packages/lina-core/src/agents/behavior-types.ts +++ b/packages/lina-core/src/agents/behavior-types.ts @@ -172,3 +172,20 @@ export interface BehaviorPersistence { ): CurrentBehaviorProjection; status(agentId: string): BehaviorJob[]; } + +export const DIMENSION_SOURCES = ["reflection", "neural"] as const; +export type DimensionSource = (typeof DIMENSION_SOURCES)[number]; +export type PersonaDimensionRef = { + schemaRevision: number; + dimensionId: string; + source: DimensionSource; +}; +/** F2 fills this; F1 declares the shape only. */ +export type NeuralProjectionRef = { + schemaVersion: 1; + agentId: string; + scopeId: string; + schemaRevision: number; + observationRef: string; + projectionDigest: string; +}; diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 3b0cfc0..0364e06 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -60,3 +60,22 @@ export { snapshotDigest, transitionIntention, } from "./judgment-validation.ts"; + +// Persona schema contracts. + +export type { + DimensionSource, + NeuralProjectionRef, + PersonaDimensionRef, +} from "./behavior-types.ts"; +export { DIMENSION_SOURCES } from "./behavior-types.ts"; +export type { + PersonaDimension, + PersonaDimensionKind, + PersonaSchema, +} from "./persona-schema.ts"; +export { + parsePersonaSchema, + personaSchemaDigest, + personaSchemaFromLifeDefinition, +} from "./persona-schema.ts"; diff --git a/packages/lina-core/src/agents/persona-schema.ts b/packages/lina-core/src/agents/persona-schema.ts new file mode 100644 index 0000000..bb6f427 --- /dev/null +++ b/packages/lina-core/src/agents/persona-schema.ts @@ -0,0 +1,369 @@ +import { createHash } from "node:crypto"; +import { lifeDigest } from "../world/life-json.ts"; +import type { + IdentityPolicySnapshot, + LifeDefinition, +} from "../world/life-types.ts"; +import { parseLifeDefinition } from "../world/life-validation.ts"; +import { DIMENSION_SOURCES, type DimensionSource } from "./behavior-types.ts"; +import { boundedId } from "./validation.ts"; + +const SCHEMA_KEYS = [ + "schemaVersion", + "agentId", + "revision", + "sourceDefinition", + "sourceIdentity", + "dimensions", + "digest", +] as const; +const DIMENSION_KEYS = [ + "id", + "kind", + "label", + "source", + "range", + "initial", + "locked", + "originAxisId", +] as const; +const KIND_RANK = { trait: 0, habit: 1, attitude: 2 } as const; +const HASH = /^[a-f0-9]{64}$/; +// Labels are copied verbatim from LIFE axes, so the persona bound must equal +// the LIFE text ceiling (world/validation.ts MAX_TEXT), not the agent-text one. +const MAX_LABEL = 32_768; + +export type PersonaDimensionKind = "trait" | "habit" | "attitude"; + +export type PersonaDimension = { + id: string; + kind: PersonaDimensionKind; + label: string; + source: DimensionSource; + range: { min: number; max: number } | null; + initial: number | boolean; + locked: boolean; + originAxisId: string | null; +}; + +export type PersonaSchema = { + schemaVersion: 1; + agentId: string; + revision: number; + sourceDefinition: { + worldId: string; + definitionRevision: number; + definitionDigest: string; + } | null; + sourceIdentity: { profileRevision: number } | null; + dimensions: PersonaDimension[]; + digest: string; +}; + +function object(value: unknown, label: string): Record { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error("invalid " + label); + return value as Record; +} + +function exact( + value: Record, + keys: readonly string[], + label: string, +): void { + for (const key of Object.keys(value)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(value, key)) throw Error("invalid " + label); +} + +function revision(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) + throw Error("invalid " + label); + return value; +} + +function digestValue(value: unknown, label: string): string { + if (typeof value !== "string" || !HASH.test(value)) + throw Error("invalid " + label); + return value; +} + +function canonical(item: unknown): unknown { + return Array.isArray(item) + ? item.map(canonical) + : item !== null && typeof item === "object" + ? Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [ + key, + canonical((item as Record)[key]), + ]), + ) + : item; +} + +export function personaSchemaDigest( + schema: Omit, +): string { + return createHash("sha256") + .update(JSON.stringify(canonical(schema))) + .digest("hex"); +} + +function parseLabel(value: unknown): string { + if ( + typeof value !== "string" || + value.trim().length === 0 || + value.length > MAX_LABEL + ) + throw Error("invalid persona dimension label"); + return value; +} + +function parseKind(value: unknown): PersonaDimensionKind { + if (value === "trait" || value === "habit" || value === "attitude") + return value; + throw Error("invalid persona dimension kind"); +} + +function parseSource(value: unknown): DimensionSource { + for (const allowed of DIMENSION_SOURCES) + if (allowed === value) return allowed; + throw Error("invalid persona dimension source"); +} + +function parseRange(value: unknown): { min: number; max: number } { + const row = object(value, "persona dimension range"); + exact(row, ["min", "max"], "persona dimension range"); + const min = row["min"]; + const max = row["max"]; + if ( + typeof min !== "number" || + typeof max !== "number" || + !Number.isFinite(min) || + !Number.isFinite(max) || + min > max + ) + throw Error("invalid persona dimension range"); + return { min, max }; +} + +function parseDimension(value: unknown): PersonaDimension { + const row = object(value, "persona dimension"); + exact(row, DIMENSION_KEYS, "persona dimension"); + const kind = parseKind(row["kind"]); + const rangeValue = row["range"]; + const initial = row["initial"]; + if (kind === "habit") { + if (rangeValue !== null) throw Error("invalid persona dimension range"); + if (typeof initial !== "boolean") + throw Error("invalid persona dimension initial"); + if (typeof row["locked"] !== "boolean") + throw Error("invalid persona dimension locked"); + return { + id: boundedId(row["id"], "persona dimension id"), + kind, + label: parseLabel(row["label"]), + source: parseSource(row["source"]), + range: null, + initial, + locked: row["locked"], + originAxisId: + row["originAxisId"] === null + ? null + : boundedId(row["originAxisId"], "origin axis id"), + }; + } + if (rangeValue === null) throw Error("invalid persona dimension range"); + const range = parseRange(rangeValue); + if ( + typeof initial !== "number" || + !Number.isFinite(initial) || + initial < range.min || + initial > range.max + ) + throw Error("invalid persona dimension initial"); + if (typeof row["locked"] !== "boolean") + throw Error("invalid persona dimension locked"); + return { + id: boundedId(row["id"], "persona dimension id"), + kind, + label: parseLabel(row["label"]), + source: parseSource(row["source"]), + range, + initial: initial === 0 ? 0 : initial, + locked: row["locked"], + originAxisId: + row["originAxisId"] === null + ? null + : boundedId(row["originAxisId"], "origin axis id"), + }; +} + +function parseSourceDefinition( + value: unknown, +): PersonaSchema["sourceDefinition"] { + if (value === null) return null; + const row = object(value, "persona schema source definition"); + exact( + row, + ["worldId", "definitionRevision", "definitionDigest"], + "persona schema source definition", + ); + return { + worldId: boundedId(row["worldId"], "world id"), + definitionRevision: revision( + row["definitionRevision"], + "definition revision", + ), + definitionDigest: digestValue(row["definitionDigest"], "definition digest"), + }; +} + +function parseSourceIdentity(value: unknown): PersonaSchema["sourceIdentity"] { + if (value === null) return null; + const row = object(value, "persona schema source identity"); + exact(row, ["profileRevision"], "persona schema source identity"); + return { + profileRevision: revision(row["profileRevision"], "profile revision"), + }; +} + +export function parsePersonaSchema(value: unknown): PersonaSchema { + const row = object(value, "persona schema"); + if (row["schemaVersion"] !== 1) + throw Error("Unsupported persona schema version"); + exact(row, SCHEMA_KEYS, "persona schema"); + if (!Array.isArray(row["dimensions"])) + throw Error("invalid persona schema dimensions"); + const dimensions = row["dimensions"].map(parseDimension); + const ids = new Set(); + let previous: PersonaDimension | undefined; + for (const dimension of dimensions) { + if (ids.has(dimension.id)) throw Error("duplicate persona dimension id"); + ids.add(dimension.id); + if (previous !== undefined && compareDimensions(previous, dimension) >= 0) + throw Error("unsorted persona dimensions"); + previous = dimension; + } + const parsed: Omit = { + schemaVersion: 1, + agentId: boundedId(row["agentId"], "agent id"), + revision: revision(row["revision"], "persona schema revision"), + sourceDefinition: parseSourceDefinition(row["sourceDefinition"]), + sourceIdentity: parseSourceIdentity(row["sourceIdentity"]), + dimensions, + }; + const digest = digestValue(row["digest"], "persona schema digest"); + if (digest !== personaSchemaDigest(parsed)) + throw Error("persona schema digest mismatch"); + return { ...parsed, digest }; +} + +function compareDimensions(a: PersonaDimension, b: PersonaDimension): number { + const delta = KIND_RANK[a.kind] - KIND_RANK[b.kind]; + if (delta !== 0) return delta; + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; +} + +function profileFor( + identity: IdentityPolicySnapshot | null, + agentId: string, +): { + profileRevision: number; + lockedTraitIds: string[]; + lockedHabitIds: string[]; + lockedAttitudeIds: string[]; +} | null { + if (identity === null) return null; + for (const profile of identity.profiles) + if (profile.agentId === agentId) return profile; + return null; +} + +function lockedFor( + policy: { + lockedTraitIds: string[]; + lockedHabitIds: string[]; + lockedAttitudeIds: string[]; + } | null, + kind: PersonaDimensionKind, + id: string, +): boolean { + if (policy === null) return false; + if (kind === "trait") return policy.lockedTraitIds.includes(id); + if (kind === "habit") return policy.lockedHabitIds.includes(id); + return policy.lockedAttitudeIds.includes(id); +} + +export function personaSchemaFromLifeDefinition(input: { + agentId: string; + revision: number; + definition: LifeDefinition; + identity: IdentityPolicySnapshot | null; +}): PersonaSchema { + const agentId = boundedId(input.agentId, "agent id"); + const schemaRevision = revision(input.revision, "persona schema revision"); + const definition = parseLifeDefinition(input.definition); + const policy = profileFor(input.identity, agentId); + const dimensions: PersonaDimension[] = [ + ...definition.traits.map((axis) => ({ + id: axis.id, + kind: "trait" as const, + label: axis.label, + source: "reflection" as const, + range: { min: axis.min, max: axis.max }, + initial: axis.initial, + locked: lockedFor(policy, "trait", axis.id), + originAxisId: axis.id, + })), + ...definition.habits.map((habit) => ({ + id: habit.id, + kind: "habit" as const, + label: habit.label, + source: "reflection" as const, + range: null, + initial: habit.initial, + locked: lockedFor(policy, "habit", habit.id), + originAxisId: habit.id, + })), + ...definition.attitudes.map((axis) => ({ + id: axis.id, + kind: "attitude" as const, + label: axis.label, + source: "reflection" as const, + range: { min: axis.min, max: axis.max }, + initial: axis.initial, + locked: lockedFor(policy, "attitude", axis.id), + originAxisId: axis.id, + })), + ].sort(compareDimensions); + const ids = new Set(); + for (const dimension of dimensions) { + if (ids.has(dimension.id)) throw Error("duplicate persona dimension id"); + ids.add(dimension.id); + } + const parsed: Omit = { + schemaVersion: 1, + agentId, + revision: schemaRevision, + sourceDefinition: { + worldId: definition.worldId, + definitionRevision: definition.revision, + definitionDigest: lifeDigest(definition), + }, + sourceIdentity: + policy === null ? null : { profileRevision: policy.profileRevision }, + dimensions, + }; + return { ...parsed, digest: personaSchemaDigest(parsed) }; +} diff --git a/packages/lina-core/test/persona-schema.test.ts b/packages/lina-core/test/persona-schema.test.ts new file mode 100644 index 0000000..fcf63fc --- /dev/null +++ b/packages/lina-core/test/persona-schema.test.ts @@ -0,0 +1,434 @@ +import { expect, test } from "bun:test"; +// Pre-existing symbol used only by the pinned-fingerprint guard; all NEW symbols must still be imported from ../src/agents/index.ts. +import { behaviorFingerprint } from "../src/agents/behavior-validation.ts"; +import type { + DimensionSource, + NeuralProjectionRef, + PersonaDimension, + PersonaDimensionKind, + PersonaDimensionRef, + PersonaSchema, +} from "../src/agents/index.ts"; +import { + DIMENSION_SOURCES, + parsePersonaSchema, + personaSchemaDigest, + personaSchemaFromLifeDefinition, +} from "../src/agents/index.ts"; + +const HASH = "a".repeat(64); +const PINNED_BEHAVIOR_FINGERPRINT = + "4525f17c17ca4c725ba9e4a8490afe2ed3e2952164cdbf9962e6e46b3e785ecd"; +const GOLDEN_PERSONA_SCHEMA_DIGEST = + "fbb3dddb51803bb05996cabe7f2182b305ceaed0127f95f7d19809686b447a37"; +const GOLDEN_PERSONA_SCHEMA_JSON = `{"schemaVersion":1,"agentId":"lina","revision":1,"sourceDefinition":{"worldId":"world","definitionRevision":1,"definitionDigest":"${HASH}"},"sourceIdentity":{"profileRevision":1},"dimensions":[{"id":"warmth","kind":"trait","label":"Warmth","source":"reflection","range":{"min":0,"max":10},"initial":5,"locked":false,"originAxisId":"warmth"},{"id":"tea","kind":"habit","label":"Drinks tea","source":"reflection","range":null,"initial":true,"locked":false,"originAxisId":"tea"},{"id":"trust","kind":"attitude","label":"Trust","source":"reflection","range":{"min":0,"max":5},"initial":2,"locked":true,"originAxisId":"trust"}],"digest":"${GOLDEN_PERSONA_SCHEMA_DIGEST}"}`; + +const definition = { + version: 1 as const, + worldId: "world", + revision: 3, + participants: ["lina"], + traits: [ + { id: "warmth", label: "Warmth", min: 0, max: 10, initial: 5 }, + { id: "curiosity", label: "Curiosity", min: -1, max: 1, initial: 0 }, + ], + habits: [{ id: "tea", label: "Drinks tea", initial: true }], + attitudes: [{ id: "trust", label: "Trust", min: 0, max: 5, initial: 2 }], + projection: { + revision: 1, + sharedTraitIds: ["warmth", "curiosity"], + sharedHabitIds: ["tea"], + sharedAttitudeIds: ["trust"], + disclosures: [], + }, +}; + +const lockProfile = { + agentId: "lina", + profileRevision: 4, + evolution: "adaptive" as const, + lockedTraitIds: ["warmth"], + lockedHabitIds: [] as string[], + lockedAttitudeIds: [] as string[], +}; + +const identityV1 = { version: 1 as const, profiles: [lockProfile] }; +const identityV2 = { + version: 2 as const, + profiles: [ + { + ...lockProfile, + personalBehavior: null, + sourceStamp: null, + }, + ], +}; + +function derive( + identity: typeof identityV1 | typeof identityV2 | null = identityV2, +): PersonaSchema { + return personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition, + identity, + }); +} + +function withDigest(schema: PersonaSchema): PersonaSchema { + const body = { + schemaVersion: schema.schemaVersion, + agentId: schema.agentId, + revision: schema.revision, + sourceDefinition: schema.sourceDefinition, + sourceIdentity: schema.sourceIdentity, + dimensions: schema.dimensions, + }; + return { ...body, digest: personaSchemaDigest(body) }; +} + +test("derivation maps two traits, one habit and one attitude in kind then id order with locked reflection sources", () => { + const schema = derive(); + expect(schema.schemaVersion).toBe(1); + expect(schema.agentId).toBe("lina"); + expect(schema.revision).toBe(1); + expect(schema.dimensions).toHaveLength(4); + expect(schema.dimensions.map((row) => row.kind)).toEqual([ + "trait", + "trait", + "habit", + "attitude", + ]); + expect(schema.dimensions.map((row) => row.id)).toEqual([ + "curiosity", + "warmth", + "tea", + "trust", + ]); + expect(schema.dimensions.map((row) => row.range)).toEqual([ + { min: -1, max: 1 }, + { min: 0, max: 10 }, + null, + { min: 0, max: 5 }, + ]); + expect(schema.dimensions.map((row) => row.initial)).toEqual([0, 5, true, 2]); + expect(schema.dimensions.map((row) => row.locked)).toEqual([ + false, + true, + false, + false, + ]); + expect(schema.dimensions.every((row) => row.source === "reflection")).toBe( + true, + ); + expect(schema.dimensions.every((row) => row.originAxisId === row.id)).toBe( + true, + ); + expect(schema.dimensions.map((row) => row.label)).toEqual([ + "Curiosity", + "Warmth", + "Drinks tea", + "Trust", + ]); + const source = schema.sourceDefinition; + expect(source).not.toBeNull(); + if (source === null) throw Error("expected sourceDefinition"); + expect(source.worldId).toBe("world"); + expect(source.definitionRevision).toBe(3); + expect(source.definitionDigest).toMatch(/^[a-f0-9]{64}$/); + expect(schema.sourceIdentity).toEqual({ profileRevision: 4 }); +}); + +test("derivation digest is idempotent and ignores v2-only identity fields", () => { + const first = derive(); + const second = derive(); + expect(first.digest).toBe(second.digest); + expect(first.digest).toBe( + personaSchemaDigest({ + schemaVersion: first.schemaVersion, + agentId: first.agentId, + revision: first.revision, + sourceDefinition: first.sourceDefinition, + sourceIdentity: first.sourceIdentity, + dimensions: first.dimensions, + }), + ); + + const unlocked = derive(null); + expect(unlocked.sourceIdentity).toBeNull(); + expect(unlocked.dimensions.every((row) => row.locked === false)).toBe(true); + expect(unlocked.digest).not.toBe(first.digest); + + const fromV1 = derive(identityV1); + const fromV2 = derive(identityV2); + expect(fromV1.digest).toBe(fromV2.digest); + expect(fromV1.digest).toBe(first.digest); + + const missing = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition, + identity: { + version: 2 as const, + profiles: [ + { + ...lockProfile, + agentId: "mira", + personalBehavior: null, + sourceStamp: null, + }, + ], + }, + }); + expect(missing.sourceIdentity).toBeNull(); + expect(missing.dimensions.every((row) => row.locked === false)).toBe(true); + expect(missing.digest).toBe(unlocked.digest); +}); + +test("parsePersonaSchema round-trips a derived schema", () => { + const schema = derive(); + const parsed = parsePersonaSchema(schema); + expect(parsed).toEqual(schema); + expect(JSON.stringify(parsed)).toBe(JSON.stringify(schema)); +}); + +test("labels at the LIFE text ceiling round-trip verbatim; one past it is rejected on both sides", () => { + const atCeiling = "x".repeat(32_768); + const pastCeiling = "x".repeat(32_769); + const withLabel = (label: string) => ({ + ...definition, + traits: [{ id: "t", label, min: 0, max: 1, initial: 0 }], + habits: [{ id: "h", label, initial: false }], + attitudes: [{ id: "a", label, min: 0, max: 1, initial: 1 }], + projection: { + revision: 1, + sharedTraitIds: ["t"], + sharedHabitIds: ["h"], + sharedAttitudeIds: ["a"], + disclosures: [], + }, + }); + const schema = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: withLabel(atCeiling), + identity: null, + }); + expect(schema.dimensions.map((row) => row.label)).toEqual([ + atCeiling, + atCeiling, + atCeiling, + ]); + expect(parsePersonaSchema(schema)).toEqual(schema); + expect(() => + personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: withLabel(pastCeiling), + identity: null, + }), + ).toThrow(); + const tampered = withDigest({ + ...schema, + dimensions: schema.dimensions.map((row) => + row.kind === "trait" ? { ...row, label: pastCeiling } : row, + ), + }); + expect(() => parsePersonaSchema(tampered)).toThrow( + "invalid persona dimension label", + ); + expect(() => + parsePersonaSchema( + withDigest({ + ...schema, + dimensions: schema.dimensions.map((row) => + row.kind === "habit" ? { ...row, label: " " } : row, + ), + }), + ), + ).toThrow("invalid persona dimension label"); +}); + +test("golden parses byte-identically", () => { + expect( + JSON.stringify(parsePersonaSchema(JSON.parse(GOLDEN_PERSONA_SCHEMA_JSON))), + ).toBe(GOLDEN_PERSONA_SCHEMA_JSON); +}); + +test("parsePersonaSchema rejects duplicate ids, unknown source, future version, tampered digest, and kind/range/initial mismatches", () => { + const schema = derive(); + const duplicate = structuredClone(schema); + const first = duplicate.dimensions[0]; + if (!first) throw Error("expected dimension"); + duplicate.dimensions.push({ ...first }); + expect(() => parsePersonaSchema(withDigest(duplicate))).toThrow( + "duplicate persona dimension id", + ); + + const other = structuredClone(schema); + const otherDimension = other.dimensions[0]; + if (!otherDimension) throw Error("expected dimension"); + (otherDimension as { source: string }).source = "other"; + expect(() => parsePersonaSchema(withDigest(other))).toThrow( + "invalid persona dimension source", + ); + + expect(() => parsePersonaSchema({ ...schema, schemaVersion: 2 })).toThrow( + /Unsupported persona schema version/, + ); + + const tampered = structuredClone(schema); + tampered.digest = "b".repeat(64); + expect(() => parsePersonaSchema(tampered)).toThrow(); + + const traitRange = structuredClone(schema); + const trait = traitRange.dimensions.find((row) => row.kind === "trait"); + if (!trait) throw Error("expected trait"); + trait.range = null; + expect(() => parsePersonaSchema(withDigest(traitRange))).toThrow( + "invalid persona dimension range", + ); + + const habitInitial = structuredClone(schema); + const habit = habitInitial.dimensions.find((row) => row.kind === "habit"); + if (!habit) throw Error("expected habit"); + habit.initial = 1; + expect(() => parsePersonaSchema(withDigest(habitInitial))).toThrow( + "invalid persona dimension initial", + ); +}); + +test("parsePersonaSchema rejects unsorted dimensions on a correctly digested body", () => { + const z: PersonaDimension = { + id: "z", + kind: "trait", + label: "Z", + source: "reflection", + range: { min: 0, max: 1 }, + initial: 0, + locked: false, + originAxisId: "z", + }; + const a: PersonaDimension = { + id: "a", + kind: "trait", + label: "A", + source: "reflection", + range: { min: 0, max: 1 }, + initial: 0, + locked: false, + originAxisId: "a", + }; + const body = { + schemaVersion: 1 as const, + agentId: "lina", + revision: 1, + sourceDefinition: { + worldId: "world", + definitionRevision: 1, + definitionDigest: HASH, + }, + sourceIdentity: { profileRevision: 1 }, + dimensions: [z, a], + }; + expect(() => + parsePersonaSchema({ ...body, digest: personaSchemaDigest(body) }), + ).toThrow("unsorted persona dimensions"); +}); + +test("personaSchemaFromLifeDefinition rejects cross-kind duplicate dimension ids", () => { + expect(() => + personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { + version: 1, + worldId: "world", + revision: 1, + participants: ["lina"], + traits: [{ id: "same", label: "Trait", min: 0, max: 1, initial: 0 }], + habits: [{ id: "same", label: "Habit", initial: true }], + attitudes: [], + projection: { + revision: 1, + sharedTraitIds: [], + sharedHabitIds: [], + sharedAttitudeIds: [], + disclosures: [], + }, + }, + identity: null, + }), + ).toThrow("duplicate persona dimension id"); +}); + +test("pinned BehaviorJobInput fingerprint is unchanged", () => { + expect( + behaviorFingerprint({ + version: 1, + agentId: "lina", + worldId: "world", + profileRevision: 1, + definitionRevision: 1, + projectionRevision: 1, + policyRevision: 0, + modelSettingsRevision: 0, + definitionDigest: HASH, + projectionDigest: HASH, + promptDigest: HASH, + maxAttempts: 2, + records: [ + { + recordId: "rec-1", + revision: 3, + contentHash: HASH, + proofDigest: + "d425a65ef71512bbc143700c9be99888d38a5f0c2176ae20753b8312792cde70", + proofs: [ + { + entryId: "source", + policyRevision: 1, + policyDigest: HASH, + }, + ], + }, + ], + selectors: { + traits: [{ axisId: "warmth", min: 0, max: 10 }], + habits: [{ habitId: "tea" }], + }, + }), + ).toBe(PINNED_BEHAVIOR_FINGERPRINT); +}); + +test("agents index re-exports persona schema types and dimension sources", () => { + expect(DIMENSION_SOURCES).toEqual(["reflection", "neural"]); + const kind: PersonaDimensionKind = "trait"; + const source: DimensionSource = "reflection"; + const dimension: PersonaDimension = { + id: "warmth", + kind, + label: "Warmth", + source, + range: { min: 0, max: 10 }, + initial: 5, + locked: false, + originAxisId: "warmth", + }; + const ref: PersonaDimensionRef = { + schemaRevision: 1, + dimensionId: dimension.id, + source, + }; + const neural: NeuralProjectionRef = { + schemaVersion: 1, + agentId: "lina", + scopeId: "personal", + schemaRevision: 1, + observationRef: "obs-1", + projectionDigest: HASH, + }; + expect(ref.source).toBe("reflection"); + expect(neural.schemaVersion).toBe(1); + expect(dimension.kind).toBe("trait"); +}); From 53a0be14303796f4ca8dd8b3b130acf81867ddf6 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:12:43 +0900 Subject: [PATCH 04/47] feat(core): add ContextReadProjection separating working and instruction revisions (F1-E1) --- packages/lina-core/src/context/index.ts | 12 + .../lina-core/src/context/read-projection.ts | 303 +++++++++++++ .../test/context-read-projection.test.ts | 409 ++++++++++++++++++ 3 files changed, 724 insertions(+) create mode 100644 packages/lina-core/src/context/read-projection.ts create mode 100644 packages/lina-core/test/context-read-projection.test.ts diff --git a/packages/lina-core/src/context/index.ts b/packages/lina-core/src/context/index.ts index b2e1f35..2fdef65 100644 --- a/packages/lina-core/src/context/index.ts +++ b/packages/lina-core/src/context/index.ts @@ -2,6 +2,18 @@ export { archiveText } from "./archive.ts"; export type { SummaryGeneration } from "./generation.ts"; export { CONTEXT_SCHEMA_VERSION } from "./schema.ts"; export { ContextStore } from "./store.ts"; + +// Context read projection (F1-E1). + +export type { + ContextReadProjection, + InstructionRef, +} from "./read-projection.ts"; +export { + buildContextReadProjection, + parseContextReadProjection, +} from "./read-projection.ts"; + export type { ActivateInput, ActiveSummary, diff --git a/packages/lina-core/src/context/read-projection.ts b/packages/lina-core/src/context/read-projection.ts new file mode 100644 index 0000000..b0fd91b --- /dev/null +++ b/packages/lina-core/src/context/read-projection.ts @@ -0,0 +1,303 @@ +/** + * Deviation note: 017 L65 says "MODIFY core `context/types.ts`의 읽기 투영 계약". + * This plan satisfies that contract with a NEW sibling module re-exported from + * `context/index.ts` because `context-wire.ts:118-162` re-projects `WorkingState` + * field-by-field and `context.test.ts` asserts exact shapes — adding fields to + * `WorkingState` would silently drop on the wire and churn the row codec. + * Same public contract, different file; movable later without changing callers. + */ + +import { createHash } from "node:crypto"; +import { + WORKING_GOAL_MAX_CHARS, + WORKING_ITEM_MAX_CHARS, + WORKING_LIST_MAX_ITEMS, + WORKING_SOURCES_MAX, + type WorkingState, +} from "./types.ts"; + +/** Identifies the instruction whose text was last projected. */ +export interface InstructionRef { + requestId: string; + entryId: string; + /** sha256 hex of the instruction text. */ + textDigest: string; +} + +/** Read-only view separating "what the user asked" from "what the agent is working on". */ +export interface ContextReadProjection { + schemaVersion: 1; + workingRevision: number; + instructionRevision: number; + instruction: InstructionRef | null; + working: WorkingState; + projectedAt: string; +} + +const ALLOWED_KEYS = new Set([ + "schemaVersion", + "workingRevision", + "instructionRevision", + "instruction", + "working", + "projectedAt", +]); + +const WORKING_KEYS = new Set([ + "revision", + "goal", + "decisions", + "openItems", + "nextSteps", + "sourceEntryIds", +]); + +function sha256Hex(text: string): string { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +function isNonBlankId(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +const ISO_8601_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-](\d{2}):(\d{2}))$/; + +function daysInMonth(year: number, month: number): number { + const isLeap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + if (month === 2) return isLeap ? 29 : 28; + if (month === 4 || month === 6 || month === 9 || month === 11) return 30; + return 31; +} + +function isIso8601(value: string): boolean { + const match = ISO_8601_PATTERN.exec(value); + if (!match) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[7] ? Number(match[7]) : 0; + const offsetMinute = match[8] ? Number(match[8]) : 0; + + if (month < 1 || month > 12) return false; + if (day < 1 || day > daysInMonth(year, month)) return false; + if (hour > 23) return false; + if (minute > 59) return false; + if (second > 59) return false; + if (offsetHour > 23) return false; + if (offsetMinute > 59) return false; + + return Number.isFinite(Date.parse(value)); +} + +function reject(message: string): never { + throw new Error(message); +} + +function parseBoundedStringList(value: unknown, field: string): string[] { + if (!Array.isArray(value)) reject(`invalid working ${field}`); + if (value.length > WORKING_LIST_MAX_ITEMS) reject(`invalid working ${field}`); + for (const item of value) { + if (typeof item !== "string") reject(`invalid working ${field}`); + if (item.length > WORKING_ITEM_MAX_CHARS) + reject(`invalid working ${field}`); + } + return [...(value as string[])]; +} + +function parseWorking(value: unknown): WorkingState { + if (typeof value !== "object" || value === null || Array.isArray(value)) + reject("invalid context read projection working"); + const obj = value as Partial>; + for (const key of Object.keys(obj)) { + if (!WORKING_KEYS.has(key)) + reject(`unknown context read projection working field ${key}`); + } + const revision = obj.revision; + if (!Number.isSafeInteger(revision) || (revision as number) < 0) + reject("invalid context read projection working revision"); + const goal = obj.goal; + if (typeof goal !== "string" || goal.length > WORKING_GOAL_MAX_CHARS) + reject("invalid context read projection working goal"); + return { + revision: revision as number, + goal, + decisions: parseBoundedStringList(obj.decisions, "decisions"), + openItems: parseBoundedStringList(obj.openItems, "openItems"), + nextSteps: parseBoundedStringList(obj.nextSteps, "nextSteps"), + sourceEntryIds: parseSourceEntryIds(obj.sourceEntryIds), + }; +} + +function parseSourceEntryIds(value: unknown): string[] { + if (!Array.isArray(value)) reject("invalid working sourceEntryIds"); + if (value.length > WORKING_SOURCES_MAX) + reject("invalid working sourceEntryIds"); + for (const item of value) { + if (typeof item !== "string") reject("invalid working sourceEntryIds"); + } + return [...(value as string[])]; +} + +function parseInstructionRef(value: unknown): InstructionRef { + if (typeof value !== "object" || value === null || Array.isArray(value)) + reject("invalid context read projection instruction"); + const obj = value as Partial>; + for (const key of Object.keys(obj)) { + if (!["requestId", "entryId", "textDigest"].includes(key)) + reject(`unknown context read projection instruction field ${key}`); + } + const requestId = obj.requestId; + const entryId = obj.entryId; + const textDigest = obj.textDigest; + if (!isNonBlankId(requestId)) + reject("invalid context read projection instruction requestId"); + if (!isNonBlankId(entryId)) + reject("invalid context read projection instruction entryId"); + if (typeof textDigest !== "string" || textDigest.length === 0) + reject("invalid context read projection instruction textDigest"); + return { requestId, entryId, textDigest }; +} + +function sameInstruction( + a: InstructionRef | null, + b: InstructionRef | null, +): boolean { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + return ( + a.requestId === b.requestId && + a.entryId === b.entryId && + a.textDigest === b.textDigest + ); +} + +function copyWorking(working: WorkingState): WorkingState { + return { + revision: working.revision, + goal: working.goal, + decisions: [...working.decisions], + openItems: [...working.openItems], + nextSteps: [...working.nextSteps], + sourceEntryIds: [...working.sourceEntryIds], + }; +} + +function validateInput( + working: WorkingState, + projectedAt: string, + instruction: { requestId: string; entryId: string; text: string } | null, +): void { + // Validate working against the same bounds the store uses. + try { + parseWorking(working); + } catch { + reject("invalid context read projection input"); + } + if (typeof projectedAt !== "string" || !isIso8601(projectedAt)) + reject("invalid context read projection input"); + if (instruction !== null) { + if (!isNonBlankId(instruction.requestId)) + reject("invalid context read projection input"); + if (!isNonBlankId(instruction.entryId)) + reject("invalid context read projection input"); + } +} + +/** + * Builds a read projection from a working state and an optional instruction. + * `workingRevision` is always taken from `working.revision`; `instructionRevision` + * advances whenever the instruction identity changes. + */ +export function buildContextReadProjection(input: { + working: WorkingState; + instruction: { requestId: string; entryId: string; text: string } | null; + previous: ContextReadProjection | null; + projectedAt: string; +}): ContextReadProjection { + const { working, instruction, previous, projectedAt } = input; + validateInput(working, projectedAt, instruction); + + const instructionRef: InstructionRef | null = + instruction === null + ? null + : { + requestId: instruction.requestId, + entryId: instruction.entryId, + textDigest: sha256Hex(instruction.text), + }; + + const instructionRevision = + previous === null + ? instructionRef === null + ? 0 + : 1 + : previous.instructionRevision + + (sameInstruction(previous.instruction, instructionRef) ? 0 : 1); + + return { + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision, + instruction: instructionRef, + working: copyWorking(working), + projectedAt, + }; +} + +/** Parses and validates a serialized read projection. */ +export function parseContextReadProjection( + value: unknown, +): ContextReadProjection { + if (typeof value !== "object" || value === null || Array.isArray(value)) + reject("invalid context read projection"); + const obj = value as Partial>; + + const schemaVersion = obj.schemaVersion; + if (schemaVersion !== 1) + reject("Unsupported context read projection schema version"); + + for (const key of Object.keys(obj)) { + if (!ALLOWED_KEYS.has(key)) + reject(`unknown context read projection field ${key}`); + } + + const workingRevision = obj.workingRevision; + if (!Number.isSafeInteger(workingRevision) || (workingRevision as number) < 0) + reject("invalid context read projection workingRevision"); + + const instructionRevision = obj.instructionRevision; + if ( + !Number.isSafeInteger(instructionRevision) || + (instructionRevision as number) < 0 + ) + reject("invalid context read projection instructionRevision"); + + const instructionRaw = obj.instruction; + if (instructionRaw !== null && instructionRaw !== undefined) + parseInstructionRef(instructionRaw); + + if ((instructionRevision as number) === 0 && instructionRaw !== null) + reject("invalid context read projection instructionRevision"); + + const projectedAt = obj.projectedAt; + if (typeof projectedAt !== "string" || !isIso8601(projectedAt)) + reject("invalid context read projection projectedAt"); + + const working = parseWorking(obj.working); + if (working.revision !== workingRevision) + reject("invalid context read projection workingRevision"); + + return { + schemaVersion: 1, + workingRevision: workingRevision as number, + instructionRevision: instructionRevision as number, + instruction: + instructionRaw === null ? null : parseInstructionRef(instructionRaw), + working, + projectedAt, + }; +} diff --git a/packages/lina-core/test/context-read-projection.test.ts b/packages/lina-core/test/context-read-projection.test.ts new file mode 100644 index 0000000..268463e --- /dev/null +++ b/packages/lina-core/test/context-read-projection.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { join } from "node:path"; +import { + buildContextReadProjection, + type ContextReadProjection, + type InstructionRef, + parseContextReadProjection, + WORKING_GOAL_MAX_CHARS, + WORKING_ITEM_MAX_CHARS, + WORKING_LIST_MAX_ITEMS, + WORKING_SOURCES_MAX, + type WorkingState, +} from "../src/context/index.ts"; +import { ContextStore } from "../src/context/store.ts"; +import { appendContextEntry } from "./context-journal-fixture.ts"; +import { entry, Fixture } from "./fixture.ts"; + +describe("ContextReadProjection", () => { + let fixture: Fixture; + let file: string; + let store: ContextStore; + + const openStore = () => { + const durable = fixture.store(); + return fixture.keep( + new ContextStore(file, fixture.binding, (id) => durable.sourceEntry(id), { + lookupRequest: (id) => + durable.sourceEntry(durable.request(id)?.entryId ?? ""), + }), + ); + }; + + beforeEach(() => { + fixture = new Fixture(); + file = join(fixture.dir, "context.sqlite"); + store = openStore(); + }); + + afterEach(() => fixture.close()); + + function makeInstruction( + entryId: string, + text: string, + ): { + requestId: string; + entryId: string; + text: string; + } { + const requestId = appendContextEntry( + fixture.store(), + fixture.binding.sessionId, + entry(entryId, { role: "user", text }), + ); + return { requestId, entryId, text }; + } + + it("increments workingRevision when store working changes, keeps instructionRevision stable", () => { + const instruction = makeInstruction("i1", "hello"); + const requestId = instruction.requestId; + const projectedAt = "2026-09-10T00:00:00.000Z"; + const working = store.working(); + const p0 = buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt, + }); + expect(p0.workingRevision).toBe(working.revision); + expect(p0.instructionRevision).toBe(1); + + const updated = store.updateWorking( + p0.workingRevision, + { goal: "g2" }, + { activeRequestId: requestId }, + ); + const p1 = buildContextReadProjection({ + working: updated, + instruction, + previous: p0, + projectedAt, + }); + expect(p1.workingRevision).toBe(p0.workingRevision + 1); + expect(p1.instructionRevision).toBe(p0.instructionRevision); + }); + + it("increments instructionRevision when entryId changes, keeps workingRevision stable", () => { + const instruction1 = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const working = store.working(); + const p0 = buildContextReadProjection({ + working, + instruction: instruction1, + previous: null, + projectedAt, + }); + const instruction2 = makeInstruction("i2", "hello"); + const p1 = buildContextReadProjection({ + working, + instruction: instruction2, + previous: p0, + projectedAt, + }); + expect(p1.instructionRevision).toBe(p0.instructionRevision + 1); + expect(p1.workingRevision).toBe(p0.workingRevision); + }); + + it("increments instructionRevision when text changes for the same entry", () => { + const instruction1 = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const working = store.working(); + const p0 = buildContextReadProjection({ + working, + instruction: instruction1, + previous: null, + projectedAt, + }); + const instruction2 = { ...instruction1, text: "world" }; + const p1 = buildContextReadProjection({ + working, + instruction: instruction2, + previous: p0, + projectedAt, + }); + expect(p1.instructionRevision).toBe(p0.instructionRevision + 1); + expect(p1.workingRevision).toBe(p0.workingRevision); + }); + + it("starts instructionRevision at 0 when there is no instruction and no previous", () => { + const working = store.working(); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const p = buildContextReadProjection({ + working, + instruction: null, + previous: null, + projectedAt, + }); + expect(p.instructionRevision).toBe(0); + expect(p.instruction).toBeNull(); + }); + + it("copies working state so later mutation of the source arrays cannot leak in", () => { + const working = store.working(); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const p = buildContextReadProjection({ + working, + instruction: null, + previous: null, + projectedAt, + }); + working.decisions.push("mutation"); + working.openItems.push("mutation"); + working.nextSteps.push("mutation"); + working.sourceEntryIds.push("mutation"); + expect(p.working.decisions).not.toContain("mutation"); + expect(p.working.openItems).not.toContain("mutation"); + expect(p.working.nextSteps).not.toContain("mutation"); + expect(p.working.sourceEntryIds).not.toContain("mutation"); + }); + + it("round-trips a golden projection through parseContextReadProjection byte-identically", () => { + const working: WorkingState = { + revision: 3, + goal: "g", + decisions: ["d1"], + openItems: ["o1"], + nextSteps: ["n1"], + sourceEntryIds: ["s1"], + }; + const instruction: InstructionRef = { + requestId: "req-1", + entryId: "e1", + textDigest: + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + }; + const golden: ContextReadProjection = { + schemaVersion: 1, + workingRevision: 3, + instructionRevision: 2, + instruction, + working, + projectedAt: "2026-09-10T00:00:00.000Z", + }; + const parsed = parseContextReadProjection(golden); + expect(JSON.stringify(parsed)).toBe(JSON.stringify(golden)); + }); + + it("rejects unsupported schemaVersion, workingRevision mismatch, and unknown fields", () => { + const working: WorkingState = { + revision: 3, + goal: "g", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }; + const base = { + schemaVersion: 1, + workingRevision: 3, + instructionRevision: 0, + instruction: null, + working, + projectedAt: "2026-09-10T00:00:00.000Z", + }; + expect(() => + parseContextReadProjection({ ...base, schemaVersion: 2 }), + ).toThrow(/Unsupported context read projection schema version/); + expect(() => + parseContextReadProjection({ ...base, workingRevision: 4 }), + ).toThrow(/invalid context read projection workingRevision/); + expect(() => + parseContextReadProjection({ ...base, unknownField: true }), + ).toThrow(/unknown context read projection field unknownField/); + }); + + it("rejects instruction non-null when instructionRevision is zero", () => { + const working: WorkingState = { + revision: 3, + goal: "g", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }; + const instruction: InstructionRef = { + requestId: "req-1", + entryId: "e1", + textDigest: + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + }; + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: 3, + instructionRevision: 0, + instruction, + working, + projectedAt: "2026-09-10T00:00:00.000Z", + }), + ).toThrow(/invalid context read projection instructionRevision/); + }); + + it("rejects non-ISO projectedAt in builder and parser", () => { + const working = store.working(); + const instruction = makeInstruction("i1", "hello"); + expect(() => + buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt: "September 10, 2026", + }), + ).toThrow(/invalid context read projection input/); + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision: 1, + instruction: null, + working, + projectedAt: "September 10, 2026", + }), + ).toThrow(/invalid context read projection projectedAt/); + const iso = "2026-09-11T00:00:00.000Z"; + expect(() => + buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt: iso, + }), + ).not.toThrow(); + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision: 1, + instruction: null, + working, + projectedAt: iso, + }), + ).not.toThrow(); + }); + + it("builder validates working state against the same bounds as the store", () => { + const instruction = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const base = store.working(); + expect(() => + buildContextReadProjection({ + working: { ...base, goal: "x".repeat(WORKING_GOAL_MAX_CHARS + 1) }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + buildContextReadProjection({ + working: { + ...base, + decisions: Array.from( + { length: WORKING_LIST_MAX_ITEMS + 1 }, + (_, i) => `d${i}`, + ), + }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + buildContextReadProjection({ + working: { + ...base, + openItems: ["x".repeat(WORKING_ITEM_MAX_CHARS + 1)], + }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + buildContextReadProjection({ + working: { + ...base, + sourceEntryIds: Array.from( + { length: WORKING_SOURCES_MAX + 1 }, + (_, i) => `s${i}`, + ), + }, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + }); + + it("builder output round-trips through parseContextReadProjection", () => { + const instruction = makeInstruction("i1", "hello"); + const projectedAt = "2026-09-10T00:00:00.000Z"; + const built = buildContextReadProjection({ + working: store.working(), + instruction, + previous: null, + projectedAt, + }); + const roundTripped = parseContextReadProjection( + JSON.parse(JSON.stringify(built)), + ); + expect(JSON.stringify(roundTripped)).toBe(JSON.stringify(built)); + }); + + it("isIso8601 rejects impossible calendar dates and invalid time/offset components", () => { + const instruction = makeInstruction("i1", "hello"); + const working = store.working(); + + const rejected = [ + "2026-02-30T00:00:00.000Z", + "2026-04-31T00:00:00.000Z", + "2023-02-29T00:00:00.000Z", + "2026-13-01T00:00:00.000Z", + "2026-00-10T00:00:00.000Z", + "2026-09-00T00:00:00.000Z", + "2026-09-10T24:00:00.000Z", + "2026-09-10T23:60:00.000Z", + "2026-09-10T23:59:60.000Z", + "2026-09-10T00:00:00.000+24:00", + ]; + for (const projectedAt of rejected) { + expect(() => + buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt, + }), + ).toThrow(/invalid context read projection input/); + expect(() => + parseContextReadProjection({ + schemaVersion: 1, + workingRevision: working.revision, + instructionRevision: 1, + instruction: null, + working, + projectedAt, + }), + ).toThrow(/invalid context read projection projectedAt/); + } + + const accepted = [ + "2024-02-29T00:00:00.000Z", + "2000-02-29T00:00:00.000Z", + "2026-12-31T23:59:59.999Z", + "2026-09-10T00:00:00+09:00", + ]; + for (const projectedAt of accepted) { + const built = buildContextReadProjection({ + working, + instruction, + previous: null, + projectedAt, + }); + expect(built.projectedAt).toBe(projectedAt); + const roundTripped = parseContextReadProjection( + JSON.parse(JSON.stringify(built)), + ); + expect(roundTripped.projectedAt).toBe(projectedAt); + } + }); +}); From 4c17e1d1bcb82517000b9dd8eed7ddd741421a3c Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:44:53 +0900 Subject: [PATCH 05/47] feat(core): add personal.v1 option catalog and arbitration policy revision 1 (F1-BC) --- packages/lina-core/src/agents/index.ts | 5 + .../lina-core/src/agents/judgment-catalog.ts | 317 ++++++++ .../lina-core/src/agents/judgment-policy.ts | 342 ++++++++ .../lina-core/test/judgment-policy.test.ts | 767 ++++++++++++++++++ 4 files changed, 1431 insertions(+) create mode 100644 packages/lina-core/src/agents/judgment-catalog.ts create mode 100644 packages/lina-core/src/agents/judgment-policy.ts create mode 100644 packages/lina-core/test/judgment-policy.test.ts diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 0364e06..8b3edba 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -79,3 +79,8 @@ export { personaSchemaDigest, personaSchemaFromLifeDefinition, } from "./persona-schema.ts"; + +// Personal catalog and arbitration policy. + +export * from "./judgment-catalog.ts"; +export * from "./judgment-policy.ts"; diff --git a/packages/lina-core/src/agents/judgment-catalog.ts b/packages/lina-core/src/agents/judgment-catalog.ts new file mode 100644 index 0000000..6a03daa --- /dev/null +++ b/packages/lina-core/src/agents/judgment-catalog.ts @@ -0,0 +1,317 @@ +import { createHash } from "node:crypto"; +import type { OptionKey } from "./judgment.ts"; +import { boundedId, boundedText } from "./validation.ts"; + +export const PERSONAL_CATALOG_ID = "personal.v1" as const; +export const PERSONAL_OPTION_KINDS = [ + "intention.adopt", + "intention.activate", + "intention.suspend", + "intention.resume", + "intention.cancel", + "intention.complete", + "task.start", + "task.send", + "task.interrupt", + "task.handover", + "inquire", + "defer", + "noop", +] as const; +export type PersonalOptionKind = (typeof PERSONAL_OPTION_KINDS)[number]; +export const EFFECT_OWNERS = [ + "judgment_store", + "task_manager", + "host", + "none", +] as const; +export type EffectOwner = (typeof EFFECT_OWNERS)[number]; + +export type PersonalPrecondition = + | { + kind: "intention.adopt"; + intentionKind: "user_commitment" | "autonomous_goal"; + sourceRef: string; + } + | { kind: "intention.activate"; intentionId: string } + | { + kind: "intention.suspend" | "intention.resume"; + intentionId: string; + acceptanceSourceRef: string; + reason: string; + } + | { + kind: "intention.cancel"; + intentionId: string; + acceptanceSourceRef: string; + authorityRef: string; + userConfirmationRef: string | null; + } + | { kind: "intention.complete"; intentionId: string; outcomeRef: string } + | { + kind: "task.start"; + authorityRef: string; + taskText: string; + intentionId: string; + } + | { + kind: "task.send" | "task.interrupt" | "task.handover"; + taskId: string; + ownerId: string; + revision: number; + } + | { kind: "inquire"; authorityRef: string } + | { kind: "defer"; resumeCondition: string } + | { kind: "noop"; reason: string }; + +export type CanonicalOption = { + schemaVersion: 1; + catalogId: typeof PERSONAL_CATALOG_ID; + kind: PersonalOptionKind; + actor: { agentId: string; scopeId: string }; + targetId: string | null; + args: Record; + preconditions: PersonalPrecondition; + effect: { owner: EffectOwner; scope: string }; + optionKey: OptionKey; +}; + +const owners: Record = { + "intention.adopt": "judgment_store", + "intention.activate": "judgment_store", + "intention.suspend": "judgment_store", + "intention.resume": "judgment_store", + "intention.cancel": "judgment_store", + "intention.complete": "judgment_store", + "task.start": "task_manager", + "task.send": "task_manager", + "task.interrupt": "task_manager", + "task.handover": "task_manager", + inquire: "host", + defer: "none", + noop: "none", +}; + +function object(value: unknown, label: string): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + return value as Record; +} +function fields( + row: Record, + keys: readonly string[], + label: string, +): void { + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); +} +function optionKind(value: unknown): PersonalOptionKind { + const text = boundedId(value, "option kind").toLowerCase(); + const kind = PERSONAL_OPTION_KINDS.find((kind) => kind === text); + if (kind === undefined) throw Error("unknown personal.v1 option kind"); + return kind; +} +function target(value: unknown): string | null { + return value === null ? null : boundedId(value, "target id").trim(); +} + +export function normalizeOptionArgs( + args: Record, +): Record { + const row = object(args, "option args"); + return Object.fromEntries( + Object.keys(row) + .sort() + .flatMap((key) => { + boundedId(key, "option argument key"); + const value = row[key]; + if (typeof value !== "string") throw Error("invalid option argument"); + const normalized = value.normalize("NFC").trim().replace(/\s+/g, " "); + if (normalized === "") return []; + boundedText(value, "option argument"); + return [[key, boundedText(normalized, "option argument")]]; + }), + ); +} + +export function canonicalOptionKey( + option: Omit, +): OptionKey { + const hash = createHash("sha256") + .update(JSON.stringify(normalizeOptionArgs(option.args))) + .digest("hex"); + return `${option.catalogId}:${optionKind(option.kind)}:${target(option.targetId) ?? "-"}:${hash}`; +} + +function parsePrecondition( + value: unknown, + kind: PersonalOptionKind, +): PersonalPrecondition { + const row = object(value, "personal precondition"); + if (row["kind"] !== kind) throw Error("precondition kind mismatch"); + const check = (...keys: string[]) => + fields(row, ["kind", ...keys], "personal precondition"); + const id = (key: string) => boundedId(row[key], key); + const text = (key: string) => boundedText(row[key], key); + switch (kind) { + case "intention.adopt": { + check("intentionKind", "sourceRef"); + const intentionKind = row["intentionKind"]; + if ( + intentionKind !== "user_commitment" && + intentionKind !== "autonomous_goal" + ) + throw Error("invalid adoption intention kind"); + return { kind, intentionKind, sourceRef: id("sourceRef") }; + } + case "intention.activate": + check("intentionId"); + return { kind, intentionId: id("intentionId") }; + case "intention.suspend": + case "intention.resume": + check("intentionId", "acceptanceSourceRef", "reason"); + return { + kind, + intentionId: id("intentionId"), + acceptanceSourceRef: id("acceptanceSourceRef"), + reason: text("reason"), + }; + case "intention.cancel": + check( + "intentionId", + "acceptanceSourceRef", + "authorityRef", + "userConfirmationRef", + ); + return { + kind, + intentionId: id("intentionId"), + acceptanceSourceRef: id("acceptanceSourceRef"), + authorityRef: id("authorityRef"), + userConfirmationRef: + row["userConfirmationRef"] === null + ? null + : id("userConfirmationRef"), + }; + case "intention.complete": + check("intentionId", "outcomeRef"); + return { + kind, + intentionId: id("intentionId"), + outcomeRef: id("outcomeRef"), + }; + case "task.start": + check("authorityRef", "taskText", "intentionId"); + return { + kind, + authorityRef: id("authorityRef"), + taskText: text("taskText"), + intentionId: id("intentionId"), + }; + case "task.send": + case "task.interrupt": + case "task.handover": { + check("taskId", "ownerId", "revision"); + const revision = row["revision"]; + if ( + typeof revision !== "number" || + !Number.isSafeInteger(revision) || + revision < 0 + ) + throw Error("invalid task revision"); + return { kind, taskId: id("taskId"), ownerId: id("ownerId"), revision }; + } + case "inquire": + check("authorityRef"); + return { kind, authorityRef: id("authorityRef") }; + case "defer": + check("resumeCondition"); + return { kind, resumeCondition: text("resumeCondition") }; + case "noop": + check("reason"); + return { kind, reason: text("reason") }; + } +} + +export function parseCanonicalOption(value: unknown): CanonicalOption { + const row = object(value, "canonical option"); + if (row["schemaVersion"] !== 1) + throw Error("Unsupported canonical option schema version"); + fields( + row, + [ + "schemaVersion", + "catalogId", + "kind", + "actor", + "targetId", + "args", + "preconditions", + "effect", + "optionKey", + ], + "canonical option", + ); + if (row["catalogId"] !== PERSONAL_CATALOG_ID) + throw Error("unsupported option catalog"); + const kind = optionKind(row["kind"]); + const actor = object(row["actor"], "option actor"); + fields(actor, ["agentId", "scopeId"], "option actor"); + const effect = object(row["effect"], "option effect"); + fields(effect, ["owner", "scope"], "option effect"); + if (effect["owner"] !== owners[kind]) + throw Error("option effect owner mismatch"); + const args = object(row["args"], "option args"); + // normalizeOptionArgs validates each value at this unknown-data boundary. + const result: CanonicalOption = { + schemaVersion: 1, + catalogId: PERSONAL_CATALOG_ID, + kind, + actor: { + agentId: boundedId(actor["agentId"], "agent id"), + scopeId: boundedId(actor["scopeId"], "scope id"), + }, + targetId: target(row["targetId"]), + args: normalizeOptionArgs(args as Record), + preconditions: parsePrecondition(row["preconditions"], kind), + effect: { + owner: owners[kind], + scope: boundedId(effect["scope"], "effect scope"), + }, + optionKey: boundedId(row["optionKey"], "option key"), + }; + if (result.optionKey !== canonicalOptionKey(result)) + throw Error("canonical option key mismatch"); + return result; +} + +export function buildCanonicalOption( + input: Omit< + CanonicalOption, + "schemaVersion" | "catalogId" | "optionKey" | "effect" + >, +): CanonicalOption { + const kind = optionKind(input.kind); + const option = { + schemaVersion: 1 as const, + catalogId: PERSONAL_CATALOG_ID, + kind, + actor: input.actor, + targetId: input.targetId, + args: input.args, + preconditions: input.preconditions, + // The Host supplies the actor's scope; the catalog selects its effect owner. + effect: { owner: owners[kind], scope: input.actor.scopeId }, + }; + return parseCanonicalOption({ + ...option, + optionKey: canonicalOptionKey(option), + }); +} diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts new file mode 100644 index 0000000..6cc52b1 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -0,0 +1,342 @@ +import { + type AssessmentSet, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type OptionAssessment, + type OptionKey, + type ResolutionRecord, + type SelectionSpec, + type Situation, + type Stance, +} from "./judgment.ts"; +import { + type CanonicalOption, + parseCanonicalOption, +} from "./judgment-catalog.ts"; +import { + judgmentDigest, + parseAssessmentSet, + parseResolutionRecord, + parseSelectionSpec, + snapshotDigest, +} from "./judgment-validation.ts"; + +export type ArbitrationPolicy = { + policyId: string; + revision: number; + ratio: number; + orders: Record; + lambda: Record; + stanceOrder: readonly Stance[]; +}; +export const PERSONAL_POLICY_V1: ArbitrationPolicy = { + policyId: "personal.v1", + revision: 1, + ratio: 0.5, + orders: { + user_request: ["atropos", "clotho", "lachesis"], + autonomous: ["lachesis", "clotho", "atropos"], + transition: ["clotho", "atropos", "lachesis"], + }, + lambda: { user_request: 0, autonomous: 1, transition: 1 }, + stanceOrder: ["prefer", "accept", "oppose"], +}; +export type HostEligibility = Array<{ + optionKey: OptionKey; + eligible: boolean; + reason: string | null; +}>; + +/** Each rank owns one geometric mass, regardless of how many candidates tie. */ +export function rankMass(ranks: number[], ratio: number): number[] { + if (!Number.isFinite(ratio) || ratio <= 0) throw Error("invalid rank ratio"); + const counts = new Map(); + for (const rank of ranks) { + if (!Number.isSafeInteger(rank) || rank < 1) throw Error("invalid rank"); + counts.set(rank, (counts.get(rank) ?? 0) + 1); + } + const total = [...counts.keys()].reduce( + (sum, rank) => sum + ratio ** (rank - 1), + 0, + ); + return ranks.map( + (rank) => ratio ** (rank - 1) / total / (counts.get(rank) ?? 1), + ); +} + +type Candidate = { + option: CanonicalOption; + opinions: Record; +}; + +export function resolvePersonalRound(input: { + policy: ArbitrationPolicy; + snapshot: JudgmentSnapshotRef; + options: CanonicalOption[]; + set: AssessmentSet; + eligibility: HostEligibility; + bias: Record; +}): { resolution: ResolutionRecord; spec: SelectionSpec | null } { + const { policy, snapshot } = input; + const options = input.options + .map(parseCanonicalOption) + .sort((a, b) => + a.optionKey < b.optionKey ? -1 : a.optionKey > b.optionKey ? 1 : 0, + ); + const keys = new Set(options.map((o) => o.optionKey)); + if (keys.size !== options.length) throw Error("duplicate option key"); + const eligibility = new Map(); + for (const row of input.eligibility) { + if (!keys.has(row.optionKey)) throw Error("unknown eligibility option key"); + if (eligibility.has(row.optionKey)) + throw Error("duplicate eligibility option key"); + eligibility.set(row.optionKey, row); + } + const set = parseAssessmentSet(input.set); + const digest = snapshotDigest(snapshot); + if (set.roundId !== snapshot.roundId || set.snapshotDigest !== digest) + throw Error("assessment set snapshot mismatch"); + const order = [...policy.orders[snapshot.situation]]; + const record: ResolutionRecord = { + schemaVersion: 1, + roundId: snapshot.roundId, + policyId: policy.policyId, + policyRevision: policy.revision, + situation: snapshot.situation, + order, + recommendations: { clotho: [], lachesis: [], atropos: [] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [], + conceded: [], + status: "resolved", + holdReason: null, + }; + for (const assessment of set.assessments) + record.recommendations[assessment.moduleKind] = [ + ...assessment.recommendedOptionKeys, + ]; + const finishWithoutSpec = ( + status: "held" | "deferred", + holdReason: string, + ) => ({ + resolution: parseResolutionRecord({ ...record, status, holdReason }), + spec: null, + }); + + // 1-2: Host facts first; completeness is required only for eligible options. + const eligible: Candidate[] = []; + let missing: string | null = null; + for (const option of options) { + const row = eligibility.get(option.optionKey); + const hostEligible = row?.eligible === true; + if (!hostEligible) + record.excluded.push({ + optionKey: option.optionKey, + stage: "host_eligibility", + byModule: null, + reason: row?.reason ?? "missing host eligibility", + }); + const opinions: Partial> = {}; + for (const assessment of set.assessments) { + const moduleKind = assessment.moduleKind; + const opinion = assessment.objectiveAssessments.find( + (o) => o.optionKey === option.optionKey, + ); + if (opinion === undefined) { + if (hostEligible && missing === null) + missing = `missing assessment ${moduleKind} for ${option.optionKey}`; + continue; + } + opinions[moduleKind] = opinion; + if (opinion.unavailableReason !== null) + record.abstentions.push({ + optionKey: option.optionKey, + moduleKind, + reason: opinion.unavailableReason, + }); + } + const { clotho, lachesis, atropos } = opinions; + if (clotho && lachesis && atropos) { + const complete = { clotho, lachesis, atropos }; + if (new Set(MODULE_KINDS.map((m) => complete[m].stance)).size > 1) + record.conflicts.push({ + optionKey: option.optionKey, + stances: { + clotho: clotho.stance, + lachesis: lachesis.stance, + atropos: atropos.stance, + }, + }); + if (hostEligible) eligible.push({ option, opinions: complete }); + } + } + if (missing !== null) return finishWithoutSpec("held", missing); + + // 3-4: Only the named module/severity can exclude at each stage. + const remaining = eligible.filter(({ option, opinions }) => { + const precondition = option.preconditions; + const changesAcceptance = + (precondition.kind === "intention.suspend" || + precondition.kind === "intention.cancel") && + precondition.acceptanceSourceRef.trim() !== ""; + if ( + opinions.atropos.stance === "oppose" && + opinions.atropos.severity === "commitment_breach" && + !changesAcceptance + ) { + record.excluded.push({ + optionKey: option.optionKey, + stage: "commitment_protection", + byModule: "atropos", + reason: "accepted commitment breach", + }); + return false; + } + if ( + opinions.clotho.stance === "oppose" && + opinions.clotho.severity === "infeasible" + ) { + record.excluded.push({ + optionKey: option.optionKey, + stage: "infeasible", + byModule: "clotho", + reason: "infeasible precondition", + }); + return false; + } + return true; + }); + // 9: No candidate means deferred, not an empty ordering or uniform fallback. + if (remaining.length === 0) + return finishWithoutSpec("deferred", "no eligible candidate"); + + // 5: Drop an unavailable module for the ENTIRE round, never per comparison. + const orderingModules = order.filter((m) => + remaining.every((c) => c.opinions[m].stance !== "unavailable"), + ); + if (orderingModules.length === 0) + return finishWithoutSpec("held", "all modules unavailable for ordering"); + const compare = (a: Candidate, b: Candidate): number => { + for (const moduleKind of orderingModules) { + const difference = + policy.stanceOrder.indexOf(a.opinions[moduleKind].stance) - + policy.stanceOrder.indexOf(b.opinions[moduleKind].stance); + if (difference !== 0) return difference; + } + return 0; + }; + remaining.sort(compare); + let rank = 0; + let previous: Candidate | undefined; + for (const candidate of remaining) { + if (previous === undefined || compare(previous, candidate) !== 0) rank += 1; + record.ranking.push({ optionKey: candidate.option.optionKey, rank }); + previous = candidate; + } + const winners = new Set( + record.ranking.filter((r) => r.rank === 1).map((r) => r.optionKey), + ); + for (const assessment of set.assessments) { + for (const opinion of assessment.objectiveAssessments) { + if ( + keys.has(opinion.optionKey) && + opinion.stance === "prefer" && + !winners.has(opinion.optionKey) + ) + record.conceded.push({ + moduleKind: assessment.moduleKind, + optionKey: opinion.optionKey, + }); + } + } + + // 6-8: Bias is copied only AFTER p0 has been produced and is never ranked. + const masses = rankMass( + record.ranking.map((r) => r.rank), + policy.ratio, + ); + const baseline = new Map( + record.ranking.map((r, i) => [r.optionKey, masses[i] ?? 0]), + ); + const candidates = options.map((o) => ({ + optionKey: o.optionKey, + p0: baseline.get(o.optionKey) ?? 0, + b: input.bias[o.optionKey] ?? null, + })); + if (!candidates.some((c) => c.p0 > 0)) + return finishWithoutSpec("deferred", "no eligible candidate"); + const resolution = parseResolutionRecord(record); + const spec = { + schemaVersion: 1 as const, + roundId: snapshot.roundId, + snapshotDigest: digest, + assessmentSetDigest: judgmentDigest(set), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + resolutionDigest: judgmentDigest(resolution), + policyId: policy.policyId, + policyRevision: policy.revision, + situation: snapshot.situation, + lambda: policy.lambda[snapshot.situation], + candidates, + eligibleDigest: judgmentDigest( + candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + }; + return { + resolution, + spec: parseSelectionSpec({ ...spec, specDigest: judgmentDigest(spec) }), + }; +} + +export function sampleSelection( + spec: SelectionSpec, + u: number, +): { + optionKey: OptionKey; + probabilities: Array<{ optionKey: OptionKey; p: number }>; + consumedDraw: boolean; +} { + const parsed = parseSelectionSpec(spec); + const positive = parsed.candidates.filter((c) => c.p0 > 0); + const single = positive.length === 1 ? positive.at(0) : undefined; + if (single) + return { + optionKey: single.optionKey, + probabilities: parsed.candidates.map((c) => ({ + optionKey: c.optionKey, + p: c.p0 > 0 ? 1 : 0, + })), + consumedDraw: false, + }; + if (!Number.isFinite(u) || u < 0 || u >= 1) + throw Error("invalid selection draw"); + const logs = positive.map((c) => Math.log(c.p0) + parsed.lambda * (c.b ?? 0)); + const max = Math.max(...logs); + const total = logs.reduce((sum, log) => sum + Math.exp(log - max), 0); + const probabilities = parsed.candidates.map((c) => ({ + optionKey: c.optionKey, + p: + c.p0 === 0 + ? 0 + : Math.exp(Math.log(c.p0) + parsed.lambda * (c.b ?? 0) - max) / total, + })); + let cumulative = 0; + let lastPositive: OptionKey | undefined; + for (const candidate of probabilities) { + cumulative += candidate.p; + if (candidate.p > 0) lastPositive = candidate.optionKey; + if (cumulative > u) + return { + optionKey: candidate.optionKey, + probabilities, + consumedDraw: true, + }; + } + // Floating-point summation may leave a sub-tolerance tail below one. + if (lastPositive !== undefined && Math.abs(cumulative - 1) <= 1e-12) + return { optionKey: lastPositive, probabilities, consumedDraw: true }; + throw Error("invalid selection weights"); +} diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts new file mode 100644 index 0000000..91b4346 --- /dev/null +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -0,0 +1,767 @@ +import { expect, test } from "bun:test"; +import { + type ArbitrationPolicy, + assessmentInputDigest, + buildCanonicalOption, + type CanonicalOption, + canonicalOptionKey, + EFFECT_OWNERS, + type JudgmentSnapshotRef, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + normalizeOptionArgs, + type OptionAssessment, + PERSONAL_CATALOG_ID, + PERSONAL_OPTION_KINDS, + PERSONAL_POLICY_V1, + type PersonalOptionKind, + type PersonalPrecondition, + parseAssessmentSet, + parseCanonicalOption, + parseResolutionRecord, + parseSelectionSpec, + rankMass, + resolvePersonalRound, + type SelectionSpec, + SITUATIONS, + type Situation, + type Stance, + sampleSelection, + snapshotDigest, +} from "../src/agents/index.ts"; + +const actor = { agentId: "agent", scopeId: "scope" }; +const preconditions: PersonalPrecondition[] = [ + { + kind: "intention.adopt", + intentionKind: "user_commitment", + sourceRef: "request", + }, + { kind: "intention.activate", intentionId: "intention" }, + { + kind: "intention.suspend", + intentionId: "intention", + acceptanceSourceRef: "request", + reason: "pause", + }, + { + kind: "intention.resume", + intentionId: "intention", + acceptanceSourceRef: "request", + reason: "resume", + }, + { + kind: "intention.cancel", + intentionId: "intention", + acceptanceSourceRef: "request", + authorityRef: "authority", + userConfirmationRef: null, + }, + { + kind: "intention.complete", + intentionId: "intention", + outcomeRef: "outcome", + }, + { + kind: "task.start", + authorityRef: "authority", + taskText: "fixture task", + intentionId: "intention", + }, + { kind: "task.send", taskId: "task", ownerId: "owner", revision: 0 }, + { kind: "task.interrupt", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "task.handover", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "inquire", authorityRef: "authority" }, + { kind: "defer", resumeCondition: "new input" }, + { kind: "noop", reason: "nothing needed" }, +]; +function option( + targetId: string, + kind: PersonalOptionKind = "noop", +): CanonicalOption { + const precondition = preconditions.find((p) => p.kind === kind); + if (!precondition) throw Error("missing fixture precondition"); + return buildCanonicalOption({ + kind, + actor, + targetId, + args: {}, + preconditions: precondition, + }); +} +function snapshot(situation: Situation): JudgmentSnapshotRef { + const ref = (moduleKind: ModuleKind) => ({ + objectiveId: `objective-${moduleKind}`, + revision: 1, + digest: judgmentDigest({ moduleKind, objective: "fixture objective" }), + }); + return { + schemaVersion: 1, + roundId: "round", + ...actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: { + clotho: ref("clotho"), + lachesis: ref("lachesis"), + atropos: ref("atropos"), + }, + observationRef: null, + frozenNeuralRef: null, + situation, + clockId: "clock", + sequence: 0, + bindingGeneration: 0, + }; +} +function opinion( + stance: Stance, + severity: OptionAssessment["severity"] = null, +): Partial { + return { + stance, + severity: stance === "oppose" ? (severity ?? "preference") : null, + unavailableReason: stance === "unavailable" ? "missing observation" : null, + }; +} +function fixture( + situation: Situation = "user_request", + options = [option("a"), option("b"), option("c")], + assess: ( + moduleKind: ModuleKind, + option: CanonicalOption, + ) => Partial | null = () => opinion("accept"), +): Parameters[0] { + const snap = snapshot(situation); + const digest = snapshotDigest(snap); + const set = parseAssessmentSet({ + schemaVersion: 1, + roundId: snap.roundId, + snapshotDigest: digest, + assessments: MODULE_KINDS.map((moduleKind) => { + const objectiveRef = snap.objectiveProfileRefs[moduleKind]; + const objectiveAssessments = options.flatMap((o) => { + const change = assess(moduleKind, o); + return change === null + ? [] + : [ + { + optionKey: o.optionKey, + stance: "accept", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + ...change, + }, + ]; + }); + return { + schemaVersion: 1, + moduleKind, + snapshotId: snap.roundId, + snapshotDigest: digest, + inputDigest: assessmentInputDigest({ + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + }), + objectiveRef, + mechanismRevision: 1, + completeText: "fixture assessment", + evidenceRefs: [], + proposedOptionKeys: [], + objectiveAssessments, + recommendedOptionKeys: objectiveAssessments + .filter((o) => o.stance === "prefer") + .map((o) => o.optionKey), + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }; + }), + }); + return { + policy: PERSONAL_POLICY_V1, + snapshot: snap, + options, + set, + eligibility: options.map((o) => ({ + optionKey: o.optionKey, + eligible: true, + reason: null, + })), + bias: {}, + }; +} +function resolve(input: Parameters[0]) { + const before = structuredClone(input); + const result = resolvePersonalRound(input); + expect(input).toEqual(before); + expect(parseResolutionRecord(result.resolution)).toEqual(result.resolution); + if (result.spec) { + expect(parseSelectionSpec(result.spec)).toEqual(result.spec); + expect( + Math.abs(result.spec.candidates.reduce((sum, c) => sum + c.p0, 0) - 1), + ).toBeLessThanOrEqual(1e-12); + expect(result.spec.resolutionDigest).toBe( + judgmentDigest(result.resolution), + ); + expect(result.spec.assessmentSetDigest).toBe(judgmentDigest(input.set)); + expect(result.spec.snapshotDigest).toBe(snapshotDigest(input.snapshot)); + expect(result.spec.objectiveProfileRefs).toEqual( + input.snapshot.objectiveProfileRefs, + ); + } + return result; +} +function requireSpec( + result: ReturnType, +): SelectionSpec { + if (!result.spec) throw Error("expected resolved fixture"); + return result.spec; +} +function samplingSpec( + candidates: SelectionSpec["candidates"], + lambda = 1, +): SelectionSpec { + const base = requireSpec(resolve(fixture())); + const result = { + ...base, + candidates, + lambda, + eligibleDigest: judgmentDigest( + candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + }; + return parseSelectionSpec({ + ...result, + specDigest: judgmentDigest({ ...result, specDigest: undefined }), + }); +} + +test("catalog vocabulary, all per-kind fields and effect ownership", () => { + expect(PERSONAL_CATALOG_ID).toBe("personal.v1"); + expect([...PERSONAL_OPTION_KINDS]).toEqual(preconditions.map((p) => p.kind)); + expect(EFFECT_OWNERS).toEqual([ + "judgment_store", + "task_manager", + "host", + "none", + ]); + for (const p of preconditions) { + const o = option("target", p.kind); + expect(o.effect).toEqual({ + owner: p.kind.startsWith("intention.") + ? "judgment_store" + : p.kind.startsWith("task.") + ? "task_manager" + : p.kind === "inquire" + ? "host" + : "none", + scope: actor.scopeId, + }); + expect(parseCanonicalOption(o)).toEqual(o); + for (const key of Object.keys(p)) { + const missing = Object.fromEntries( + Object.entries(p).filter(([field]) => field !== key), + ); + expect(() => + parseCanonicalOption({ ...o, preconditions: missing }), + ).toThrow(); + } + } +}); + +test("(1) canonical keys normalize case, target, sorted args, NFC, whitespace and empty values", () => { + const base = option("task", "task.start"); + const a = { + ...base, + kind: "Task.Start" as PersonalOptionKind, + targetId: " task ", + args: { b: "x", a: " y z ", empty: " \t " }, + }; + const b = { ...base, args: { a: "y z", b: "x" } }; + expect(canonicalOptionKey(a)).toBe(canonicalOptionKey(b)); + expect(canonicalOptionKey({ ...b, targetId: "other" })).not.toBe( + canonicalOptionKey(b), + ); + expect(canonicalOptionKey({ ...b, args: { a: "e\u0301" } })).toBe( + canonicalOptionKey({ ...b, args: { a: "\u00e9" } }), + ); + expect(normalizeOptionArgs(a.args)).toEqual({ a: "y z", b: "x" }); + expect(Object.keys(normalizeOptionArgs(a.args))).toEqual(["a", "b"]); + const built = buildCanonicalOption(a); + expect(built.kind).toBe("task.start"); + expect(built.targetId).toBe("task"); + expect(built.args).toEqual(b.args); + expect(built.optionKey).toBe(canonicalOptionKey(b)); + expect(canonicalOptionKey({ ...base, targetId: null })).toContain(":-:"); +}); + +test("(2) golden option parses byte-identically and rejects invalid boundary data", () => { + const golden = { + schemaVersion: 1, + catalogId: "personal.v1", + kind: "noop", + actor, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "nothing needed" }, + effect: { owner: "none", scope: "scope" }, + optionKey: + "personal.v1:noop:-:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + }; + expect(JSON.stringify(parseCanonicalOption(golden))).toBe( + JSON.stringify(golden), + ); + for (const schemaVersion of [0, 2]) + expect(() => + parseCanonicalOption({ ...golden, schemaVersion, extra: true }), + ).toThrow(/Unsupported .* schema version/); + expect(() => + parseCanonicalOption({ ...golden, kind: "memory.store" }), + ).toThrow("unknown personal.v1 option kind"); + for (const change of [ + { catalogId: "other" }, + { effect: { owner: "task_manager", scope: "scope" } }, + { optionKey: "tampered" }, + { preconditions: { kind: "defer", resumeCondition: "input" } }, + { extra: true }, + { actor: { ...actor, extra: true } }, + { effect: { ...golden.effect, extra: true } }, + { preconditions: { ...golden.preconditions, extra: true } }, + { actor: { ...actor, agentId: "x".repeat(161) } }, + { targetId: " " }, + { args: { key: 1 } }, + { args: { key: "x".repeat(1001) } }, + { args: { key: "x\u0000y" } }, + { preconditions: { kind: "noop", reason: " " } }, + ]) + expect(() => parseCanonicalOption({ ...golden, ...change })).toThrow(); + for (const value of [null, [], "option"]) + expect(() => parseCanonicalOption(value)).toThrow(); + for (const revision of [-1, 0.5, Infinity]) + expect(() => + parseCanonicalOption({ + ...option("task", "task.send"), + preconditions: { + kind: "task.send", + taskId: "task", + ownerId: "owner", + revision, + }, + }), + ).toThrow(); +}); + +const orders: Record = { + user_request: ["atropos", "clotho", "lachesis"], + autonomous: ["lachesis", "clotho", "atropos"], + transition: ["clotho", "atropos", "lachesis"], +}; +for (const situation of SITUATIONS) { + test(`(3,6,8) ${situation} uses declared order, lambda and reproducible digests`, () => { + const options = MODULE_KINDS.map((m) => option(m)); + const input = fixture(situation, options, (m, o) => + opinion(m === o.targetId ? "prefer" : "accept"), + ); + const result = resolve(input); + expect(result.resolution.order).toEqual(orders[situation]); + expect(result.resolution.ranking).toEqual( + orders[situation].map((m, i) => ({ + optionKey: option(m).optionKey, + rank: i + 1, + })), + ); + expect(result.resolution.policyRevision).toBe(1); + expect(result.resolution.conflicts).toHaveLength(3); + for (const m of MODULE_KINDS) + expect(result.resolution.recommendations[m]).toEqual([ + option(m).optionKey, + ]); + expect(result.resolution.conceded).toHaveLength(2); + expect(result.resolution.conceded).not.toContainEqual({ + moduleKind: orders[situation][0], + optionKey: option(orders[situation][0]).optionKey, + }); + const spec = requireSpec(result); + expect(spec.lambda).toBe(situation === "user_request" ? 0 : 1); + expect(spec.candidates.every((c) => c.b === null)).toBe(true); + expect(resolve(structuredClone(input)).spec?.specDigest).toBe( + spec.specDigest, + ); + const biased = resolve({ + ...input, + bias: Object.fromEntries(options.map((o) => [o.optionKey, 100])), + }); + expect(biased.spec?.candidates.map((c) => c.p0)).toEqual( + spec.candidates.map((c) => c.p0), + ); + expect(biased.spec?.candidates.every((c) => c.b === 100)).toBe(true); + }); +} + +test("(4) ties share dense rank and split rank mass, rather than doubling activity mass", () => { + expect(rankMass([1, 2, 3], 0.5)).toEqual([4 / 7, 2 / 7, 1 / 7]); + expect(rankMass([1, 1, 2], 0.5)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(rankMass([2, 1, 1], 0.5)).toEqual([1 / 3, 1 / 3, 1 / 3]); + expect(rankMass([], 0.5)).toEqual([]); + const result = resolve( + fixture("user_request", undefined, (_, o) => + opinion(o.targetId === "c" ? "accept" : "prefer"), + ), + ); + expect(result.resolution.ranking).toEqual([ + { optionKey: option("a").optionKey, rank: 1 }, + { optionKey: option("b").optionKey, rank: 1 }, + { optionKey: option("c").optionKey, rank: 2 }, + ]); + expect(requireSpec(result).candidates.map((c) => c.p0)).toEqual([ + 1 / 3, + 1 / 3, + 1 / 3, + ]); +}); + +test("(5) only original-acceptance suspend/cancel escape commitment protection", () => { + const options = [ + "task.start", + "intention.cancel", + "intention.suspend", + "intention.resume", + ].map((kind) => option(kind, kind as PersonalOptionKind)); + const result = resolve( + fixture("user_request", options, (m) => + m === "atropos" + ? opinion("oppose", "commitment_breach") + : opinion("accept"), + ), + ); + for (const o of options) { + const exempt = + o.kind === "intention.cancel" || o.kind === "intention.suspend"; + expect( + requireSpec(result).candidates.find((c) => c.optionKey === o.optionKey) + ?.p0, + ).toBe(exempt ? 0.5 : 0); + if (!exempt) + expect(result.resolution.excluded).toContainEqual({ + optionKey: o.optionKey, + stage: "commitment_protection", + byModule: "atropos", + reason: expect.any(String), + }); + } + const cancel = option("cancel", "intention.cancel"); + const input = fixture("user_request", [cancel]); + input.options = [ + { + ...cancel, + preconditions: { + kind: "intention.cancel", + intentionId: "intention", + authorityRef: "authority", + acceptanceSourceRef: "", + userConfirmationRef: null, + }, + }, + ]; + expect(() => resolvePersonalRound(input)).toThrow(); +}); + +test("(5) preference never excludes, and only clotho drives infeasible exclusion", () => { + for (const moduleKind of MODULE_KINDS) { + for (const severity of ["preference", "infeasible"] as const) { + const result = resolve( + fixture("autonomous", undefined, (m, o) => + m === moduleKind && o.targetId === "a" + ? opinion("oppose", severity) + : opinion("accept"), + ), + ); + const p0 = requireSpec(result).candidates.find( + (c) => c.optionKey === option("a").optionKey, + )?.p0; + if (moduleKind === "clotho" && severity === "infeasible") { + expect(p0).toBe(0); + expect(result.resolution.excluded).toContainEqual({ + optionKey: option("a").optionKey, + stage: "infeasible", + byModule: "clotho", + reason: expect.any(String), + }); + } else expect(p0).toBeGreaterThan(0); + } + } +}); + +test("(7) no host-eligible or no remaining candidates defers, never supplies uniform mass", () => { + const input = fixture(); + for (const eligibility of [ + [], + input.eligibility.map((row) => ({ + ...row, + eligible: false, + reason: "not authorized", + })), + ]) { + const result = resolve({ ...input, eligibility }); + expect(result.resolution.status).toBe("deferred"); + expect(result.resolution.holdReason).toBe("no eligible candidate"); + expect(result.resolution.ranking).toEqual([]); + expect( + result.resolution.excluded.every((e) => e.stage === "host_eligibility"), + ).toBe(true); + expect(result.spec).toBeNull(); + } + const excluded = resolve( + fixture("transition", undefined, (m) => + m === "clotho" ? opinion("oppose", "infeasible") : opinion("accept"), + ), + ); + expect(excluded.resolution.status).toBe("deferred"); + expect(excluded.spec).toBeNull(); + expect(resolve(fixture("user_request", [])).resolution.status).toBe( + "deferred", + ); + expect(() => + resolvePersonalRound({ + ...input, + eligibility: [ + ...input.eligibility, + { optionKey: "unknown", eligible: true, reason: null }, + ], + }), + ).toThrow(); + const partial = resolve({ + ...input, + eligibility: input.eligibility.slice(1), + }); + expect( + requireSpec(partial).candidates.find( + (c) => c.optionKey === option("a").optionKey, + )?.p0, + ).toBe(0); +}); + +test("(7) eligible completeness precedes protection; ineligible candidates need no assessments", () => { + const input = fixture("user_request", undefined, (m, o) => + m === "lachesis" && o.targetId === "a" + ? null + : m === "atropos" + ? opinion("oppose", "commitment_breach") + : opinion("accept"), + ); + const result = resolve(input); + expect(result.resolution.status).toBe("held"); + expect(result.resolution.holdReason).toBe( + `missing assessment lachesis for ${option("a").optionKey}`, + ); + expect(result.spec).toBeNull(); + const without = resolve({ + ...input, + eligibility: input.eligibility.filter( + (e) => e.optionKey !== option("a").optionKey, + ), + }); + expect(without.resolution.status).toBe("deferred"); + const mismatched = { + ...input, + snapshot: { ...input.snapshot, workingRevision: 1 }, + }; + expect(() => resolvePersonalRound(mismatched)).toThrow(/snapshot/); + expect(() => + resolvePersonalRound({ + ...input, + set: { ...input.set, assessments: input.set.assessments.slice(1) }, + }), + ).toThrow(); +}); + +for (const situation of SITUATIONS) { + test(`(7b) ${situation}: unavailable drops a module transitively for every candidate`, () => { + const [m1, m2] = orders[situation]; + const input = fixture(situation, undefined, (m, o) => { + if (m === m1) + return opinion( + o.targetId === "a" + ? "prefer" + : o.targetId === "b" + ? "accept" + : "unavailable", + ); + if (m === m2) + return opinion( + o.targetId === "a" + ? "oppose" + : o.targetId === "b" + ? "prefer" + : "accept", + ); + return opinion("accept"); + }); + const result = resolve(input); + expect(result.resolution.status).toBe("resolved"); + expect(result.resolution.ranking).toEqual( + ["b", "c", "a"].map((id, i) => ({ + optionKey: option(id).optionKey, + rank: i + 1, + })), + ); + expect(result.resolution.abstentions).toContainEqual({ + optionKey: option("c").optionKey, + moduleKind: m1, + reason: "missing observation", + }); + expect( + resolve({ ...input, options: [...input.options].reverse() }), + ).toEqual(result); + }); +} + +test("(7b) all modules unavailable for ordering holds and records every abstention", () => { + const result = resolve( + fixture( + "transition", + MODULE_KINDS.map((m) => option(m)), + (m, o) => opinion(m === o.targetId ? "unavailable" : "accept"), + ), + ); + expect(result.resolution.status).toBe("held"); + expect(result.resolution.holdReason).toBe( + "all modules unavailable for ordering", + ); + expect(result.resolution.abstentions).toHaveLength(3); + expect(result.spec).toBeNull(); +}); + +test("unavailable on an excluded option is recorded but does not drop ordering modules", () => { + const input = fixture("user_request", undefined, (m, o) => + o.targetId === "c" + ? opinion("unavailable") + : opinion(m === "atropos" && o.targetId === "a" ? "prefer" : "accept"), + ); + const result = resolve({ + ...input, + eligibility: input.eligibility.filter( + (e) => e.optionKey !== option("c").optionKey, + ), + }); + expect(result.resolution.status).toBe("resolved"); + expect(result.resolution.abstentions).toHaveLength(3); + expect(result.resolution.ranking.at(0)).toEqual({ + optionKey: option("a").optionKey, + rank: 1, + }); +}); + +test("(9) normalized duplicate options cannot double activity mass", () => { + const o = option("a"); + const input = fixture("user_request", [o]); + const variant = buildCanonicalOption({ + ...o, + targetId: " a ", + args: { omitted: " " }, + }); + expect(() => + resolvePersonalRound({ ...input, options: [o, variant] }), + ).toThrow("duplicate option key"); +}); + +test("(10) single positive candidate consumes no draw, including NaN", () => { + const spec = samplingSpec([ + { optionKey: "a", p0: 0, b: 1000 }, + { optionKey: "b", p0: 1, b: null }, + ]); + expect(sampleSelection(spec, NaN)).toEqual({ + optionKey: "b", + probabilities: [ + { optionKey: "a", p: 0 }, + { optionKey: "b", p: 1 }, + ], + consumedDraw: false, + }); +}); + +test("(10) sampling uses normalized log weights and strict cumulative boundaries", () => { + const spec = samplingSpec([ + { optionKey: "a", p0: 0.5, b: Math.log(3) }, + { optionKey: "b", p0: 0.5, b: 0 }, + ]); + const sample = sampleSelection(spec, 0); + expect(sample.optionKey).toBe("a"); + expect(sample.consumedDraw).toBe(true); + expect(sample.probabilities[0]?.p).toBeCloseTo(0.75, 12); + expect(sample.probabilities[1]?.p).toBeCloseTo(0.25, 12); + expect(sampleSelection(spec, 0.9).optionKey).toBe("b"); + const boundary = sample.probabilities[0]?.p; + if (boundary === undefined) throw Error("missing probability"); + expect(sampleSelection(spec, boundary).optionKey).toBe("b"); + for (const u of [1, -0.1, NaN, Infinity]) + expect(() => sampleSelection(spec, u)).toThrow("invalid selection draw"); + const unmodulated = sampleSelection(samplingSpec(spec.candidates, 0), 0.5); + expect(unmodulated.probabilities.map((c) => c.p)).toEqual([0.5, 0.5]); + expect(unmodulated.optionKey).toBe("b"); +}); + +test("(10) asymmetric and extreme log-space weights remain finite and normalized", () => { + const spec = samplingSpec([ + { optionKey: "a", p0: 0.8, b: 0.5 }, + { optionKey: "b", p0: 0.2, b: -0.5 }, + ]); + const { probabilities } = sampleSelection(spec, 0); + const [a, b] = probabilities; + if (!a || !b) throw Error("missing probabilities"); + expect(Math.abs(a.p + b.p - 1)).toBeLessThanOrEqual(1e-12); + expect(Math.abs(a.p / b.p - 4 * Math.E)).toBeLessThanOrEqual(1e-9); + for (const candidates of [ + [ + { optionKey: "a", p0: 1e-300, b: null }, + { optionKey: "b", p0: 1, b: null }, + ], + [ + { optionKey: "a", p0: 0.5, b: 1000 }, + { optionKey: "b", p0: 0.5, b: 999 }, + ], + [ + { optionKey: "a", p0: 0.5, b: -1000 }, + { optionKey: "b", p0: 0.5, b: -999 }, + ], + ]) { + const result = sampleSelection( + samplingSpec(candidates), + 1 - Number.EPSILON, + ); + expect(result.probabilities.every((c) => Number.isFinite(c.p))).toBe(true); + expect( + Math.abs(result.probabilities.reduce((sum, c) => sum + c.p, 0) - 1), + ).toBeLessThanOrEqual(1e-12); + } +}); + +test("policy public type exposes exactly the revision-one declaration", () => { + const policy: ArbitrationPolicy = PERSONAL_POLICY_V1; + expect(policy).toEqual({ + policyId: "personal.v1", + revision: 1, + ratio: 0.5, + orders, + lambda: { user_request: 0, autonomous: 1, transition: 1 }, + stanceOrder: ["prefer", "accept", "oppose"], + }); +}); From 71dcf58904972dcedfceecdfdffb2013fe4880f9 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:08:05 +0900 Subject: [PATCH 06/47] fix(core): preserve catalog rejection precedence and clear literal-key diagnostics --- .../lina-core/src/agents/judgment-catalog.ts | 58 ++++++++++++------- .../lina-core/test/judgment-policy.test.ts | 22 +++++++ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-catalog.ts b/packages/lina-core/src/agents/judgment-catalog.ts index 6a03daa..0fb2ca9 100644 --- a/packages/lina-core/src/agents/judgment-catalog.ts +++ b/packages/lina-core/src/agents/judgment-catalog.ts @@ -154,8 +154,13 @@ function parsePrecondition( value: unknown, kind: PersonalOptionKind, ): PersonalPrecondition { - const row = object(value, "personal precondition"); - if (row["kind"] !== kind) throw Error("precondition kind mismatch"); + const row: Record & { + kind?: unknown; + intentionKind?: unknown; + userConfirmationRef?: unknown; + revision?: unknown; + } = object(value, "personal precondition"); + if (row.kind !== kind) throw Error("precondition kind mismatch"); const check = (...keys: string[]) => fields(row, ["kind", ...keys], "personal precondition"); const id = (key: string) => boundedId(row[key], key); @@ -163,7 +168,7 @@ function parsePrecondition( switch (kind) { case "intention.adopt": { check("intentionKind", "sourceRef"); - const intentionKind = row["intentionKind"]; + const intentionKind = row.intentionKind; if ( intentionKind !== "user_commitment" && intentionKind !== "autonomous_goal" @@ -196,9 +201,7 @@ function parsePrecondition( acceptanceSourceRef: id("acceptanceSourceRef"), authorityRef: id("authorityRef"), userConfirmationRef: - row["userConfirmationRef"] === null - ? null - : id("userConfirmationRef"), + row.userConfirmationRef === null ? null : id("userConfirmationRef"), }; case "intention.complete": check("intentionId", "outcomeRef"); @@ -219,7 +222,7 @@ function parsePrecondition( case "task.interrupt": case "task.handover": { check("taskId", "ownerId", "revision"); - const revision = row["revision"]; + const revision = row.revision; if ( typeof revision !== "number" || !Number.isSafeInteger(revision) || @@ -241,8 +244,12 @@ function parsePrecondition( } export function parseCanonicalOption(value: unknown): CanonicalOption { - const row = object(value, "canonical option"); - if (row["schemaVersion"] !== 1) + const row: Record & + Partial> = object( + value, + "canonical option", + ); + if (row.schemaVersion !== 1) throw Error("Unsupported canonical option schema version"); fields( row, @@ -259,33 +266,42 @@ export function parseCanonicalOption(value: unknown): CanonicalOption { ], "canonical option", ); - if (row["catalogId"] !== PERSONAL_CATALOG_ID) + if (row.catalogId !== PERSONAL_CATALOG_ID) throw Error("unsupported option catalog"); - const kind = optionKind(row["kind"]); - const actor = object(row["actor"], "option actor"); + const kind = optionKind(row.kind); + const preconditions = parsePrecondition(row.preconditions, kind); + const actor: Record & + Partial> = object( + row.actor, + "option actor", + ); fields(actor, ["agentId", "scopeId"], "option actor"); - const effect = object(row["effect"], "option effect"); + const effect: Record & + Partial> = object( + row.effect, + "option effect", + ); fields(effect, ["owner", "scope"], "option effect"); - if (effect["owner"] !== owners[kind]) + if (effect.owner !== owners[kind]) throw Error("option effect owner mismatch"); - const args = object(row["args"], "option args"); + const args = object(row.args, "option args"); // normalizeOptionArgs validates each value at this unknown-data boundary. const result: CanonicalOption = { schemaVersion: 1, catalogId: PERSONAL_CATALOG_ID, kind, actor: { - agentId: boundedId(actor["agentId"], "agent id"), - scopeId: boundedId(actor["scopeId"], "scope id"), + agentId: boundedId(actor.agentId, "agent id"), + scopeId: boundedId(actor.scopeId, "scope id"), }, - targetId: target(row["targetId"]), + targetId: target(row.targetId), args: normalizeOptionArgs(args as Record), - preconditions: parsePrecondition(row["preconditions"], kind), + preconditions, effect: { owner: owners[kind], - scope: boundedId(effect["scope"], "effect scope"), + scope: boundedId(effect.scope, "effect scope"), }, - optionKey: boundedId(row["optionKey"], "option key"), + optionKey: boundedId(row.optionKey, "option key"), }; if (result.optionKey !== canonicalOptionKey(result)) throw Error("canonical option key mismatch"); diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts index 91b4346..1a1157b 100644 --- a/packages/lina-core/test/judgment-policy.test.ts +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -369,6 +369,28 @@ test("(2) golden option parses byte-identically and rejects invalid boundary dat ).toThrow(); }); +test("precondition kind mismatch precedes effect owner mismatch", () => { + const o = buildCanonicalOption({ + kind: "task.start", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: "x", + args: {}, + preconditions: { + kind: "task.start", + authorityRef: "authority", + taskText: "work", + intentionId: "intention", + }, + }); + expect(() => + parseCanonicalOption({ + ...o, + preconditions: { kind: "noop", reason: "x" }, + effect: { owner: "host", scope: "scope" }, + }), + ).toThrow(/^precondition kind mismatch$/); +}); + const orders: Record = { user_request: ["atropos", "clotho", "lachesis"], autonomous: ["lachesis", "clotho", "atropos"], From 33d888b4c4c4a26bef2faa8d49f58dce37f23504 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:34:27 +0900 Subject: [PATCH 07/47] style(core): reformat judgment-validation.ts after parseObjectiveProfileRef export (fixup of 4335e94) --- .../src/agents/judgment-validation.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 89b577e..1db83cf 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -113,8 +113,8 @@ function uniqueSorted( throw Error(`duplicate ${label}`); return sort ? [...items].sort((a, b) => - key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, - ) + key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, + ) : items; } @@ -163,13 +163,13 @@ export function judgmentDigest(value: unknown): string { ? item.map(canonical) : item !== null && typeof item === "object" ? Object.fromEntries( - Object.keys(item) - .sort() - .map((key) => [ - key, - canonical((item as Record)[key]), - ]), - ) + Object.keys(item) + .sort() + .map((key) => [ + key, + canonical((item as Record)[key]), + ]), + ) : item; return createHash("sha256") .update(JSON.stringify(canonical(value))) @@ -569,10 +569,10 @@ export function parseResolutionRecord(value: unknown): ResolutionRecord { item["byModule"] === null ? null : enumeration( - item["byModule"], - MODULE_KINDS, - "exclusion module", - ), + item["byModule"], + MODULE_KINDS, + "exclusion module", + ), reason: boundedText(item["reason"], "exclusion reason"), }; }, From fdcb794cff9f57503714791e540030fbc35fa124 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:35:01 +0900 Subject: [PATCH 08/47] feat(core): add JudgmentStore standalone SQLite persistence for rounds, assessments and intentions (F1-A2) --- packages/lina-core/src/agents/index.ts | 5 + .../lina-core/src/agents/judgment-schema.ts | 62 ++ .../lina-core/src/agents/judgment-store.ts | 519 +++++++++++++ .../lina-core/test/judgment-store.test.ts | 686 ++++++++++++++++++ 4 files changed, 1272 insertions(+) create mode 100644 packages/lina-core/src/agents/judgment-schema.ts create mode 100644 packages/lina-core/src/agents/judgment-store.ts create mode 100644 packages/lina-core/test/judgment-store.test.ts diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 8b3edba..01585ce 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -84,3 +84,8 @@ export { export * from "./judgment-catalog.ts"; export * from "./judgment-policy.ts"; + +// Judgment persistence. + +export { JUDGMENT_SCHEMA_VERSION } from "./judgment-schema.ts"; +export { JudgmentStore } from "./judgment-store.ts"; diff --git a/packages/lina-core/src/agents/judgment-schema.ts b/packages/lina-core/src/agents/judgment-schema.ts new file mode 100644 index 0000000..e29b4e9 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-schema.ts @@ -0,0 +1,62 @@ +import type { DatabaseSync } from "node:sqlite"; + +export const JUDGMENT_SCHEMA_VERSION = 1; + +const SCHEMA = ` +CREATE TABLE judgment_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; +CREATE TABLE objective_profiles (objective_id TEXT NOT NULL, revision INTEGER NOT NULL, module_kind TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(objective_id, revision)) STRICT; +CREATE TABLE objective_profile_active (agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, module_kind TEXT NOT NULL, objective_id TEXT NOT NULL, revision INTEGER NOT NULL, activation_revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(agent_id, scope_id, module_kind), FOREIGN KEY(objective_id, revision) REFERENCES objective_profiles(objective_id, revision)) STRICT; +CREATE TABLE rounds (round_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, situation TEXT NOT NULL, sequence INTEGER NOT NULL, snapshot TEXT NOT NULL, snapshot_digest TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','resolved','deferred','held')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(agent_id, scope_id, sequence)) STRICT; +CREATE TABLE assessments (round_id TEXT NOT NULL REFERENCES rounds(round_id), module_kind TEXT NOT NULL, snapshot_digest TEXT NOT NULL, input_digest TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(round_id, module_kind)) STRICT; +CREATE TABLE resolution_records (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; +CREATE TABLE selection_specs (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), spec_digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; +CREATE TABLE intention_records (intention_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, revision INTEGER NOT NULL, status TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL) STRICT; +CREATE TABLE intention_transitions (intention_id TEXT NOT NULL REFERENCES intention_records(intention_id), revision INTEGER NOT NULL, from_status TEXT NOT NULL, to_status TEXT NOT NULL, reason TEXT NOT NULL, evidence_ref TEXT, at TEXT NOT NULL, PRIMARY KEY(intention_id, revision)) STRICT; +`; + +function verify(db: DatabaseSync): void { + const expected = SCHEMA.split(";") + .map((sql) => sql.trim()) + .filter(Boolean) + .sort(); + const actual = db + .prepare("SELECT sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'") + .all() + .map(({ sql }) => sql) + .sort(); + if ( + actual.length !== expected.length || + actual.some((sql, index) => sql !== expected[index]) + ) + throw Error("unknown judgment store schema"); +} + +export function initializeJudgmentSchema( + db: DatabaseSync, + fresh: boolean, +): void { + const { user_version: version } = + db.prepare("PRAGMA user_version").get() ?? {}; + const tables = db + .prepare("SELECT name FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'") + .all(); + if (version === 0 && tables.length === 0) { + if (!fresh) throw Error("unknown judgment store schema"); + db.exec(SCHEMA); + const insert = db.prepare( + "INSERT INTO judgment_meta(key, value) VALUES (?, ?)", + ); + insert.run("schema_version", String(JUDGMENT_SCHEMA_VERSION)); + insert.run("store", "judgment"); + db.exec(`PRAGMA user_version = ${JUDGMENT_SCHEMA_VERSION}`); + return; + } + if (version !== JUDGMENT_SCHEMA_VERSION) + throw Error("unknown judgment store schema"); + verify(db); + const meta = db.prepare("SELECT value FROM judgment_meta WHERE key = ?"); + const { value: store } = meta.get("store") ?? {}; + const { value: schemaVersion } = meta.get("schema_version") ?? {}; + if (store !== "judgment" || schemaVersion !== "1") + throw Error("unknown judgment store schema"); +} diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts new file mode 100644 index 0000000..2f1374b --- /dev/null +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -0,0 +1,519 @@ +import type { DatabaseSync } from "node:sqlite"; +import { openCheckedDatabase } from "../session-binding.ts"; +import { + type Assessment, + type AssessmentSet, + INTENTION_STATUSES, + type IntentionRecord, + type IntentionStatus, + type IntentionTransition, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + type ObjectiveProfileRef, + type ResolutionRecord, + type RoundStatus, + type SelectionSpec, +} from "./judgment.ts"; +import { initializeJudgmentSchema } from "./judgment-schema.ts"; +import { + intentionDigest, + judgmentDigest, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseIntentionTransition, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseObjectiveProfileRef, + parseResolutionRecord, + parseSelectionSpec, + snapshotDigest, + transitionIntention, +} from "./judgment-validation.ts"; +import { boundedId } from "./validation.ts"; + +function sortedCanonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortedCanonical); + if (value !== null && typeof value === "object") + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [ + key, + sortedCanonical((value as Record)[key]), + ]), + ); + return value; +} +function body(value: unknown): string { + return JSON.stringify(sortedCanonical(value)); +} +function revision(value: number, minimum = 0): number { + if (!Number.isSafeInteger(value) || value < minimum) + throw Error("invalid judgment revision"); + return value; +} + +/** The Host owns the single writer; this ledger is independent of session lifetimes. */ +export class JudgmentStore { + private readonly db: DatabaseSync; + private readonly now: () => number; + private closed = false; + + constructor(path: string, options: { now?: () => number } = {}) { + const opened = openCheckedDatabase(path); + this.db = opened.db; + this.now = options.now ?? Date.now; + try { + this.db.exec("PRAGMA foreign_keys = ON; BEGIN IMMEDIATE"); + initializeJudgmentSchema(this.db, opened.fresh); + this.db.exec( + "COMMIT; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL", + ); + } catch (error) { + if (this.db.isTransaction) this.db.exec("ROLLBACK"); + this.db.close(); + throw error; + } + } + + putObjectiveProfile(profile: ObjectiveProfile): ObjectiveProfileRef { + this.assertOpen(); + const parsed = parseObjectiveProfile(profile); + const ref = { + objectiveId: parsed.objectiveId, + revision: parsed.revision, + digest: judgmentDigest(parsed), + }; + return this.transaction(() => { + const existing = this.getObjectiveProfile(ref.objectiveId, ref.revision); + if (existing) { + if (judgmentDigest(existing) !== ref.digest) + throw Error("objective profile revision already exists"); + return ref; + } + this.db + .prepare( + "INSERT INTO objective_profiles(objective_id, revision, module_kind, digest, body, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ) + .run( + ref.objectiveId, + ref.revision, + parsed.moduleKind, + ref.digest, + body(parsed), + this.now(), + ); + return ref; + }); + } + + getObjectiveProfile( + objectiveId: string, + profileRevision: number, + ): ObjectiveProfile | null { + this.assertOpen(); + const row = this.db + .prepare( + "SELECT body FROM objective_profiles WHERE objective_id = ? AND revision = ?", + ) + .get( + boundedId(objectiveId, "objective id"), + revision(profileRevision, 1), + ); + const { body: json } = row ?? {}; + return row ? parseObjectiveProfile(JSON.parse(String(json))) : null; + } + + activateObjectiveProfile( + agentId: string, + scopeId: string, + ref: ObjectiveProfileRef, + ): { activationRevision: number } { + this.assertOpen(); + const agent = boundedId(agentId, "agent id"); + const scope = boundedId(scopeId, "scope id"); + const parsed = parseObjectiveProfileRef(ref); + return this.transaction(() => { + const profile = this.getObjectiveProfile( + parsed.objectiveId, + parsed.revision, + ); + if (!profile || judgmentDigest(profile) !== parsed.digest) + throw Error("objective profile ref mismatch"); + const existing = this.db + .prepare( + "SELECT objective_id, revision, activation_revision FROM objective_profile_active WHERE agent_id = ? AND scope_id = ? AND module_kind = ?", + ) + .get(agent, scope, profile.moduleKind); + const { + objective_id: objectiveId, + revision: activeRevision, + activation_revision: previous, + } = existing ?? {}; + if ( + objectiveId === parsed.objectiveId && + activeRevision === parsed.revision + ) + return { activationRevision: Number(previous) }; + const activationRevision = revision(Number(previous ?? 0) + 1, 1); + this.db + .prepare(`INSERT INTO objective_profile_active(agent_id, scope_id, module_kind, objective_id, revision, activation_revision, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(agent_id, scope_id, module_kind) DO UPDATE SET + objective_id = excluded.objective_id, revision = excluded.revision, activation_revision = excluded.activation_revision, updated_at = excluded.updated_at`) + .run( + agent, + scope, + profile.moduleKind, + parsed.objectiveId, + parsed.revision, + activationRevision, + this.now(), + ); + return { activationRevision }; + }); + } + + activeObjectiveProfiles( + agentId: string, + scopeId: string, + ): Record | null { + this.assertOpen(); + const rows = this.db + .prepare(`SELECT p.body FROM objective_profile_active a JOIN objective_profiles p + ON p.objective_id = a.objective_id AND p.revision = a.revision WHERE a.agent_id = ? AND a.scope_id = ?`) + .all(boundedId(agentId, "agent id"), boundedId(scopeId, "scope id")); + const refs: Partial> = {}; + for (const { body: json } of rows) { + const profile = parseObjectiveProfile(JSON.parse(String(json))); + refs[profile.moduleKind] = parseObjectiveProfileRef({ + objectiveId: profile.objectiveId, + revision: profile.revision, + digest: judgmentDigest(profile), + }); + } + if (!refs.clotho || !refs.lachesis || !refs.atropos) return null; + return { + clotho: refs.clotho, + lachesis: refs.lachesis, + atropos: refs.atropos, + }; + } + + openRound(snapshot: JudgmentSnapshotRef): { + roundId: string; + snapshotDigest: string; + } { + this.assertOpen(); + const parsed = parseJudgmentSnapshotRef(snapshot); + return this.transaction(() => { + const active = this.activeObjectiveProfiles( + parsed.agentId, + parsed.scopeId, + ); + if (body(active) !== body(parsed.objectiveProfileRefs)) + throw Error("stale objective profile refs"); + const digest = snapshotDigest(parsed); + const now = this.now(); + this.db + .prepare(`INSERT INTO rounds(round_id, agent_id, scope_id, situation, sequence, snapshot, snapshot_digest, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`) + .run( + parsed.roundId, + parsed.agentId, + parsed.scopeId, + parsed.situation, + parsed.sequence, + body(parsed), + digest, + now, + now, + ); + return { roundId: parsed.roundId, snapshotDigest: digest }; + }); + } + + getRound(roundId: string): { + snapshot: JudgmentSnapshotRef; + status: RoundStatus; + snapshotDigest: string; + } | null { + this.assertOpen(); + const row = this.db + .prepare( + "SELECT snapshot, status, snapshot_digest FROM rounds WHERE round_id = ?", + ) + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { snapshot, status, snapshot_digest: digest } = row; + return { + snapshot: parseJudgmentSnapshotRef(JSON.parse(String(snapshot))), + status: status as RoundStatus, + snapshotDigest: String(digest), + }; + } + + putAssessment(assessment: Assessment): void { + this.assertOpen(); + const parsed = parseAssessment(assessment); + this.transaction(() => { + const round = this.requireOpenRound(parsed.snapshotId); + if ( + parsed.snapshotId !== round.snapshot.roundId || + parsed.snapshotDigest !== round.snapshotDigest + ) + throw Error("assessment snapshot mismatch"); + if ( + this.db + .prepare( + "SELECT 1 FROM assessments WHERE round_id = ? AND module_kind = ?", + ) + .get(parsed.snapshotId, parsed.moduleKind) + ) + throw Error("duplicate assessment"); + this.db + .prepare( + "INSERT INTO assessments(round_id, module_kind, snapshot_digest, input_digest, digest, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .run( + parsed.snapshotId, + parsed.moduleKind, + parsed.snapshotDigest, + parsed.inputDigest, + judgmentDigest(parsed), + body(parsed), + this.now(), + ); + }); + } + + assessmentSet(roundId: string): AssessmentSet { + this.assertOpen(); + const round = this.getRound(roundId); + const rows = this.db + .prepare("SELECT body FROM assessments WHERE round_id = ?") + .all(boundedId(roundId, "round id")); + if (!round || rows.length !== MODULE_KINDS.length) + throw Error("incomplete assessment set"); + const assessments = rows.map(({ body: json }) => + parseAssessment(JSON.parse(String(json))), + ); + return parseAssessmentSet({ + schemaVersion: 1, + roundId, + snapshotDigest: round.snapshotDigest, + assessments: MODULE_KINDS.map((module) => + assessments.find((item) => item.moduleKind === module), + ), + }); + } + + recordResolution( + roundId: string, + resolution: ResolutionRecord, + spec: SelectionSpec | null, + ): void { + this.assertOpen(); + const id = boundedId(roundId, "round id"); + const parsed = parseResolutionRecord(resolution); + const selection = spec === null ? null : parseSelectionSpec(spec); + this.transaction(() => { + const round = this.requireOpenRound(id); + if (parsed.roundId !== id) throw Error("resolution round mismatch"); + if ( + (parsed.status === "resolved") !== (selection !== null) || + (selection && + (selection.roundId !== id || + selection.snapshotDigest !== round.snapshotDigest)) + ) + throw Error("selection spec mismatch"); + const now = this.now(); + this.db + .prepare( + "INSERT INTO resolution_records(round_id, digest, body, created_at) VALUES (?, ?, ?, ?)", + ) + .run(id, judgmentDigest(parsed), body(parsed), now); + if (selection) + this.db + .prepare( + "INSERT INTO selection_specs(round_id, spec_digest, body, created_at) VALUES (?, ?, ?, ?)", + ) + .run(id, selection.specDigest, body(selection), now); + this.db + .prepare( + "UPDATE rounds SET status = ?, updated_at = ? WHERE round_id = ?", + ) + .run(parsed.status, now, id); + }); + } + + getResolution(roundId: string): ResolutionRecord | null { + this.assertOpen(); + const row = this.db + .prepare("SELECT body FROM resolution_records WHERE round_id = ?") + .get(boundedId(roundId, "round id")); + const { body: json } = row ?? {}; + return row ? parseResolutionRecord(JSON.parse(String(json))) : null; + } + + getSelectionSpec(roundId: string): SelectionSpec | null { + this.assertOpen(); + const row = this.db + .prepare("SELECT body FROM selection_specs WHERE round_id = ?") + .get(boundedId(roundId, "round id")); + const { body: json } = row ?? {}; + return row ? parseSelectionSpec(JSON.parse(String(json))) : null; + } + + putIntention(record: IntentionRecord): void { + this.assertOpen(); + const parsed = parseIntentionRecord(record); + if ( + parsed.status !== "proposed" || + parsed.revision !== 0 || + parsed.history.length !== 0 + ) + throw Error("intention must be proposed"); + this.transaction(() => { + if (this.getIntention(parsed.intentionId)) + throw Error("duplicate intention"); + const now = this.now(); + this.db + .prepare( + "INSERT INTO intention_records(intention_id, agent_id, scope_id, revision, status, digest, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + parsed.intentionId, + parsed.agentId, + parsed.scopeId, + parsed.revision, + parsed.status, + intentionDigest(parsed), + body(parsed), + now, + now, + ); + }); + } + + transitionIntention( + intentionId: string, + transition: Omit, + expectedRevision: number, + ): IntentionRecord { + this.assertOpen(); + const id = boundedId(intentionId, "intention id"); + revision(expectedRevision); + return this.transaction(() => { + const record = this.getIntention(id); + if (!record) throw Error("unknown intention"); + if (record.revision !== expectedRevision) + throw Error("stale intention revision"); + if (Object.hasOwn(transition, "from")) + throw Error("unknown intention transition field from"); + const entry = parseIntentionTransition({ + from: record.status, + ...transition, + }); + const updated = parseIntentionRecord( + transitionIntention(record, transition), + ); + this.db + .prepare( + "UPDATE intention_records SET revision = ?, status = ?, digest = ?, body = ?, updated_at = ? WHERE intention_id = ?", + ) + .run( + updated.revision, + updated.status, + intentionDigest(updated), + body(updated), + this.now(), + id, + ); + this.db + .prepare( + "INSERT INTO intention_transitions(intention_id, revision, from_status, to_status, reason, evidence_ref, at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .run( + id, + updated.revision, + entry.from, + entry.to, + entry.reason, + entry.evidenceRef, + entry.at, + ); + return updated; + }); + } + + getIntention(intentionId: string): IntentionRecord | null { + this.assertOpen(); + const row = this.db + .prepare("SELECT body FROM intention_records WHERE intention_id = ?") + .get(boundedId(intentionId, "intention id")); + const { body: json } = row ?? {}; + return row ? parseIntentionRecord(JSON.parse(String(json))) : null; + } + + listIntentions( + agentId: string, + scopeId: string, + status?: IntentionStatus, + ): IntentionRecord[] { + this.assertOpen(); + const agent = boundedId(agentId, "agent id"); + const scope = boundedId(scopeId, "scope id"); + if (status !== undefined && !INTENTION_STATUSES.includes(status)) + throw Error("invalid intention status"); + const rows = + status === undefined + ? this.db + .prepare( + "SELECT body FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id", + ) + .all(agent, scope) + : this.db + .prepare( + "SELECT body FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", + ) + .all(agent, scope, status); + return rows.map(({ body: json }) => + parseIntentionRecord(JSON.parse(String(json))), + ); + } + + close(): void { + this.assertOpen(); + this.db.close(); + this.closed = true; + } + + private assertOpen(): void { + if (this.closed) throw Error("judgment store is closed"); + } + + private requireOpenRound(roundId: string) { + this.assertOpen(); + const round = this.getRound(roundId); + if (!round) throw Error("unknown judgment round"); + if (round.status !== "open") throw Error("judgment round is not open"); + return round; + } + + private transaction(fn: () => T): T { + this.assertOpen(); + if (this.db.isTransaction) return fn(); + this.db.exec("BEGIN IMMEDIATE"); + try { + const result = fn(); + this.db.exec("COMMIT"); + return result; + } catch (error) { + if (this.db.isTransaction) this.db.exec("ROLLBACK"); + throw error; + } + } +} diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts new file mode 100644 index 0000000..e91edfc --- /dev/null +++ b/packages/lina-core/test/judgment-store.test.ts @@ -0,0 +1,686 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + type Assessment, + assessmentInputDigest, + type IntentionRecord, + type IntentionTransition, + JUDGMENT_SCHEMA_VERSION, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseResolutionRecord, + parseSelectionSpec, + type ResolutionRecord, + type SelectionSpec, + snapshotDigest, + transitionIntention, +} from "../src/agents/index.ts"; +import { Fixture } from "./fixture.ts"; + +let fixture: Fixture; +let path: string; +const stores = new Set(); +beforeEach(() => { + fixture = new Fixture(); + path = join(fixture.dir, "judgment.sqlite"); +}); +afterEach(() => { + for (const store of stores) store.close(); + stores.clear(); + fixture.close(); +}); +function open(file = path): JudgmentStore { + const store = new JudgmentStore(file, { now: () => 1234 }); + stores.add(store); + return store; +} +function close(store: JudgmentStore): void { + store.close(); + stores.delete(store); +} +function database(file = path): DatabaseSync { + return fixture.keep(new DatabaseSync(file)); +} +function ddl(db: DatabaseSync) { + return db + .prepare( + "SELECT name,sql FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*' ORDER BY name", + ) + .all(); +} +function profile( + moduleKind: ModuleKind = "clotho", + revision = 1, +): ObjectiveProfile { + return { + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision, + objective: "Compare fixture outcomes", + comparisonCriteria: ["cost", "outcome"], + reconsiderationConditions: ["new evidence"], + }; +} +function snapshot( + store: JudgmentStore, + roundId = "round-1", + sequence = 1, +): JudgmentSnapshotRef { + for (const module of MODULE_KINDS) { + const ref = store.putObjectiveProfile(profile(module)); + store.activateObjectiveProfile("agent-1", "scope-1", ref); + } + const refs = store.activeObjectiveProfiles("agent-1", "scope-1"); + if (!refs) throw Error("fixture profiles missing"); + return parseJudgmentSnapshotRef({ + schemaVersion: 1, + roundId, + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [], + workingRevision: 2, + instructionRevision: 1, + policyRevision: 1, + identityRevision: 1, + domainRevisions: { life: 0 }, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence, + bindingGeneration: 0, + }); +} +function assessment( + ref: JudgmentSnapshotRef, + moduleKind: ModuleKind, + digest = snapshotDigest(ref), +): Assessment { + const input = { + snapshotDigest: digest, + objectiveRef: ref.objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + return parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: ref.roundId, + ...input, + inputDigest: assessmentInputDigest(input), + completeText: "Fixture assessment", + evidenceRefs: [], + proposedOptionKeys: ["a"], + objectiveAssessments: [ + { + optionKey: "a", + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + }, + ], + recommendedOptionKeys: ["a"], + detail: { + kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ + moduleKind + ], + body: { z: [{ b: 2, a: 1 }], a: true }, + }, + diagnostics: {}, + }); +} +function resolution( + ref: JudgmentSnapshotRef, + status: ResolutionRecord["status"] = "resolved", +): ResolutionRecord { + return parseResolutionRecord({ + schemaVersion: 1, + roundId: ref.roundId, + policyId: "personal.v1", + policyRevision: 1, + situation: ref.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: ["a"], lachesis: ["a"], atropos: ["a"] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [{ optionKey: "a", rank: 1 }], + conceded: [], + status, + holdReason: status === "resolved" ? null : "no eligible candidate", + }); +} +function spec( + ref: JudgmentSnapshotRef, + record = resolution(ref), +): SelectionSpec { + const body = { + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessmentSetDigest: judgmentDigest( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((m) => assessment(ref, m)), + }), + ), + objectiveProfileRefs: ref.objectiveProfileRefs, + resolutionDigest: judgmentDigest(record), + policyId: record.policyId, + policyRevision: record.policyRevision, + situation: ref.situation, + lambda: 0, + candidates: [{ optionKey: "a", p0: 1, b: null }], + eligibleDigest: judgmentDigest(["a"]), + }; + return parseSelectionSpec({ ...body, specDigest: judgmentDigest(body) }); +} +function intention(intentionId = "intention-1"): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId, + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete fixture task", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} +function transition( + to: IntentionTransition["to"], +): Omit { + return { + to, + reason: "fixture transition", + evidenceRef: "request-1", + at: "2026-09-11T01:00:00.000Z", + }; +} +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (value !== null && typeof value === "object") + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => [k, canonical(v)]), + ); + return value; +} + +test("fresh schema has exactly nine STRICT tables, metadata, WAL and unchanged DDL on reopen", () => { + let store = open(); + const db = database(); + const original = ddl(db); + expect(JUDGMENT_SCHEMA_VERSION).toBe(1); + expect(db.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 }); + expect(db.prepare("PRAGMA journal_mode").get()).toEqual({ + journal_mode: "wal", + }); + expect(original.map(({ name }) => name)).toEqual([ + "assessments", + "intention_records", + "intention_transitions", + "judgment_meta", + "objective_profile_active", + "objective_profiles", + "resolution_records", + "rounds", + "selection_specs", + ]); + for (const { sql } of original) expect(sql).toContain("STRICT"); + expect( + db.prepare("SELECT key,value FROM judgment_meta ORDER BY key").all(), + ).toEqual([ + { key: "schema_version", value: "1" }, + { key: "store", value: "judgment" }, + ]); + close(store); + store = open(); + expect(ddl(db)).toEqual(original); + expect(store.getRound("missing")).toBeNull(); +}); + +for (const change of [ + "CREATE TABLE foreign_table (id INTEGER)", + "PRAGMA user_version = 99", + "CREATE TABLE judgment_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; INSERT INTO judgment_meta VALUES ('store','judgment'),('schema_version','1'); PRAGMA user_version=1", +]) + test(`foreign schema rejected: ${change}`, () => { + const db = database(); + db.exec(change); + const before = ddl(db); + expect(() => open()).toThrow("unknown judgment store schema"); + expect(ddl(db)).toEqual(before); + }); + +for (const change of [ + "ALTER TABLE rounds ADD COLUMN extra TEXT", + "UPDATE judgment_meta SET value='other' WHERE key='store'", + "UPDATE judgment_meta SET value='2' WHERE key='schema_version'", + "DELETE FROM judgment_meta WHERE key='store'", + "CREATE INDEX extra ON rounds(status)", + "PRAGMA user_version=2", +]) + test(`tampered judgment schema rejected: ${change}`, () => { + close(open()); + const db = database(); + db.exec(change); + expect(() => open()).toThrow("unknown judgment store schema"); + }); + +test("nonfresh empty SQLite schema is rejected", () => { + const db = database(); + db.exec("VACUUM"); + expect(statSync(path).size).toBeGreaterThan(0); + expect(() => open()).toThrow("unknown judgment store schema"); +}); + +test("objective revisions are append-only and activation changes only for a new reference", () => { + const store = open(); + const ref = store.putObjectiveProfile(profile()); + expect(store.putObjectiveProfile(profile())).toEqual(ref); + expect(store.getObjectiveProfile(ref.objectiveId, 1)).toEqual(profile()); + expect(store.getObjectiveProfile("missing", 1)).toBeNull(); + expect(() => + store.putObjectiveProfile({ ...profile(), objective: "different" }), + ).toThrow("objective profile revision already exists"); + expect(store.activeObjectiveProfiles("agent-1", "scope-1")).toBeNull(); + expect(store.activateObjectiveProfile("agent-1", "scope-1", ref)).toEqual({ + activationRevision: 1, + }); + expect(store.activateObjectiveProfile("agent-1", "scope-1", ref)).toEqual({ + activationRevision: 1, + }); + const next = store.putObjectiveProfile(profile("clotho", 2)); + expect(store.activateObjectiveProfile("agent-1", "scope-1", next)).toEqual({ + activationRevision: 2, + }); + expect(store.getObjectiveProfile(ref.objectiveId, 1)).toEqual(profile()); + for (const module of ["lachesis", "atropos"] as const) + store.activateObjectiveProfile( + "agent-1", + "scope-1", + store.putObjectiveProfile(profile(module)), + ); + expect(store.activeObjectiveProfiles("agent-1", "scope-1")?.clotho).toEqual( + next, + ); + expect(store.activeObjectiveProfiles("agent-1", "other")).toBeNull(); + for (const invalid of [ + { ...ref, digest: "wrong" }, + { ...ref, revision: 99 }, + { ...ref, extra: true }, + ]) + expect(() => + store.activateObjectiveProfile("agent-1", "scope-1", invalid), + ).toThrow(); + expect(store.activeObjectiveProfiles("agent-1", "scope-1")?.clotho).toEqual( + next, + ); +}); + +test("rounds freeze active references and reject duplicate ids or scope sequences", () => { + const store = open(); + const ref = snapshot(store); + const stale = { + ...ref, + objectiveProfileRefs: { + ...ref.objectiveProfileRefs, + clotho: { ...ref.objectiveProfileRefs.clotho, digest: "stale" }, + }, + }; + expect(() => store.openRound(stale)).toThrow("stale objective profile refs"); + expect(store.openRound(ref)).toEqual({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + }); + expect(() => store.openRound(ref)).toThrow(); + expect(() => store.openRound({ ...ref, roundId: "round-2" })).toThrow(); + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + store.putObjectiveProfile(profile("clotho", 2)), + ); + expect(store.getRound(ref.roundId)).toEqual({ + snapshot: ref, + status: "open", + snapshotDigest: snapshotDigest(ref), + }); + expect(() => + store.openRound({ ...ref, roundId: "round-3", sequence: 3 }), + ).toThrow("stale objective profile refs"); +}); + +test("assessments require an open matching snapshot and exactly one of each module", () => { + const store = open(); + const ref = snapshot(store); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow(); + store.openRound(ref); + expect(() => store.putAssessment(assessment(ref, "clotho", "wrong"))).toThrow( + "assessment snapshot mismatch", + ); + store.putAssessment(assessment(ref, "clotho")); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow( + "duplicate assessment", + ); + store.putAssessment(assessment(ref, "lachesis")); + expect(() => store.assessmentSet(ref.roundId)).toThrow( + "incomplete assessment set", + ); + store.putAssessment(assessment(ref, "atropos")); + const set = store.assessmentSet(ref.roundId); + expect(set).toEqual( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((m) => assessment(ref, m)), + }), + ); + store.recordResolution(ref.roundId, resolution(ref, "held"), null); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow(); + expect(store.assessmentSet(ref.roundId)).toEqual(set); + expect(() => store.assessmentSet("missing")).toThrow( + "incomplete assessment set", + ); +}); + +test("resolution and selection persist atomically and survive reopen with canonical JSON", () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const record = resolution(ref); + const selection = spec(ref); + store.putIntention(intention()); + store.recordResolution(ref.roundId, record, selection); + expect(store.getRound(ref.roundId)?.status).toBe("resolved"); + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow(); + close(store); + store = open(); + expect(store.getResolution(ref.roundId)).toEqual(record); + const saved = store.getSelectionSpec(ref.roundId); + expect(saved).toEqual(selection); + if (!saved) throw Error("missing selection"); + expect(saved.specDigest).toBe( + judgmentDigest({ ...saved, specDigest: undefined }), + ); + expect(store.getIntention("intention-1")).toEqual(intention()); + const db = database(); + for (const [table, column] of [ + ["objective_profiles", "body"], + ["rounds", "snapshot"], + ["assessments", "body"], + ["resolution_records", "body"], + ["selection_specs", "body"], + ["intention_records", "body"], + ] as const) { + for (const { body: json } of db + .prepare(`SELECT ${column} AS body FROM ${table}`) + .all()) { + const body = String(json); + expect(body).toBe(JSON.stringify(canonical(JSON.parse(body)))); + } + } + expect(db.prepare("SELECT created_at FROM rounds").get()).toEqual({ + created_at: 1234, + }); +}); + +test("resolution rejects mismatched selection presence, round id and snapshot without partial rows", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + const other = { ...ref, roundId: "other" }; + const db = database(); + for (const [record, selection] of [ + [resolution(ref, "deferred"), spec(ref)], + [resolution(ref, "held"), spec(ref)], + [resolution(ref), null], + [resolution(ref), spec(other)], + [resolution(ref), spec({ ...ref, workingRevision: 99 })], + ] as const) { + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow("selection spec mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect( + db.prepare("SELECT count(*) AS n FROM resolution_records").get(), + ).toEqual({ n: 0 }); + expect( + db.prepare("SELECT count(*) AS n FROM selection_specs").get(), + ).toEqual({ n: 0 }); + } + expect(() => + store.recordResolution(ref.roundId, resolution(other), spec(ref)), + ).toThrow(); + store.recordResolution(ref.roundId, resolution(ref, "deferred"), null); + expect(store.getRound(ref.roundId)?.status).toBe("deferred"); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + expect(store.getResolution("missing")).toBeNull(); +}); + +test("SQLite failure during resolution rolls back the already inserted resolution", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + const db = database(); + db.exec( + "CREATE TRIGGER fail_spec BEFORE INSERT ON selection_specs BEGIN SELECT RAISE(ABORT, 'fixture spec failure'); END", + ); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + ).toThrow("fixture spec failure"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + db.exec("DROP TRIGGER fail_spec"); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); +}); + +test("intention transitions match the pure function, persist, filter and reject stale/invalid changes atomically", () => { + let store = open(); + const initial = intention(); + store.putIntention(initial); + expect(() => store.putIntention(initial)).toThrow("duplicate intention"); + const adopted = transitionIntention(initial, transition("adopted")); + expect(() => + store.putIntention({ ...adopted, intentionId: "invalid-new" }), + ).toThrow(); + expect( + store.transitionIntention(initial.intentionId, transition("adopted"), 0), + ).toEqual(adopted); + const active = transitionIntention(adopted, transition("active")); + expect( + store.transitionIntention(initial.intentionId, transition("active"), 1), + ).toEqual(active); + expect(active.revision).toBe(2); + const db = database(); + const rows = () => + db.prepare("SELECT * FROM intention_transitions ORDER BY revision").all(); + expect(rows()).toEqual( + active.history.map((t, i) => ({ + intention_id: initial.intentionId, + revision: i + 1, + from_status: t.from, + to_status: t.to, + reason: t.reason, + evidence_ref: t.evidenceRef, + at: t.at, + })), + ); + expect(() => + store.transitionIntention(initial.intentionId, transition("suspended"), 1), + ).toThrow("stale intention revision"); + expect(() => + store.transitionIntention(initial.intentionId, transition("proposed"), 2), + ).toThrow("invalid intention transition: active -> proposed"); + expect(store.getIntention(initial.intentionId)).toEqual(active); + expect(rows()).toHaveLength(2); + store.putIntention(intention("aaa")); + store.putIntention({ ...intention("elsewhere"), scopeId: "other" }); + expect( + store.listIntentions("agent-1", "scope-1").map((r) => r.intentionId), + ).toEqual(["aaa", "intention-1"]); + expect(store.listIntentions("agent-1", "scope-1", "active")).toEqual([ + active, + ]); + close(store); + store = open(); + expect(store.getIntention(initial.intentionId)).toEqual(active); + expect(store.getIntention("missing")).toBeNull(); +}); + +test("SQLite transition insert failure rolls back updated intention", () => { + const store = open(); + store.putIntention(intention()); + const db = database(); + db.exec( + "CREATE TRIGGER fail_transition BEFORE INSERT ON intention_transitions BEGIN SELECT RAISE(ABORT, 'fixture transition failure'); END", + ); + expect(() => + store.transitionIntention("intention-1", transition("adopted"), 0), + ).toThrow("fixture transition failure"); + expect(store.getIntention("intention-1")).toEqual(intention()); + expect( + db.prepare("SELECT count(*) AS n FROM intention_transitions").get(), + ).toEqual({ n: 0 }); + db.exec("DROP TRIGGER fail_transition"); + expect( + store.transitionIntention("intention-1", transition("adopted"), 0).revision, + ).toBe(1); +}); + +test("external records are reparsed and malformed persisted bodies are rejected on read", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const action of [ + () => + store.putObjectiveProfile({ + ...profile(), + extra: true, + } as ObjectiveProfile), + () => + store.openRound({ + ...ref, + schemaVersion: 2, + } as unknown as JudgmentSnapshotRef), + () => + store.putAssessment({ + ...assessment(ref, "clotho"), + inputDigest: "invalid", + }), + () => + store.recordResolution( + ref.roundId, + { ...resolution(ref), extra: true } as ResolutionRecord, + spec(ref), + ), + () => + store.recordResolution(ref.roundId, resolution(ref), { + ...spec(ref), + specDigest: "invalid", + }), + () => + store.putIntention({ ...intention(), extra: true } as IntentionRecord), + ]) + expect(action).toThrow(); + const db = database(); + store.putIntention(intention()); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + for (const [table, column, read] of [ + [ + "objective_profiles", + "body", + () => store.getObjectiveProfile("objective-clotho", 1), + ], + ["rounds", "snapshot", () => store.getRound(ref.roundId)], + ["resolution_records", "body", () => store.getResolution(ref.roundId)], + ["selection_specs", "body", () => store.getSelectionSpec(ref.roundId)], + ["intention_records", "body", () => store.getIntention("intention-1")], + ] as const) { + db.prepare(`UPDATE ${table} SET ${column} = ?`).run('{"schemaVersion":2}'); + expect(read).toThrow(/Unsupported .* schema version/); + } + expect(parseObjectiveProfile(profile())).toEqual(profile()); +}); + +test("every public method rejects use after close", () => { + const store = open(); + const ref = snapshot(store); + close(store); + for (const action of [ + () => store.close(), + () => store.putObjectiveProfile(profile()), + () => store.getObjectiveProfile("objective-clotho", 1), + () => + store.activateObjectiveProfile( + "agent-1", + "scope-1", + ref.objectiveProfileRefs.clotho, + ), + () => store.activeObjectiveProfiles("agent-1", "scope-1"), + () => store.openRound(ref), + () => store.getRound(ref.roundId), + () => store.putAssessment(assessment(ref, "clotho")), + () => store.assessmentSet(ref.roundId), + () => store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + () => store.getResolution(ref.roundId), + () => store.getSelectionSpec(ref.roundId), + () => store.putIntention(intention()), + () => store.getIntention("intention-1"), + () => store.transitionIntention("intention-1", transition("adopted"), 0), + () => store.listIntentions("agent-1", "scope-1"), + ]) + expect(action).toThrow(/closed/); +}); + +test("missing parent is private; directory and database symlinks are rejected", () => { + const parent = join(fixture.dir, "private"); + open(join(parent, "judgment.sqlite")); + expect(statSync(parent).mode & 0o777).toBe(0o700); + const target = join(fixture.dir, "target"); + mkdirSync(target); + const link = join(fixture.dir, "link"); + symlinkSync(target, link); + expect(() => open(join(link, "judgment.sqlite"))).toThrow(/unsafe directory/); + const file = join(target, "judgment.sqlite"); + writeFileSync(file, ""); + symlinkSync(file, path); + expect(() => open()).toThrow(/unsafe regular file/); +}); From d6ddd3355b45fb28ca4f640e1e6cf495f47f0906 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:56:51 +0900 Subject: [PATCH 09/47] refactor(core): share JSON canonicalizer between judgment digest and store (F1-BC) --- .../lina-core/src/agents/judgment-store.ts | 16 ++------- .../src/agents/judgment-validation.ts | 36 ++++++++++--------- .../lina-core/test/judgment-store.test.ts | 10 ++++++ 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 2f1374b..20dc442 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -18,6 +18,7 @@ import { } from "./judgment.ts"; import { initializeJudgmentSchema } from "./judgment-schema.ts"; import { + canonicalJson, intentionDigest, judgmentDigest, parseAssessment, @@ -34,21 +35,8 @@ import { } from "./judgment-validation.ts"; import { boundedId } from "./validation.ts"; -function sortedCanonical(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortedCanonical); - if (value !== null && typeof value === "object") - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [ - key, - sortedCanonical((value as Record)[key]), - ]), - ); - return value; -} function body(value: unknown): string { - return JSON.stringify(sortedCanonical(value)); + return canonicalJson(value); } function revision(value: number, minimum = 0): number { if (!Number.isSafeInteger(value) || value < minimum) diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 1db83cf..8a6214d 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -157,23 +157,27 @@ function jsonObject(value: unknown): JsonObject { ); } +function canonical(item: unknown): unknown { + return Array.isArray(item) + ? item.map(canonical) + : item !== null && typeof item === "object" + ? Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [ + key, + canonical((item as Record)[key]), + ]), + ) + : item; +} + +export function canonicalJson(value: unknown): string { + return JSON.stringify(canonical(value)); +} + export function judgmentDigest(value: unknown): string { - const canonical = (item: unknown): unknown => - Array.isArray(item) - ? item.map(canonical) - : item !== null && typeof item === "object" - ? Object.fromEntries( - Object.keys(item) - .sort() - .map((key) => [ - key, - canonical((item as Record)[key]), - ]), - ) - : item; - return createHash("sha256") - .update(JSON.stringify(canonical(value))) - .digest("hex"); + return createHash("sha256").update(canonicalJson(value)).digest("hex"); } export function assessmentInputDigest(input: { diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index e91edfc..fee4a4b 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { mkdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -26,8 +27,17 @@ import { snapshotDigest, transitionIntention, } from "../src/agents/index.ts"; +import { canonicalJson } from "../src/agents/judgment-validation.ts"; import { Fixture } from "./fixture.ts"; +test("canonical JSON bytes are shared with judgment digests", () => { + const value = { b: [{ d: 1, c: 2 }], a: null }; + expect(canonicalJson(value)).toBe('{"a":null,"b":[{"c":2,"d":1}]}'); + expect(judgmentDigest(value)).toBe( + createHash("sha256").update(canonicalJson(value)).digest("hex"), + ); +}); + let fixture: Fixture; let path: string; const stores = new Set(); From 8c4eb986752285ca4b2977083b9ee38da5391ad0 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:41:51 +0900 Subject: [PATCH 10/47] test(core): prove shared judgment canonicalizer through public barrel only (F1-A2) --- .../lina-core/test/judgment-store.test.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index fee4a4b..7aa754f 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -27,17 +27,8 @@ import { snapshotDigest, transitionIntention, } from "../src/agents/index.ts"; -import { canonicalJson } from "../src/agents/judgment-validation.ts"; import { Fixture } from "./fixture.ts"; -test("canonical JSON bytes are shared with judgment digests", () => { - const value = { b: [{ d: 1, c: 2 }], a: null }; - expect(canonicalJson(value)).toBe('{"a":null,"b":[{"c":2,"d":1}]}'); - expect(judgmentDigest(value)).toBe( - createHash("sha256").update(canonicalJson(value)).digest("hex"), - ); -}); - let fixture: Fixture; let path: string; const stores = new Set(); @@ -250,6 +241,30 @@ function canonical(value: unknown): unknown { return value; } +test("canonical JSON bytes are shared with judgment digests", () => { + const value = { + z: [ + { d: 1, c: 2 }, + { b: null, a: "\u65e5\u672c\u8a9e" }, + ], + a: null, + missing: undefined, + text: "caf\u00e9", + }; + expect(judgmentDigest(value)).toBe( + createHash("sha256") + .update(JSON.stringify(canonical(value))) + .digest("hex"), + ); + const store = open(); + const saved = profile(); + store.putObjectiveProfile(saved); + const db = fixture.keep(new DatabaseSync(path, { readOnly: true })); + expect(db.prepare("SELECT body FROM objective_profiles").get()).toEqual({ + body: JSON.stringify(canonical(saved)), + }); +}); + test("fresh schema has exactly nine STRICT tables, metadata, WAL and unchanged DDL on reopen", () => { let store = open(); const db = database(); From 02cb0efa628a827c16115bbc73de73b7cdaa93c4 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:18:57 +0900 Subject: [PATCH 11/47] test(core): add end-to-end personal.v1 judgment round fixture (F1 integration) --- .../lina-core/test/judgment-round.test.ts | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 packages/lina-core/test/judgment-round.test.ts diff --git a/packages/lina-core/test/judgment-round.test.ts b/packages/lina-core/test/judgment-round.test.ts new file mode 100644 index 0000000..95e7d9c --- /dev/null +++ b/packages/lina-core/test/judgment-round.test.ts @@ -0,0 +1,478 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + type Assessment, + assessmentInputDigest, + buildCanonicalOption, + type CanonicalOption, + type IntentionRecord, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + type ModuleKind, + type ObjectiveProfile, + PERSONAL_CATALOG_ID, + PERSONAL_POLICY_V1, + type PersonalOptionKind, + parseAssessment, + parseCanonicalOption, + parseIntentionRecord, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + resolvePersonalRound, + type SelectionSpec, + type Situation, + sampleSelection, + snapshotDigest, + transitionIntention, +} from "../src/agents/index.ts"; +import { buildContextReadProjection } from "../src/context/index.ts"; + +const AGENT = "agent-1"; +const SCOPE = "scope-1"; +const PROJECTED_AT = "2026-09-12T00:00:00.000Z"; +const actor = { agentId: AGENT, scopeId: SCOPE }; + +let dir: string | undefined; +let sqlitePath = ""; +const stores = new Set(); + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "lina-core-judgment-round-")); + sqlitePath = join(dir, "judgment.sqlite"); +}); + +afterEach(() => { + for (const store of stores) store.close(); + stores.clear(); + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + dir = undefined; +}); + +function open(path = sqlitePath): JudgmentStore { + const store = new JudgmentStore(path, { now: () => 1234 }); + stores.add(store); + return store; +} + +function close(store: JudgmentStore): void { + store.close(); + stores.delete(store); +} + +function contextProjection() { + const working = { + revision: 7, + goal: "Keep working revision at seven", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }; + const previous = buildContextReadProjection({ + working, + instruction: { + requestId: "request-1", + entryId: "entry-1", + text: "first instruction", + }, + previous: null, + projectedAt: PROJECTED_AT, + }); + return buildContextReadProjection({ + working, + instruction: { + requestId: "request-2", + entryId: "entry-2", + text: "second instruction", + }, + previous, + projectedAt: PROJECTED_AT, + }); +} + +function profile(moduleKind: ModuleKind): ObjectiveProfile { + return parseObjectiveProfile({ + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision: 1, + objective: `Fixture ${moduleKind} objective`, + comparisonCriteria: [`${moduleKind} cost`, `${moduleKind} outcome`], + reconsiderationConditions: [`${moduleKind} new evidence`], + }); +} + +function putActivatedProfiles(store: JudgmentStore) { + const clotho = profile("clotho"); + const lachesis = profile("lachesis"); + const atropos = profile("atropos"); + const clothoRef = store.putObjectiveProfile(clotho); + const lachesisRef = store.putObjectiveProfile(lachesis); + const atroposRef = store.putObjectiveProfile(atropos); + expect(store.activateObjectiveProfile(AGENT, SCOPE, clothoRef)).toEqual({ + activationRevision: 1, + }); + expect(store.activateObjectiveProfile(AGENT, SCOPE, lachesisRef)).toEqual({ + activationRevision: 1, + }); + expect(store.activateObjectiveProfile(AGENT, SCOPE, atroposRef)).toEqual({ + activationRevision: 1, + }); + return { + clotho, + lachesis, + atropos, + refs: { clotho: clothoRef, lachesis: lachesisRef, atropos: atroposRef }, + }; +} + +function canonicalOptions() { + const adopt = buildCanonicalOption({ + kind: "intention.adopt", + actor, + targetId: "intention-1", + args: {}, + preconditions: { + kind: "intention.adopt", + intentionKind: "autonomous_goal", + sourceRef: "source-1", + }, + }); + const start = buildCanonicalOption({ + kind: "task.start", + actor, + targetId: "task-1", + args: {}, + preconditions: { + kind: "task.start", + authorityRef: "authority-1", + taskText: "Start fixture task", + intentionId: "intention-1", + }, + }); + const inquire = buildCanonicalOption({ + kind: "inquire", + actor, + targetId: "ask-1", + args: { topic: "what next" }, + preconditions: { kind: "inquire", authorityRef: "authority-1" }, + }); + const inquireVariant = buildCanonicalOption({ + kind: "Inquire" as PersonalOptionKind, + actor, + targetId: " ask-1 ", + args: { topic: " what next " }, + preconditions: { kind: "inquire", authorityRef: "authority-1" }, + }); + const noop = buildCanonicalOption({ + kind: "noop", + actor, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "nothing needed" }, + }); + return { adopt, start, inquire, inquireVariant, noop }; +} + +function snapshotRef( + situation: Situation, + refs: ReturnType["refs"], + projection: ReturnType, +): JudgmentSnapshotRef { + return parseJudgmentSnapshotRef({ + schemaVersion: 1, + roundId: `round-${situation}`, + agentId: AGENT, + scopeId: SCOPE, + sourceRefs: [], + workingRevision: projection.workingRevision, + instructionRevision: projection.instructionRevision, + policyRevision: 1, + identityRevision: 1, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation, + clockId: "clock-1", + sequence: 1, + bindingGeneration: 0, + }); +} + +function stanceFor( + moduleKind: ModuleKind, + option: CanonicalOption, +): { stance: "prefer" | "accept" | "oppose"; severity: string | null } { + if (moduleKind === "lachesis" && option.kind === "intention.adopt") + return { stance: "prefer", severity: null }; + if (moduleKind === "clotho" && option.kind === "task.start") + return { stance: "oppose", severity: "infeasible" }; + if (moduleKind === "atropos" && option.kind === "noop") + return { stance: "oppose", severity: "commitment_breach" }; + return { stance: "accept", severity: null }; +} + +function assessmentFor( + snapshot: JudgmentSnapshotRef, + moduleKind: ModuleKind, + options: CanonicalOption[], +): Assessment { + const digest = snapshotDigest(snapshot); + const objectiveRef = snapshot.objectiveProfileRefs[moduleKind]; + const objectiveAssessments = options.map((option) => { + const { stance, severity } = stanceFor(moduleKind, option); + return { + optionKey: option.optionKey, + stance, + severity, + unavailableReason: null, + gain: "fixture gain", + loss: "fixture loss", + uncertainty: "fixture uncertainty", + evidenceRefs: [], + }; + }); + return parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + snapshotDigest: digest, + inputDigest: assessmentInputDigest({ + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + }), + objectiveRef, + mechanismRevision: 1, + completeText: `Fixture ${moduleKind} assessment`, + evidenceRefs: [], + proposedOptionKeys: options.map((option) => option.optionKey), + objectiveAssessments, + recommendedOptionKeys: objectiveAssessments + .filter((row) => row.stance === "prefer") + .map((row) => row.optionKey), + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }); +} + +function proposedIntention(): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId: "intention-1", + agentId: AGENT, + scopeId: SCOPE, + revision: 0, + kind: "autonomous_goal", + purposeRef: "purpose-1", + text: "Adopt the fixture intention", + acceptance: { + sourceRef: "source-1", + acceptedBy: "host_autonomy", + policyRevision: 1, + acceptedAt: PROJECTED_AT, + }, + priority: 0, + deadline: null, + completionCondition: "Fixture completion", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} + +function p0(spec: SelectionSpec, optionKey: string): number | undefined { + return spec.candidates.find((row) => row.optionKey === optionKey)?.p0; +} + +function playRound(store: JudgmentStore, situation: Situation) { + const projection = contextProjection(); + const profiles = putActivatedProfiles(store); + const options = canonicalOptions(); + const candidates = [ + options.adopt, + options.start, + options.inquire, + options.noop, + ]; + const snapshot = snapshotRef(situation, profiles.refs, projection); + expect(store.openRound(snapshot)).toEqual({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + }); + for (const moduleKind of ["clotho", "lachesis", "atropos"] as const) + store.putAssessment(assessmentFor(snapshot, moduleKind, candidates)); + const set = store.assessmentSet(snapshot.roundId); + const resolved = resolvePersonalRound({ + policy: PERSONAL_POLICY_V1, + snapshot, + options: candidates, + set, + eligibility: candidates.map((option) => ({ + optionKey: option.optionKey, + eligible: true, + reason: null, + })), + bias: {}, + }); + store.recordResolution(snapshot.roundId, resolved.resolution, resolved.spec); + return { projection, profiles, options, candidates, snapshot, set, resolved }; +} + +test("autonomous personal.v1 round persists selection, sampling and adopted intention", () => { + const store = open(); + const { projection, profiles, options, candidates, snapshot, set, resolved } = + playRound(store, "autonomous"); + + expect(projection.workingRevision).toBe(7); + expect(projection.instructionRevision).toBe(2); + expect(projection.workingRevision !== projection.instructionRevision).toBe( + true, + ); + expect(snapshot.workingRevision !== snapshot.instructionRevision).toBe(true); + expect(snapshot.workingRevision).toBe(projection.workingRevision); + expect(snapshot.instructionRevision).toBe(projection.instructionRevision); + + expect(store.getObjectiveProfile("objective-clotho", 1)).toEqual( + profiles.clotho, + ); + expect(store.getObjectiveProfile("objective-lachesis", 1)).toEqual( + profiles.lachesis, + ); + expect(store.getObjectiveProfile("objective-atropos", 1)).toEqual( + profiles.atropos, + ); + expect(store.activeObjectiveProfiles(AGENT, SCOPE)).toEqual(profiles.refs); + + expect(options.adopt.catalogId).toBe(PERSONAL_CATALOG_ID); + expect(parseCanonicalOption(options.inquireVariant)).toEqual( + parseCanonicalOption(options.inquire), + ); + expect(options.inquireVariant.optionKey).toBe(options.inquire.optionKey); + expect(new Set(candidates.map((option) => option.optionKey)).size).toBe(4); + + const spec = store.getSelectionSpec(snapshot.roundId); + const resolution = store.getResolution(snapshot.roundId); + expect(spec).not.toBeNull(); + expect(resolution).not.toBeNull(); + if (spec === null || resolution === null) + throw Error("missing resolution artifacts"); + + expect(p0(spec, options.start.optionKey)).toBe(0); + expect(p0(spec, options.noop.optionKey)).toBe(0); + + expect(set.assessments.map((row) => row.moduleKind)).toEqual([ + "clotho", + "lachesis", + "atropos", + ]); + expect( + set.assessments + .find((row) => row.moduleKind === "lachesis") + ?.objectiveAssessments.find( + (row) => row.optionKey === options.adopt.optionKey, + )?.stance, + ).toBe("prefer"); + expect( + set.assessments + .find((row) => row.moduleKind === "clotho") + ?.objectiveAssessments.find( + (row) => row.optionKey === options.start.optionKey, + ), + ).toMatchObject({ stance: "oppose", severity: "infeasible" }); + expect( + set.assessments + .find((row) => row.moduleKind === "atropos") + ?.objectiveAssessments.find( + (row) => row.optionKey === options.noop.optionKey, + ), + ).toMatchObject({ stance: "oppose", severity: "commitment_breach" }); + expect( + resolution.ranking.find((row) => row.optionKey === options.adopt.optionKey) + ?.rank, + ).toBe(1); + expect( + Math.abs(spec.candidates.reduce((sum, row) => sum + row.p0, 0) - 1), + ).toBeLessThanOrEqual(1e-12); + expect(spec.lambda).toBe(1); + expect(spec.specDigest).toBe( + judgmentDigest({ ...spec, specDigest: undefined }), + ); + expect(resolution.excluded).toContainEqual({ + optionKey: options.start.optionKey, + stage: "infeasible", + byModule: "clotho", + reason: "infeasible precondition", + }); + expect(resolution.excluded).toContainEqual({ + optionKey: options.noop.optionKey, + stage: "commitment_protection", + byModule: "atropos", + reason: "accepted commitment breach", + }); + + const high = sampleSelection(spec, 0.999); + const low = sampleSelection(spec, 0.0); + expect(p0(spec, high.optionKey)).toBeGreaterThan(0); + expect(p0(spec, low.optionKey)).toBeGreaterThan(0); + + const proposed = proposedIntention(); + store.putIntention(proposed); + const adoption = { + to: "adopted" as const, + reason: "selected by personal.v1 round", + evidenceRef: snapshot.roundId, + at: "2026-09-12T01:00:00.000Z", + }; + const adopted = transitionIntention(proposed, adoption); + expect(adopted.history[0]?.evidenceRef).toBe(snapshot.roundId); + expect(store.transitionIntention(proposed.intentionId, adoption, 0)).toEqual( + adopted, + ); + + const round = store.getRound(snapshot.roundId); + close(store); + const reopened = open(); + expect(reopened.getObjectiveProfile("objective-clotho", 1)).toEqual( + profiles.clotho, + ); + expect(reopened.getObjectiveProfile("objective-lachesis", 1)).toEqual( + profiles.lachesis, + ); + expect(reopened.getObjectiveProfile("objective-atropos", 1)).toEqual( + profiles.atropos, + ); + expect(reopened.activeObjectiveProfiles(AGENT, SCOPE)).toEqual(profiles.refs); + expect(reopened.getRound(snapshot.roundId)).toEqual(round); + expect(reopened.assessmentSet(snapshot.roundId)).toEqual(set); + expect(reopened.getResolution(snapshot.roundId)).toEqual(resolution); + expect(reopened.getSelectionSpec(snapshot.roundId)).toEqual(spec); + expect(reopened.getIntention(proposed.intentionId)).toEqual(adopted); + expect(resolved.spec).toEqual(spec); +}); + +test("user_request personal.v1 round sets lambda 0 and atropos-first order", () => { + const store = open(); + const { snapshot } = playRound(store, "user_request"); + const spec = store.getSelectionSpec(snapshot.roundId); + const resolution = store.getResolution(snapshot.roundId); + expect(spec).not.toBeNull(); + expect(resolution).not.toBeNull(); + if (spec === null || resolution === null) + throw Error("missing resolution artifacts"); + expect(spec.lambda).toBe(0); + expect(resolution.order[0]).toBe("atropos"); +}); From 7e9bffc9b396c6e683cc5ca4fc73986969a77b8b Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:54:51 +0900 Subject: [PATCH 12/47] test(core): assert judgment round reload against original in-memory records --- .../lina-core/test/judgment-round.test.ts | 82 ++++++++++++++----- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/packages/lina-core/test/judgment-round.test.ts b/packages/lina-core/test/judgment-round.test.ts index 95e7d9c..d26013f 100644 --- a/packages/lina-core/test/judgment-round.test.ts +++ b/packages/lina-core/test/judgment-round.test.ts @@ -298,6 +298,14 @@ function p0(spec: SelectionSpec, optionKey: string): number | undefined { return spec.candidates.find((row) => row.optionKey === optionKey)?.p0; } +function sortedAssessments(rows: Assessment[]): Assessment[] { + return [...rows].sort((a, b) => + a.moduleKind < b.moduleKind ? -1 : a.moduleKind > b.moduleKind ? 1 : 0, + ); +} + +const AUTONOMOUS_ORDER = ["lachesis", "clotho", "atropos"] as const; + function playRound(store: JudgmentStore, situation: Situation) { const projection = contextProjection(); const profiles = putActivatedProfiles(store); @@ -309,12 +317,15 @@ function playRound(store: JudgmentStore, situation: Situation) { options.noop, ]; const snapshot = snapshotRef(situation, profiles.refs, projection); - expect(store.openRound(snapshot)).toEqual({ + const opened = store.openRound(snapshot); + expect(opened).toEqual({ roundId: snapshot.roundId, snapshotDigest: snapshotDigest(snapshot), }); - for (const moduleKind of ["clotho", "lachesis", "atropos"] as const) - store.putAssessment(assessmentFor(snapshot, moduleKind, candidates)); + const assessments = (["clotho", "lachesis", "atropos"] as const).map( + (moduleKind) => assessmentFor(snapshot, moduleKind, candidates), + ); + for (const assessment of assessments) store.putAssessment(assessment); const set = store.assessmentSet(snapshot.roundId); const resolved = resolvePersonalRound({ policy: PERSONAL_POLICY_V1, @@ -329,13 +340,30 @@ function playRound(store: JudgmentStore, situation: Situation) { bias: {}, }); store.recordResolution(snapshot.roundId, resolved.resolution, resolved.spec); - return { projection, profiles, options, candidates, snapshot, set, resolved }; + return { + projection, + profiles, + options, + candidates, + snapshot, + opened, + assessments, + resolved, + }; } test("autonomous personal.v1 round persists selection, sampling and adopted intention", () => { const store = open(); - const { projection, profiles, options, candidates, snapshot, set, resolved } = - playRound(store, "autonomous"); + const { + projection, + profiles, + options, + candidates, + snapshot, + opened, + assessments, + resolved, + } = playRound(store, "autonomous"); expect(projection.workingRevision).toBe(7); expect(projection.instructionRevision).toBe(2); @@ -364,37 +392,37 @@ test("autonomous personal.v1 round persists selection, sampling and adopted inte expect(options.inquireVariant.optionKey).toBe(options.inquire.optionKey); expect(new Set(candidates.map((option) => option.optionKey)).size).toBe(4); - const spec = store.getSelectionSpec(snapshot.roundId); - const resolution = store.getResolution(snapshot.roundId); - expect(spec).not.toBeNull(); - expect(resolution).not.toBeNull(); - if (spec === null || resolution === null) - throw Error("missing resolution artifacts"); + expect(resolved.spec).not.toBeNull(); + if (resolved.spec === null) throw Error("expected resolved selection spec"); + const spec = resolved.spec; + const resolution = resolved.resolution; + expect(store.getSelectionSpec(snapshot.roundId)).toEqual(spec); + expect(store.getResolution(snapshot.roundId)).toEqual(resolution); expect(p0(spec, options.start.optionKey)).toBe(0); expect(p0(spec, options.noop.optionKey)).toBe(0); - expect(set.assessments.map((row) => row.moduleKind)).toEqual([ + expect(assessments.map((row) => row.moduleKind)).toEqual([ "clotho", "lachesis", "atropos", ]); expect( - set.assessments + assessments .find((row) => row.moduleKind === "lachesis") ?.objectiveAssessments.find( (row) => row.optionKey === options.adopt.optionKey, )?.stance, ).toBe("prefer"); expect( - set.assessments + assessments .find((row) => row.moduleKind === "clotho") ?.objectiveAssessments.find( (row) => row.optionKey === options.start.optionKey, ), ).toMatchObject({ stance: "oppose", severity: "infeasible" }); expect( - set.assessments + assessments .find((row) => row.moduleKind === "atropos") ?.objectiveAssessments.find( (row) => row.optionKey === options.noop.optionKey, @@ -411,6 +439,7 @@ test("autonomous personal.v1 round persists selection, sampling and adopted inte expect(spec.specDigest).toBe( judgmentDigest({ ...spec, specDigest: undefined }), ); + expect(resolution.order).toEqual([...AUTONOMOUS_ORDER]); expect(resolution.excluded).toContainEqual({ optionKey: options.start.optionKey, stage: "infeasible", @@ -443,7 +472,6 @@ test("autonomous personal.v1 round persists selection, sampling and adopted inte adopted, ); - const round = store.getRound(snapshot.roundId); close(store); const reopened = open(); expect(reopened.getObjectiveProfile("objective-clotho", 1)).toEqual( @@ -456,12 +484,26 @@ test("autonomous personal.v1 round persists selection, sampling and adopted inte profiles.atropos, ); expect(reopened.activeObjectiveProfiles(AGENT, SCOPE)).toEqual(profiles.refs); - expect(reopened.getRound(snapshot.roundId)).toEqual(round); - expect(reopened.assessmentSet(snapshot.roundId)).toEqual(set); + const reopenedRound = reopened.getRound(snapshot.roundId); + expect(reopenedRound).toEqual({ + snapshot, + status: "resolved", + snapshotDigest: opened.snapshotDigest, + }); + expect(reopenedRound?.snapshot).toEqual(snapshot); + expect(reopenedRound?.snapshot.roundId).toBe(opened.roundId); + expect(reopenedRound?.snapshot.situation).toBe("autonomous"); + expect(reopenedRound?.snapshotDigest).toBe(snapshotDigest(snapshot)); + expect(reopenedRound?.status).toBe("resolved"); + expect( + sortedAssessments(reopened.assessmentSet(snapshot.roundId).assessments), + ).toEqual(sortedAssessments(assessments)); expect(reopened.getResolution(snapshot.roundId)).toEqual(resolution); + expect(reopened.getResolution(snapshot.roundId)?.order).toEqual([ + ...AUTONOMOUS_ORDER, + ]); expect(reopened.getSelectionSpec(snapshot.roundId)).toEqual(spec); expect(reopened.getIntention(proposed.intentionId)).toEqual(adopted); - expect(resolved.spec).toEqual(spec); }); test("user_request personal.v1 round sets lambda 0 and atropos-first order", () => { From b0285185ae19124ad9fcdf4cc376d095da873b30 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:15:50 +0900 Subject: [PATCH 13/47] test(core): import behaviorFingerprint through the agents barrel in persona schema test (F1 audit) --- packages/lina-core/src/agents/index.ts | 5 +++++ packages/lina-core/test/persona-schema.test.ts | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 01585ce..e6b40eb 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -89,3 +89,8 @@ export * from "./judgment-policy.ts"; export { JUDGMENT_SCHEMA_VERSION } from "./judgment-schema.ts"; export { JudgmentStore } from "./judgment-store.ts"; + +// Behavior identity (pre-existing pure function; exposed so persona contract +// tests can pin the BehaviorJobInput fingerprint through the public barrel). + +export { behaviorFingerprint } from "./behavior-validation.ts"; diff --git a/packages/lina-core/test/persona-schema.test.ts b/packages/lina-core/test/persona-schema.test.ts index fcf63fc..2ea372e 100644 --- a/packages/lina-core/test/persona-schema.test.ts +++ b/packages/lina-core/test/persona-schema.test.ts @@ -1,6 +1,4 @@ import { expect, test } from "bun:test"; -// Pre-existing symbol used only by the pinned-fingerprint guard; all NEW symbols must still be imported from ../src/agents/index.ts. -import { behaviorFingerprint } from "../src/agents/behavior-validation.ts"; import type { DimensionSource, NeuralProjectionRef, @@ -10,6 +8,7 @@ import type { PersonaSchema, } from "../src/agents/index.ts"; import { + behaviorFingerprint, DIMENSION_SOURCES, parsePersonaSchema, personaSchemaDigest, From e597594a33bf116de86d1cafb23a4b3c368c4e59 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:25:36 +0900 Subject: [PATCH 14/47] fix(core): harden judgment transitions, policy and store consistency --- .../lina-core/src/agents/judgment-catalog.ts | 4 +- .../lina-core/src/agents/judgment-policy.ts | 26 +-- .../lina-core/src/agents/judgment-store.ts | 30 ++++ .../src/agents/judgment-validation.ts | 46 ++++-- .../lina-core/test/judgment-contract.test.ts | 80 +++++++++ .../lina-core/test/judgment-policy.test.ts | 60 +++++++ .../lina-core/test/judgment-store.test.ts | 154 ++++++++++++++++++ 7 files changed, 371 insertions(+), 29 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-catalog.ts b/packages/lina-core/src/agents/judgment-catalog.ts index 0fb2ca9..1fbf11c 100644 --- a/packages/lina-core/src/agents/judgment-catalog.ts +++ b/packages/lina-core/src/agents/judgment-catalog.ts @@ -119,7 +119,9 @@ function optionKind(value: unknown): PersonalOptionKind { return kind; } function target(value: unknown): string | null { - return value === null ? null : boundedId(value, "target id").trim(); + const id = value === null ? null : boundedId(value, "target id").trim(); + if (id === "-") throw Error("reserved target id"); + return id; } export function normalizeOptionArgs( diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index 6cc52b1..c171a5c 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -22,26 +22,26 @@ import { snapshotDigest, } from "./judgment-validation.ts"; -export type ArbitrationPolicy = { +export type ArbitrationPolicy = Readonly<{ policyId: string; revision: number; ratio: number; - orders: Record; - lambda: Record; + orders: Readonly>; + lambda: Readonly>; stanceOrder: readonly Stance[]; -}; -export const PERSONAL_POLICY_V1: ArbitrationPolicy = { +}>; +export const PERSONAL_POLICY_V1: ArbitrationPolicy = Object.freeze({ policyId: "personal.v1", revision: 1, ratio: 0.5, - orders: { - user_request: ["atropos", "clotho", "lachesis"], - autonomous: ["lachesis", "clotho", "atropos"], - transition: ["clotho", "atropos", "lachesis"], - }, - lambda: { user_request: 0, autonomous: 1, transition: 1 }, - stanceOrder: ["prefer", "accept", "oppose"], -}; + orders: Object.freeze({ + user_request: Object.freeze(["atropos", "clotho", "lachesis"] as const), + autonomous: Object.freeze(["lachesis", "clotho", "atropos"] as const), + transition: Object.freeze(["clotho", "atropos", "lachesis"] as const), + }), + lambda: Object.freeze({ user_request: 0, autonomous: 1, transition: 1 }), + stanceOrder: Object.freeze(["prefer", "accept", "oppose"] as const), +}); export type HostEligibility = Array<{ optionKey: OptionKey; eligible: boolean; diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 20dc442..485125a 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -253,6 +253,13 @@ export class JudgmentStore { parsed.snapshotDigest !== round.snapshotDigest ) throw Error("assessment snapshot mismatch"); + const objective = round.snapshot.objectiveProfileRefs[parsed.moduleKind]; + if ( + parsed.objectiveRef.objectiveId !== objective.objectiveId || + parsed.objectiveRef.revision !== objective.revision || + parsed.objectiveRef.digest !== objective.digest + ) + throw Error("assessment objective mismatch"); if ( this.db .prepare( @@ -310,6 +317,11 @@ export class JudgmentStore { this.transaction(() => { const round = this.requireOpenRound(id); if (parsed.roundId !== id) throw Error("resolution round mismatch"); + if ( + parsed.situation !== round.snapshot.situation || + parsed.policyRevision !== round.snapshot.policyRevision + ) + throw Error("resolution snapshot mismatch"); if ( (parsed.status === "resolved") !== (selection !== null) || (selection && @@ -317,6 +329,24 @@ export class JudgmentStore { selection.snapshotDigest !== round.snapshotDigest)) ) throw Error("selection spec mismatch"); + if (selection) { + const set = this.assessmentSet(id); + if (selection.assessmentSetDigest !== judgmentDigest(set)) + throw Error("selection assessment set mismatch"); + if (selection.resolutionDigest !== judgmentDigest(parsed)) + throw Error("selection resolution digest mismatch"); + if ( + selection.policyId !== parsed.policyId || + selection.policyRevision !== parsed.policyRevision || + selection.situation !== parsed.situation + ) + throw Error("selection policy mismatch"); + if ( + canonicalJson(selection.objectiveProfileRefs) !== + canonicalJson(round.snapshot.objectiveProfileRefs) + ) + throw Error("selection objective refs mismatch"); + } const now = this.now(); this.db .prepare( diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 8a6214d..fe6a518 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -157,22 +157,30 @@ function jsonObject(value: unknown): JsonObject { ); } -function canonical(item: unknown): unknown { +function canonical(item: unknown, depth = 0): unknown { + if (depth > 64) throw Error("canonical value too deep"); return Array.isArray(item) - ? item.map(canonical) + ? item.map((value) => canonical(value, depth + 1)) : item !== null && typeof item === "object" ? Object.fromEntries( Object.keys(item) .sort() .map((key) => [ key, - canonical((item as Record)[key]), + canonical((item as Record)[key], depth + 1), ]), ) : item; } export function canonicalJson(value: unknown): string { + if ( + value === undefined || + typeof value === "function" || + typeof value === "symbol" || + typeof value === "bigint" + ) + throw Error("unsupported canonical value"); return JSON.stringify(canonical(value)); } @@ -741,17 +749,16 @@ export function parseSelectionSpec(value: unknown): SelectionSpec { return result; } -export const INTENTION_TRANSITIONS: Record< - IntentionStatus, - readonly IntentionStatus[] -> = { - proposed: ["adopted", "cancelled"], - adopted: ["active", "suspended", "cancelled"], - active: ["suspended", "completed", "cancelled"], - suspended: ["active", "cancelled"], - completed: [], - cancelled: [], -}; +export const INTENTION_TRANSITIONS: Readonly< + Record +> = Object.freeze({ + proposed: Object.freeze(["adopted", "cancelled"] as const), + adopted: Object.freeze(["active", "suspended", "cancelled"] as const), + active: Object.freeze(["suspended", "completed", "cancelled"] as const), + suspended: Object.freeze(["active", "cancelled"] as const), + completed: Object.freeze([]), + cancelled: Object.freeze([]), +}); export function parseIntentionTransition(value: unknown): IntentionTransition { const row = fields( @@ -903,9 +910,18 @@ export function transitionIntention( record: IntentionRecord, transition: Omit, ): IntentionRecord { + for (const key of Reflect.ownKeys(transition)) + if ( + typeof key !== "string" || + !["to", "reason", "evidenceRef", "at"].includes(key) + ) + throw Error("invalid intention transition"); const entry = parseIntentionTransition({ from: record.status, - ...transition, + to: transition.to, + reason: transition.reason, + evidenceRef: transition.evidenceRef, + at: transition.at, }); validateTransition(entry, record.acceptance.sourceRef); if (record.history.length >= MAX_LIST) diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 68ffcb6..bcce089 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -34,6 +34,7 @@ import { snapshotDigest, transitionIntention, } from "../src/agents/index.ts"; +import { canonicalJson } from "../src/agents/judgment-validation.ts"; const profile: ObjectiveProfile = { schemaVersion: 1, @@ -345,6 +346,21 @@ test("digests canonicalize nested object keys, preserve arrays, and omit undefin expect(assessmentInputDigest(assessment)).toBe(assessment.inputDigest); }); +test("canonical JSON rejects unsupported top-level values", () => { + for (const value of [undefined, () => 1, Symbol("value"), 1n]) + expect(() => canonicalJson(value)).toThrow("unsupported canonical value"); +}); + +test("canonical JSON bounds nesting and rejects cycles clearly", () => { + let value: unknown = null; + for (let depth = 0; depth < 64; depth += 1) value = [value]; + expect(canonicalJson(value)).toBe(JSON.stringify(value)); + expect(() => canonicalJson([value])).toThrow("canonical value too deep"); + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + expect(() => canonicalJson(cyclic)).toThrow("canonical value too deep"); +}); + test("snapshot opaque refs and independently bounded revisions", () => { for (const field of ["frozenNeuralRef", "observationRef"] as const) { for (const ref of [null, "any-opaque-id"]) @@ -622,6 +638,70 @@ test("intention transitions complete the legal lifecycle without mutating input" ).toThrow(`invalid intention transition: completed -> ${to}`); }); +test("caller-supplied from cannot skip intention states", () => { + const forged = { + ...transition, + from: "active", + to: "completed" as const, + evidenceRef: "outcome-1", + }; + expect(() => transitionIntention(intention, forged)).toThrow( + /intention transition/, + ); + expect(intention.status).toBe("proposed"); + expect(intention.history).toEqual([]); +}); + +test("intention changes reject unknown fields", () => { + const change = { + to: "adopted" as const, + reason: "accepted", + evidenceRef: null, + at: transition.at, + }; + for (const extra of [{ unexpected: true }, { from: "proposed" }]) + expect(() => + transitionIntention(intention, { ...change, ...extra }), + ).toThrow(/intention transition/); +}); + +test("intention transition table is frozen at every level", () => { + expect(Object.isFrozen(INTENTION_TRANSITIONS)).toBe(true); + for (const edges of Object.values(INTENTION_TRANSITIONS)) + expect(Object.isFrozen(edges)).toBe(true); +}); + +test("assignment cannot enable a forbidden intention edge", () => { + const original = INTENTION_TRANSITIONS.proposed; + try { + expect(Reflect.set(INTENTION_TRANSITIONS, "proposed", ["completed"])).toBe( + false, + ); + expect(Reflect.set(original, "0", "completed")).toBe(false); + const change = { + to: "completed" as const, + reason: "done", + evidenceRef: "outcome-1", + at: transition.at, + }; + expect(() => transitionIntention(intention, change)).toThrow( + "invalid intention transition", + ); + expect(() => + parseIntentionRecord({ + ...intention, + revision: 1, + status: "completed", + history: [{ ...change, from: "proposed" }], + }), + ).toThrow("invalid intention transition"); + expect(parseIntentionRecord(adopt()).status).toBe("adopted"); + } finally { + Reflect.set(INTENTION_TRANSITIONS, "proposed", original); + Reflect.set(original, "0", "adopted"); + } +}); + test("intention table and all prohibited edges are explicit", () => { expect(INTENTION_TRANSITIONS).toEqual({ proposed: ["adopted", "cancelled"], diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts index 1a1157b..1c2232e 100644 --- a/packages/lina-core/test/judgment-policy.test.ts +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -313,6 +313,21 @@ test("(1) canonical keys normalize case, target, sorted args, NFC, whitespace an expect(canonicalOptionKey({ ...base, targetId: null })).toContain(":-:"); }); +test("reserved target sentinel is rejected without changing null keys", () => { + const base = option("target"); + const untargeted = buildCanonicalOption({ ...base, targetId: null }); + expect(untargeted.targetId).toBeNull(); + expect(untargeted.optionKey).toContain(":noop:-:"); + expect(parseCanonicalOption(untargeted)).toEqual(untargeted); + for (const targetId of ["-", " - "]) + for (const action of [ + () => canonicalOptionKey({ ...base, targetId }), + () => buildCanonicalOption({ ...base, targetId }), + () => parseCanonicalOption({ ...untargeted, targetId }), + ]) + expect(action).toThrow("reserved target id"); +}); + test("(2) golden option parses byte-identically and rejects invalid boundary data", () => { const golden = { schemaVersion: 1, @@ -776,6 +791,51 @@ test("(10) asymmetric and extreme log-space weights remain finite and normalized } }); +test("personal policy is frozen at every level", () => { + for (const value of [ + PERSONAL_POLICY_V1, + PERSONAL_POLICY_V1.orders, + ...Object.values(PERSONAL_POLICY_V1.orders), + PERSONAL_POLICY_V1.lambda, + PERSONAL_POLICY_V1.stanceOrder, + ]) + expect(Object.isFrozen(value)).toBe(true); +}); + +test("assignment cannot change personal policy arbitration", () => { + const input = fixture( + "autonomous", + MODULE_KINDS.map((m) => option(m)), + (m, o) => opinion(m === o.targetId ? "prefer" : "accept"), + ); + const before = resolve(input); + const changes: Array<[object, string, unknown]> = [ + [PERSONAL_POLICY_V1, "ratio", 0.25], + [PERSONAL_POLICY_V1, "revision", 999], + [PERSONAL_POLICY_V1, "policyId", "other"], + [PERSONAL_POLICY_V1, "orders", {}], + [PERSONAL_POLICY_V1, "lambda", {}], + [PERSONAL_POLICY_V1, "stanceOrder", []], + [ + PERSONAL_POLICY_V1.orders, + "autonomous", + ["atropos", "clotho", "lachesis"], + ], + [PERSONAL_POLICY_V1.orders.autonomous, "0", "atropos"], + [PERSONAL_POLICY_V1.lambda, "autonomous", 99], + [PERSONAL_POLICY_V1.stanceOrder, "0", "oppose"], + ]; + for (const [target, key, value] of changes) { + const original: unknown = Reflect.get(target, key); + try { + expect(Reflect.set(target, key, value)).toBe(false); + expect(resolve(input)).toEqual(before); + } finally { + Reflect.set(target, key, original); + } + } +}); + test("policy public type exposes exactly the revision-one declaration", () => { const policy: ArbitrationPolicy = PERSONAL_POLICY_V1; expect(policy).toEqual({ diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index 7aa754f..0895636 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -443,6 +443,158 @@ test("assessments require an open matching snapshot and exactly one of each modu ); }); +for (const change of [ + { objectiveId: "unregistered-objective" }, + { revision: 999 }, + { digest: "wrong-objective-digest" }, +]) { + test(`assessment must match frozen objective ${Object.keys(change)[0]}`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + const original = assessment(ref, "clotho"); + const changed = { + ...original, + objectiveRef: { ...original.objectiveRef, ...change }, + }; + const invalid = parseAssessment({ + ...changed, + inputDigest: assessmentInputDigest(changed), + }); + expect(() => store.putAssessment(invalid)).toThrow( + "assessment objective mismatch", + ); + expect( + database().prepare("SELECT count(*) AS n FROM assessments").get(), + ).toEqual({ n: 0 }); + store.putAssessment(original); + }); +} + +for (const status of ["resolved", "held", "deferred"] as const) { + for (const change of [ + { situation: "autonomous" as const }, + { policyRevision: 999 }, + ]) { + test(`${status} resolution must match snapshot ${Object.keys(change)[0]}`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const record = { ...resolution(ref, status), ...change }; + expect(() => + store.recordResolution( + ref.roundId, + record, + status === "resolved" ? spec(ref, record) : null, + ), + ).toThrow("resolution snapshot mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + }); + } +} + +test("resolved round requires all three persisted assessments", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + store.putAssessment(assessment(ref, "clotho")); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + ).toThrow("incomplete assessment set"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); +}); + +const selectionMismatches: Array<{ + label: string; + change: (selection: SelectionSpec) => Partial; + error: string; +}> = [ + { + label: "assessment set digest", + change: () => ({ assessmentSetDigest: "nonexistent-set" }), + error: "selection assessment set mismatch", + }, + { + label: "resolution digest", + change: () => ({ resolutionDigest: "wrong-resolution" }), + error: "selection resolution digest mismatch", + }, + { + label: "policy id", + change: () => ({ policyId: "other-policy" }), + error: "selection policy mismatch", + }, + { + label: "policy revision", + change: () => ({ policyRevision: 999 }), + error: "selection policy mismatch", + }, + { + label: "situation", + change: () => ({ situation: "autonomous" }), + error: "selection policy mismatch", + }, + ...[ + { objectiveId: "other-objective" }, + { revision: 999 }, + { digest: "wrong-objective" }, + ].map((change) => ({ + label: `objective ${Object.keys(change)[0]}`, + change: (selection: SelectionSpec) => ({ + objectiveProfileRefs: { + ...selection.objectiveProfileRefs, + clotho: { ...selection.objectiveProfileRefs.clotho, ...change }, + }, + }), + error: "selection objective refs mismatch", + })), +]; +for (const { label, change, error } of selectionMismatches) { + test(`selection rejects inconsistent ${label} atomically`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const selection = spec(ref); + const changed = { ...selection, ...change(selection) }; + const invalid = parseSelectionSpec({ + ...changed, + specDigest: judgmentDigest({ ...changed, specDigest: undefined }), + }); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), invalid), + ).toThrow(error); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + store.recordResolution(ref.roundId, resolution(ref), selection); + expect(store.getRound(ref.roundId)?.status).toBe("resolved"); + expect(store.getResolution(ref.roundId)).toEqual(resolution(ref)); + expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); + }); +} + +test("assessment and selection use frozen refs after objective activation changes", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + const next = store.putObjectiveProfile(profile("clotho", 2)); + store.activateObjectiveProfile(ref.agentId, ref.scopeId, next); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const selection = spec(ref); + expect(selection.assessmentSetDigest).toBe( + judgmentDigest(store.assessmentSet(ref.roundId)), + ); + store.recordResolution(ref.roundId, resolution(ref), selection); + expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); + expect(store.getRound(ref.roundId)?.status).toBe("resolved"); +}); + test("resolution and selection persist atomically and survive reopen with canonical JSON", () => { let store = open(); const ref = snapshot(store); @@ -524,6 +676,7 @@ test("SQLite failure during resolution rolls back the already inserted resolutio const store = open(); const ref = snapshot(store); store.openRound(ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); const db = database(); db.exec( "CREATE TRIGGER fail_spec BEFORE INSERT ON selection_specs BEGIN SELECT RAISE(ABORT, 'fixture spec failure'); END", @@ -647,6 +800,7 @@ test("external records are reparsed and malformed persisted bodies are rejected expect(action).toThrow(); const db = database(); store.putIntention(intention()); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); store.recordResolution(ref.roundId, resolution(ref), spec(ref)); for (const [table, column, read] of [ [ From 8cf0198d9b9c501948d26224d4b47ab6ba2206c6 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:36:40 +0900 Subject: [PATCH 15/47] test(core): prove judgment canonical JSON bounds through the agents barrel only (F1 audit r2) --- packages/lina-core/test/judgment-contract.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index bcce089..243f7ac 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { ACCEPTED_BY, type Assessment, @@ -34,7 +35,6 @@ import { snapshotDigest, transitionIntention, } from "../src/agents/index.ts"; -import { canonicalJson } from "../src/agents/judgment-validation.ts"; const profile: ObjectiveProfile = { schemaVersion: 1, @@ -348,17 +348,19 @@ test("digests canonicalize nested object keys, preserve arrays, and omit undefin test("canonical JSON rejects unsupported top-level values", () => { for (const value of [undefined, () => 1, Symbol("value"), 1n]) - expect(() => canonicalJson(value)).toThrow("unsupported canonical value"); + expect(() => judgmentDigest(value)).toThrow("unsupported canonical value"); }); test("canonical JSON bounds nesting and rejects cycles clearly", () => { let value: unknown = null; for (let depth = 0; depth < 64; depth += 1) value = [value]; - expect(canonicalJson(value)).toBe(JSON.stringify(value)); - expect(() => canonicalJson([value])).toThrow("canonical value too deep"); + expect(judgmentDigest(value)).toBe( + createHash("sha256").update(JSON.stringify(value)).digest("hex"), + ); + expect(() => judgmentDigest([value])).toThrow("canonical value too deep"); const cyclic: { self?: unknown } = {}; cyclic.self = cyclic; - expect(() => canonicalJson(cyclic)).toThrow("canonical value too deep"); + expect(() => judgmentDigest(cyclic)).toThrow("canonical value too deep"); }); test("snapshot opaque refs and independently bounded revisions", () => { From c37851c6b6ce9fcf928febd1d27f15381a10b6ed Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:32:15 +0900 Subject: [PATCH 16/47] fix(core): preserve persona projection allowlists Keep private LIFE dimensions out of derived persona schemas while retaining source ID uniqueness and existing fingerprints. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/persona-schema.ts | 100 ++++++++++-------- .../lina-core/test/persona-schema.test.ts | 40 +++++++ 2 files changed, 95 insertions(+), 45 deletions(-) diff --git a/packages/lina-core/src/agents/persona-schema.ts b/packages/lina-core/src/agents/persona-schema.ts index bb6f427..86b1cac 100644 --- a/packages/lina-core/src/agents/persona-schema.ts +++ b/packages/lina-core/src/agents/persona-schema.ts @@ -95,18 +95,14 @@ function digestValue(value: unknown, label: string): string { } function canonical(item: unknown): unknown { - return Array.isArray(item) - ? item.map(canonical) - : item !== null && typeof item === "object" - ? Object.fromEntries( - Object.keys(item) - .sort() - .map((key) => [ - key, - canonical((item as Record)[key]), - ]), - ) - : item; + if (Array.isArray(item)) return item.map(canonical); + if (item !== null && typeof item === "object") + return Object.fromEntries( + Object.keys(item) + .sort() + .map((key) => [key, canonical((item as Record)[key])]), + ); + return item; } export function personaSchemaDigest( @@ -315,43 +311,57 @@ export function personaSchemaFromLifeDefinition(input: { const schemaRevision = revision(input.revision, "persona schema revision"); const definition = parseLifeDefinition(input.definition); const policy = profileFor(input.identity, agentId); - const dimensions: PersonaDimension[] = [ - ...definition.traits.map((axis) => ({ - id: axis.id, - kind: "trait" as const, - label: axis.label, - source: "reflection" as const, - range: { min: axis.min, max: axis.max }, - initial: axis.initial, - locked: lockedFor(policy, "trait", axis.id), - originAxisId: axis.id, - })), - ...definition.habits.map((habit) => ({ - id: habit.id, - kind: "habit" as const, - label: habit.label, - source: "reflection" as const, - range: null, - initial: habit.initial, - locked: lockedFor(policy, "habit", habit.id), - originAxisId: habit.id, - })), - ...definition.attitudes.map((axis) => ({ - id: axis.id, - kind: "attitude" as const, - label: axis.label, - source: "reflection" as const, - range: { min: axis.min, max: axis.max }, - initial: axis.initial, - locked: lockedFor(policy, "attitude", axis.id), - originAxisId: axis.id, - })), - ].sort(compareDimensions); const ids = new Set(); - for (const dimension of dimensions) { + for (const dimension of [ + ...definition.traits, + ...definition.habits, + ...definition.attitudes, + ]) { if (ids.has(dimension.id)) throw Error("duplicate persona dimension id"); ids.add(dimension.id); } + const dimensions: PersonaDimension[] = [ + ...definition.traits + .filter((axis) => definition.projection.sharedTraitIds.includes(axis.id)) + .map((axis) => ({ + id: axis.id, + kind: "trait" as const, + label: axis.label, + source: "reflection" as const, + range: { min: axis.min, max: axis.max }, + initial: axis.initial, + locked: lockedFor(policy, "trait", axis.id), + originAxisId: axis.id, + })), + ...definition.habits + .filter((habit) => + definition.projection.sharedHabitIds.includes(habit.id), + ) + .map((habit) => ({ + id: habit.id, + kind: "habit" as const, + label: habit.label, + source: "reflection" as const, + range: null, + initial: habit.initial, + locked: lockedFor(policy, "habit", habit.id), + originAxisId: habit.id, + })), + ...definition.attitudes + .filter((axis) => + definition.projection.sharedAttitudeIds.includes(axis.id), + ) + .map((axis) => ({ + id: axis.id, + kind: "attitude" as const, + label: axis.label, + source: "reflection" as const, + range: { min: axis.min, max: axis.max }, + initial: axis.initial, + locked: lockedFor(policy, "attitude", axis.id), + originAxisId: axis.id, + })), + ].sort(compareDimensions); const parsed: Omit = { schemaVersion: 1, agentId, diff --git a/packages/lina-core/test/persona-schema.test.ts b/packages/lina-core/test/persona-schema.test.ts index 2ea372e..67ef6bc 100644 --- a/packages/lina-core/test/persona-schema.test.ts +++ b/packages/lina-core/test/persona-schema.test.ts @@ -138,6 +138,46 @@ test("derivation maps two traits, one habit and one attitude in kind then id ord expect(schema.sourceIdentity).toEqual({ profileRevision: 4 }); }); +test.each([ + { + sharedTraitIds: ["warmth"], + sharedHabitIds: ["tea"], + sharedAttitudeIds: [], + expected: ["warmth", "tea"], + }, + { + sharedTraitIds: [], + sharedHabitIds: [], + sharedAttitudeIds: ["trust"], + expected: ["trust"], + }, + { + sharedTraitIds: [], + sharedHabitIds: [], + sharedAttitudeIds: [], + expected: [], + }, +])("derivation preserves projection allowlists: %j", (projection) => { + const schema = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { + ...definition, + projection: { + ...definition.projection, + sharedTraitIds: [...projection.sharedTraitIds], + sharedHabitIds: [...projection.sharedHabitIds], + sharedAttitudeIds: [...projection.sharedAttitudeIds], + }, + }, + identity: identityV2, + }); + expect(schema.dimensions.map((dimension) => dimension.id)).toEqual([ + ...projection.expected, + ]); + expect(parsePersonaSchema(schema)).toEqual(schema); +}); + test("derivation digest is idempotent and ignores v2-only identity fields", () => { const first = derive(); const second = derive(); From 8cc88f6ced356346b7d5a3a0e47f3bbe09fa2901 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:32:38 +0900 Subject: [PATCH 17/47] fix(core): validate restored context projections Reject invalid source IDs, duplicate provenance, malformed instruction hashes and blank working items. Reject instruction revision overflow while preserving valid maximum revisions. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/context/read-projection.ts | 45 ++++++----- .../test/context-read-projection.test.ts | 81 +++++++++++++++++++ 2 files changed, 104 insertions(+), 22 deletions(-) diff --git a/packages/lina-core/src/context/read-projection.ts b/packages/lina-core/src/context/read-projection.ts index b0fd91b..98245dc 100644 --- a/packages/lina-core/src/context/read-projection.ts +++ b/packages/lina-core/src/context/read-projection.ts @@ -15,6 +15,7 @@ import { WORKING_SOURCES_MAX, type WorkingState, } from "./types.ts"; +import { validId } from "./validation.ts"; /** Identifies the instruction whose text was last projected. */ export interface InstructionRef { @@ -101,7 +102,8 @@ function parseBoundedStringList(value: unknown, field: string): string[] { if (!Array.isArray(value)) reject(`invalid working ${field}`); if (value.length > WORKING_LIST_MAX_ITEMS) reject(`invalid working ${field}`); for (const item of value) { - if (typeof item !== "string") reject(`invalid working ${field}`); + if (typeof item !== "string" || item.trim().length === 0) + reject(`invalid working ${field}`); if (item.length > WORKING_ITEM_MAX_CHARS) reject(`invalid working ${field}`); } @@ -136,10 +138,10 @@ function parseSourceEntryIds(value: unknown): string[] { if (!Array.isArray(value)) reject("invalid working sourceEntryIds"); if (value.length > WORKING_SOURCES_MAX) reject("invalid working sourceEntryIds"); - for (const item of value) { - if (typeof item !== "string") reject("invalid working sourceEntryIds"); - } - return [...(value as string[])]; + const ids = value.map((item) => validId(item, "working sourceEntryIds")); + if (new Set(ids).size !== ids.length) + reject("working sourceEntryIds must be unique"); + return ids; } function parseInstructionRef(value: unknown): InstructionRef { @@ -157,7 +159,7 @@ function parseInstructionRef(value: unknown): InstructionRef { reject("invalid context read projection instruction requestId"); if (!isNonBlankId(entryId)) reject("invalid context read projection instruction entryId"); - if (typeof textDigest !== "string" || textDigest.length === 0) + if (typeof textDigest !== "string" || !/^[0-9a-f]{64}$/.test(textDigest)) reject("invalid context read projection instruction textDigest"); return { requestId, entryId, textDigest }; } @@ -221,22 +223,21 @@ export function buildContextReadProjection(input: { const { working, instruction, previous, projectedAt } = input; validateInput(working, projectedAt, instruction); - const instructionRef: InstructionRef | null = - instruction === null - ? null - : { - requestId: instruction.requestId, - entryId: instruction.entryId, - textDigest: sha256Hex(instruction.text), - }; - - const instructionRevision = - previous === null - ? instructionRef === null - ? 0 - : 1 - : previous.instructionRevision + - (sameInstruction(previous.instruction, instructionRef) ? 0 : 1); + let instructionRef: InstructionRef | null = null; + if (instruction !== null) { + instructionRef = { + requestId: instruction.requestId, + entryId: instruction.entryId, + textDigest: sha256Hex(instruction.text), + }; + } + let instructionRevision = instructionRef === null ? 0 : 1; + if (previous !== null) + instructionRevision = + previous.instructionRevision + + (sameInstruction(previous.instruction, instructionRef) ? 0 : 1); + if (!Number.isSafeInteger(instructionRevision)) + reject("invalid context read projection instructionRevision"); return { schemaVersion: 1, diff --git a/packages/lina-core/test/context-read-projection.test.ts b/packages/lina-core/test/context-read-projection.test.ts index 268463e..d779851 100644 --- a/packages/lina-core/test/context-read-projection.test.ts +++ b/packages/lina-core/test/context-read-projection.test.ts @@ -104,6 +104,29 @@ describe("ContextReadProjection", () => { expect(p1.workingRevision).toBe(p0.workingRevision); }); + it("rejects instruction revision overflow but retains the maximum revision for unchanged input", () => { + const input = { + working: store.working(), + instruction: makeInstruction("overflow-entry", "original"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }; + const previous = { + ...buildContextReadProjection(input), + instructionRevision: Number.MAX_SAFE_INTEGER, + }; + expect(parseContextReadProjection(previous)).toEqual(previous); + const unchanged = buildContextReadProjection({ ...input, previous }); + expect(unchanged).toEqual(previous); + expect(() => + buildContextReadProjection({ + ...input, + instruction: { ...input.instruction, text: "changed" }, + previous, + }), + ).toThrow(/instructionRevision/); + }); + it("increments instructionRevision when text changes for the same entry", () => { const instruction1 = makeInstruction("i1", "hello"); const projectedAt = "2026-09-10T00:00:00.000Z"; @@ -239,6 +262,64 @@ describe("ContextReadProjection", () => { ).toThrow(/invalid context read projection instructionRevision/); }); + it.each([ + [""], + [" "], + ["entry\0id"], + ["x".repeat(100_000)], + ["entry-1", "entry-1"], + ])("rejects invalid restored source identifiers: %j", (...sourceEntryIds) => { + const projection = buildContextReadProjection({ + working: store.working(), + instruction: null, + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }); + expect(() => + parseContextReadProjection({ + ...projection, + working: { ...projection.working, sourceEntryIds }, + }), + ).toThrow(/sourceEntryIds/); + }); + + it.each(["bogus", "a".repeat(63), "a".repeat(65), "g".repeat(64)])( + "rejects invalid restored instruction digest: %s", + (textDigest) => { + const projection = buildContextReadProjection({ + working: store.working(), + instruction: makeInstruction("digest-entry", "hello"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }); + expect(() => + parseContextReadProjection({ + ...projection, + instruction: { ...projection.instruction, textDigest }, + }), + ).toThrow(/instruction textDigest/); + }, + ); + + it.each(["decisions", "openItems", "nextSteps"] as const)( + "rejects blank restored working %s", + (field) => { + const projection = buildContextReadProjection({ + working: store.working(), + instruction: null, + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }); + for (const item of ["", " \n\t "]) + expect(() => + parseContextReadProjection({ + ...projection, + working: { ...projection.working, [field]: [item] }, + }), + ).toThrow(/invalid working/); + }, + ); + it("rejects non-ISO projectedAt in builder and parser", () => { const working = store.working(); const instruction = makeInstruction("i1", "hello"); From 4512d67c7319ca69b80bea16cc4f7b8e2a3497da Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:32:59 +0900 Subject: [PATCH 18/47] fix(core): bind judgment actions and frozen provenance Bind option keys to execution semantics and scope. Freeze policy identity with snapshots, reject stale objectives and foreign candidates, verify stored snapshot digests, bound neural bias and require original acceptance on resume. Normalize geometric rank weights without underflow. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-catalog.ts | 28 ++- .../lina-core/src/agents/judgment-policy.ts | 27 ++- .../lina-core/src/agents/judgment-store.ts | 6 +- .../src/agents/judgment-validation.ts | 10 +- packages/lina-core/src/agents/judgment.ts | 1 + .../test/judgment-catalog-review.test.ts | 196 ++++++++++++++++ .../lina-core/test/judgment-contract.test.ts | 37 ++- .../test/judgment-integrity-review.test.ts | 94 ++++++++ .../test/judgment-policy-review.test.ts | 217 ++++++++++++++++++ .../lina-core/test/judgment-policy.test.ts | 52 ++--- .../lina-core/test/judgment-round.test.ts | 1 + .../lina-core/test/judgment-store.test.ts | 53 +++++ 12 files changed, 684 insertions(+), 38 deletions(-) create mode 100644 packages/lina-core/test/judgment-catalog-review.test.ts create mode 100644 packages/lina-core/test/judgment-integrity-review.test.ts create mode 100644 packages/lina-core/test/judgment-policy-review.test.ts diff --git a/packages/lina-core/src/agents/judgment-catalog.ts b/packages/lina-core/src/agents/judgment-catalog.ts index 1fbf11c..7a6f3ce 100644 --- a/packages/lina-core/src/agents/judgment-catalog.ts +++ b/packages/lina-core/src/agents/judgment-catalog.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; import type { OptionKey } from "./judgment.ts"; +import { judgmentDigest } from "./judgment-validation.ts"; import { boundedId, boundedText } from "./validation.ts"; export const PERSONAL_CATALOG_ID = "personal.v1" as const; @@ -146,10 +146,26 @@ export function normalizeOptionArgs( export function canonicalOptionKey( option: Omit, ): OptionKey { - const hash = createHash("sha256") - .update(JSON.stringify(normalizeOptionArgs(option.args))) - .digest("hex"); - return `${option.catalogId}:${optionKind(option.kind)}:${target(option.targetId) ?? "-"}:${hash}`; + const kind = optionKind(option.kind); + const targetId = target(option.targetId); + // Bind execution semantics, not just display arguments, in sorted JSON. + const hash = judgmentDigest({ + schemaVersion: option.schemaVersion, + catalogId: option.catalogId, + kind, + actor: { + agentId: boundedId(option.actor.agentId, "agent id"), + scopeId: boundedId(option.actor.scopeId, "scope id"), + }, + targetId, + args: normalizeOptionArgs(option.args), + preconditions: parsePrecondition(option.preconditions, kind), + effect: { + owner: option.effect.owner, + scope: boundedId(option.effect.scope, "effect scope"), + }, + }); + return `${option.catalogId}:${kind}:${targetId ?? "-"}:${hash}`; } function parsePrecondition( @@ -305,6 +321,8 @@ export function parseCanonicalOption(value: unknown): CanonicalOption { }, optionKey: boundedId(row.optionKey, "option key"), }; + if (result.effect.scope !== result.actor.scopeId) + throw Error("option effect scope mismatch"); if (result.optionKey !== canonicalOptionKey(result)) throw Error("canonical option key mismatch"); return result; diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index c171a5c..f8c049f 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -52,16 +52,19 @@ export type HostEligibility = Array<{ export function rankMass(ranks: number[], ratio: number): number[] { if (!Number.isFinite(ratio) || ratio <= 0) throw Error("invalid rank ratio"); const counts = new Map(); + let anchor = ranks[0] ?? 1; for (const rank of ranks) { if (!Number.isSafeInteger(rank) || rank < 1) throw Error("invalid rank"); counts.set(rank, (counts.get(rank) ?? 0) + 1); + anchor = ratio <= 1 ? Math.min(anchor, rank) : Math.max(anchor, rank); } + // Divide all weights by the largest geometric weight before summing. const total = [...counts.keys()].reduce( - (sum, rank) => sum + ratio ** (rank - 1), + (sum, rank) => sum + ratio ** (rank - anchor), 0, ); return ranks.map( - (rank) => ratio ** (rank - 1) / total / (counts.get(rank) ?? 1), + (rank) => ratio ** (rank - anchor) / total / (counts.get(rank) ?? 1), ); } @@ -97,6 +100,26 @@ export function resolvePersonalRound(input: { const digest = snapshotDigest(snapshot); if (set.roundId !== snapshot.roundId || set.snapshotDigest !== digest) throw Error("assessment set snapshot mismatch"); + if ( + policy.policyId !== snapshot.policyId || + policy.revision !== snapshot.policyRevision + ) + throw Error("policy snapshot mismatch"); + for (const assessment of set.assessments) { + const objective = snapshot.objectiveProfileRefs[assessment.moduleKind]; + if ( + assessment.objectiveRef.objectiveId !== objective.objectiveId || + assessment.objectiveRef.revision !== objective.revision || + assessment.objectiveRef.digest !== objective.digest + ) + throw Error("assessment objective mismatch"); + if ( + assessment.proposedOptionKeys.some((key) => !keys.has(key)) || + assessment.objectiveAssessments.some((o) => !keys.has(o.optionKey)) || + assessment.recommendedOptionKeys.some((key) => !keys.has(key)) + ) + throw Error("unknown assessment option key"); + } const order = [...policy.orders[snapshot.situation]]; const record: ResolutionRecord = { schemaVersion: 1, diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 485125a..6762762 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -236,8 +236,11 @@ export class JudgmentStore { .get(boundedId(roundId, "round id")); if (!row) return null; const { snapshot, status, snapshot_digest: digest } = row; + const parsed = parseJudgmentSnapshotRef(JSON.parse(String(snapshot))); + if (snapshotDigest(parsed) !== digest) + throw Error("snapshot digest mismatch"); return { - snapshot: parseJudgmentSnapshotRef(JSON.parse(String(snapshot))), + snapshot: parsed, status: status as RoundStatus, snapshotDigest: String(digest), }; @@ -319,6 +322,7 @@ export class JudgmentStore { if (parsed.roundId !== id) throw Error("resolution round mismatch"); if ( parsed.situation !== round.snapshot.situation || + parsed.policyId !== round.snapshot.policyId || parsed.policyRevision !== round.snapshot.policyRevision ) throw Error("resolution snapshot mismatch"); diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index fe6a518..b8a440c 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -268,6 +268,7 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { "sourceRefs", "workingRevision", "instructionRevision", + "policyId", "policyRevision", "identityRevision", "domainRevisions", @@ -311,6 +312,7 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { row["instructionRevision"], "instruction revision", ), + policyId: boundedId(row["policyId"], "policy id"), policyRevision: revision(row["policyRevision"], "policy revision"), identityRevision: revision(row["identityRevision"], "identity revision"), domainRevisions: Object.fromEntries( @@ -689,10 +691,12 @@ export function parseSelectionSpec(value: unknown): SelectionSpec { ); const p0 = finite(item["p0"], "p0"); if (p0 < 0) throw Error("invalid p0"); + const b = item["b"] === null ? null : finite(item["b"], "bias"); + if (b !== null && (b < -1 || b > 1)) throw Error("invalid bias"); return { optionKey: boundedId(item["optionKey"], "option key"), p0, - b: item["b"] === null ? null : finite(item["b"], "bias"), + b, }; }, "selection candidates", @@ -785,7 +789,9 @@ function validateTransition( if (to === "completed" && evidenceRef === null) throw Error("completed intention requires outcome ref"); if ( - (to === "cancelled" || to === "suspended") && + (to === "cancelled" || + to === "suspended" || + (from === "suspended" && to === "active")) && evidenceRef !== acceptanceSourceRef ) throw Error("intention change requires original acceptance ref"); diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts index 5d4ea92..e7561f5 100644 --- a/packages/lina-core/src/agents/judgment.ts +++ b/packages/lina-core/src/agents/judgment.ts @@ -65,6 +65,7 @@ export type JudgmentSnapshotRef = { sourceRefs: SourceRef[]; workingRevision: number; instructionRevision: number; + policyId: string; policyRevision: number; identityRevision: number; domainRevisions: Record; diff --git a/packages/lina-core/test/judgment-catalog-review.test.ts b/packages/lina-core/test/judgment-catalog-review.test.ts new file mode 100644 index 0000000..1dc3194 --- /dev/null +++ b/packages/lina-core/test/judgment-catalog-review.test.ts @@ -0,0 +1,196 @@ +import { expect, test } from "bun:test"; +import { + buildCanonicalOption, + type CanonicalOption, + canonicalOptionKey, + PERSONAL_OPTION_KINDS, + type PersonalOptionKind, + type PersonalPrecondition, + parseCanonicalOption, +} from "../src/agents/index.ts"; + +const preconditions = [ + { + kind: "intention.adopt", + intentionKind: "user_commitment", + sourceRef: "source", + }, + { kind: "intention.activate", intentionId: "intention" }, + { + kind: "intention.suspend", + intentionId: "intention", + acceptanceSourceRef: "acceptance", + reason: "pause work", + }, + { + kind: "intention.resume", + intentionId: "intention", + acceptanceSourceRef: "acceptance", + reason: "resume work", + }, + { + kind: "intention.cancel", + intentionId: "intention", + acceptanceSourceRef: "acceptance", + authorityRef: "authority", + userConfirmationRef: null, + }, + { + kind: "intention.complete", + intentionId: "intention", + outcomeRef: "outcome", + }, + { + kind: "task.start", + authorityRef: "authority", + taskText: "perform work", + intentionId: "intention", + }, + { kind: "task.send", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "task.interrupt", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "task.handover", taskId: "task", ownerId: "owner", revision: 1 }, + { kind: "inquire", authorityRef: "authority" }, + { kind: "defer", resumeCondition: "input arrives" }, + { kind: "noop", reason: "nothing needed" }, +] satisfies PersonalPrecondition[]; + +function option(precondition: PersonalPrecondition): CanonicalOption { + return buildCanonicalOption({ + kind: precondition.kind, + actor: { agentId: "agent", scopeId: "scope" }, + targetId: "target", + args: {}, + preconditions: precondition, + }); +} + +test("identity fixtures cover every personal option kind", () => { + expect(preconditions.map((p) => p.kind)).toEqual([...PERSONAL_OPTION_KINDS]); +}); + +for (const precondition of preconditions) { + for (const [field, value] of Object.entries(precondition)) { + if (field === "kind") continue; + test(`${precondition.kind} identity binds preconditions.${field}`, () => { + const original = option(precondition); + const changedValue = + field === "intentionKind" + ? "autonomous_goal" + : typeof value === "number" + ? value + 1 + : `${value ?? "confirmation"}-changed`; + const changed = buildCanonicalOption({ + ...original, + preconditions: { ...precondition, [field]: changedValue }, + }); + expect(changed.optionKey).not.toBe(original.optionKey); + expect(parseCanonicalOption(changed)).toEqual(changed); + expect(() => + parseCanonicalOption({ ...changed, optionKey: original.optionKey }), + ).toThrow(); + }); + } + + test(`${precondition.kind} keys preserve normalized equivalence and ignore insertion order`, () => { + const original = buildCanonicalOption({ + ...option(precondition), + args: { a: "\u00e9 x", z: "last" }, + }); + const reordered = { + ...original, + kind: original.kind.toUpperCase() as PersonalOptionKind, + targetId: " target ", + actor: { scopeId: "scope", agentId: "agent" }, + args: { z: " last ", empty: " \t ", a: " e\u0301 x " }, + preconditions: Object.fromEntries( + Object.entries(precondition).reverse(), + ) as PersonalPrecondition, + effect: { scope: "scope", owner: original.effect.owner }, + }; + expect(canonicalOptionKey(reordered)).toBe(original.optionKey); + expect(buildCanonicalOption(reordered)).toEqual(original); + expect(parseCanonicalOption(reordered)).toEqual(original); + expect( + canonicalOptionKey({ + ...original, + optionKey: "ignored", + } as CanonicalOption), + ).toBe(original.optionKey); + }); + + test(`${precondition.kind} rejects an effect outside the actor scope even with a new key`, () => { + const original = option(precondition); + const changed = { + ...original, + effect: { ...original.effect, scope: "other-scope" }, + }; + expect(() => + parseCanonicalOption({ + ...changed, + optionKey: canonicalOptionKey(changed), + }), + ).toThrow(); + }); +} + +for (const field of ["agentId", "scopeId"] as const) { + test(`identity binds actor.${field}`, () => { + const original = option({ kind: "noop", reason: "nothing needed" }); + const changed = buildCanonicalOption({ + ...original, + actor: { ...original.actor, [field]: "other" }, + }); + expect(changed.optionKey).not.toBe(original.optionKey); + expect(() => + parseCanonicalOption({ ...changed, optionKey: original.optionKey }), + ).toThrow(); + }); +} + +for (const effect of [ + { owner: "host", scope: "scope" }, + { owner: "none", scope: "other-scope" }, +] as const) { + test(`identity binds effect ${JSON.stringify(effect)}`, () => { + const original = option({ kind: "noop", reason: "nothing needed" }); + const changed = { ...original, effect }; + expect(canonicalOptionKey(changed)).not.toBe(original.optionKey); + expect(() => + parseCanonicalOption({ + ...changed, + optionKey: canonicalOptionKey(changed), + }), + ).toThrow(); + }); +} + +test("keys retain catalog/kind/target prefix, argument sensitivity and null sentinel", () => { + const original = option({ kind: "noop", reason: "nothing needed" }); + expect(original.optionKey).toMatch(/^personal\.v1:noop:target:[a-f0-9]{64}$/); + expect(canonicalOptionKey({ ...original, targetId: null })).toMatch( + /^personal\.v1:noop:-:[a-f0-9]{64}$/, + ); + expect(canonicalOptionKey({ ...original, targetId: "other" })).not.toBe( + original.optionKey, + ); + expect( + canonicalOptionKey({ ...original, args: { text: "different" } }), + ).not.toBe(original.optionKey); + expect(() => canonicalOptionKey({ ...original, targetId: " - " })).toThrow(); +}); + +test("precondition text is not normalized like display arguments", () => { + const original = option({ + kind: "task.start", + authorityRef: "authority", + taskText: "perform work", + intentionId: "intention", + }); + const changed = option({ + kind: "task.start", + authorityRef: "authority", + taskText: "perform work", + intentionId: "intention", + }); + expect(changed.optionKey).not.toBe(original.optionKey); +}); diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 243f7ac..491d495 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -53,6 +53,7 @@ const snapshot: JudgmentSnapshotRef = { sourceRefs: [{ kind: "request", id: "request-1", revision: 0 }], workingRevision: 2, instructionRevision: 1, + policyId: "personal.v1", policyRevision: 1, identityRevision: 1, domainRevisions: { life: 0 }, @@ -399,6 +400,38 @@ test("snapshot opaque refs and independently bounded revisions", () => { ).toThrow(/unknown/); }); +test("snapshot requires a bounded policy identity without a fallback", () => { + const { policyRevision: _revision, ...withoutRevision } = snapshot; + expect(() => parseJudgmentSnapshotRef(withoutRevision)).toThrow(); + for (const policyId of [undefined, null, "", " ", 42, "x".repeat(161)]) + expect(() => parseJudgmentSnapshotRef({ ...snapshot, policyId })).toThrow(); + const withPolicy = { ...snapshot, policyId: "personal.v1" }; + const { policyId: _policyId, ...withoutPolicy } = withPolicy; + expect(() => parseJudgmentSnapshotRef(withoutPolicy)).toThrow(); + expect(parseJudgmentSnapshotRef(withPolicy)).toEqual(withPolicy); + expect(snapshotDigest({ ...withPolicy, policyId: "other-policy" })).not.toBe( + snapshotDigest(withPolicy), + ); +}); + +test("selection bias is bounded even for excluded candidates and zero lambda", () => { + for (const b of [-1 - Number.EPSILON, 1 + Number.EPSILON, -2, 2]) { + for (const p0 of [0, 1]) { + const candidates = [ + { optionKey: "a", p0, b }, + { optionKey: "b", p0: 1 - p0, b: null }, + ]; + expect(() => parseSelectionSpec(signedSpec({ candidates }))).toThrow( + "invalid bias", + ); + } + } + for (const b of [null, -1, -0.5, 0, 0.5, 1]) { + const valid = signedSpec({ candidates: [{ optionKey: "a", p0: 1, b }] }); + expect(parseSelectionSpec(valid)).toEqual(valid); + } +}); + test("bounded text, ids, lists, uniqueness and canonical list order", () => { for (const objective of ["", " ", "x\u0000y", "x".repeat(1001)]) expect(() => parseObjectiveProfile({ ...profile, objective })).toThrow(); @@ -534,7 +567,7 @@ test("selection rejects invalid mass, lambda, bias, order, duplicates, and tampe expect(() => parseSelectionSpec({ ...spec, specDigest: "wrong" })).toThrow(); const withExcluded = signedSpec({ candidates: [ - { optionKey: "a", p0: 1, b: -2 }, + { optionKey: "a", p0: 1, b: -1 }, { optionKey: "b", p0: 0, b: null }, ], }); @@ -605,7 +638,7 @@ test("intention transitions complete the legal lifecycle without mutating input" const evidenceRef = to === "completed" ? "outcome-1" - : to === "suspended" + : to === "suspended" || record.status === "suspended" ? intention.acceptance.sourceRef : null; const next = transitionIntention(record, { diff --git a/packages/lina-core/test/judgment-integrity-review.test.ts b/packages/lina-core/test/judgment-integrity-review.test.ts new file mode 100644 index 0000000..1d28f6f --- /dev/null +++ b/packages/lina-core/test/judgment-integrity-review.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { + type IntentionRecord, + parseIntentionRecord, + transitionIntention, +} from "../src/agents/index.ts"; + +const at = "2026-09-12T00:00:00.000Z"; +function suspendedIntention(): IntentionRecord { + let record: IntentionRecord = { + schemaVersion: 1, + intentionId: "integrity-intention", + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete the accepted task", + acceptance: { + sourceRef: "original-request", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }; + for (const to of ["adopted", "active", "suspended"] as const) + record = transitionIntention(record, { + to, + reason: "Fixture transition", + evidenceRef: to === "suspended" ? record.acceptance.sourceRef : null, + at, + }); + return record; +} + +for (const evidenceRef of [null, "unrelated-request"]) { + test(`3995131658: pure resume rejects ${String(evidenceRef)} evidence`, () => { + const record = suspendedIntention(); + const before = structuredClone(record); + expect(() => + transitionIntention(record, { + to: "active", + reason: "Resume", + evidenceRef, + at, + }), + ).toThrow("intention change requires original acceptance ref"); + expect(record).toEqual(before); + }); + + test(`3995131658: history parsing rejects ${String(evidenceRef)} resume evidence`, () => { + const record = suspendedIntention(); + expect(() => + parseIntentionRecord({ + ...record, + status: "active", + revision: record.revision + 1, + history: [ + ...record.history, + { + from: "suspended", + to: "active", + reason: "Resume", + evidenceRef, + at, + }, + ], + }), + ).toThrow("intention change requires original acceptance ref"); + }); +} + +test("3995131658: resume preserves the original acceptance and round-trips", () => { + const record = suspendedIntention(); + const resumed = transitionIntention(record, { + to: "active", + reason: "Resume", + evidenceRef: record.acceptance.sourceRef, + at, + }); + expect(resumed.acceptance).toEqual(record.acceptance); + expect(resumed.status).toBe("active"); + expect(resumed.revision).toBe(record.revision + 1); + expect(parseIntentionRecord(JSON.parse(JSON.stringify(resumed)))).toEqual( + resumed, + ); +}); diff --git a/packages/lina-core/test/judgment-policy-review.test.ts b/packages/lina-core/test/judgment-policy-review.test.ts new file mode 100644 index 0000000..e866720 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-review.test.ts @@ -0,0 +1,217 @@ +import { expect, test } from "bun:test"; +import { + assessmentInputDigest, + buildCanonicalOption, + judgmentDigest, + MODULE_KINDS, + PERSONAL_POLICY_V1, + parseAssessmentSet, + rankMass, + resolvePersonalRound, + sampleSelection, + snapshotDigest, +} from "../src/agents/index.ts"; + +test("rank mass remains normalized when absolute geometric weights overflow or underflow", () => { + expect(rankMass([1076], 0.5)).toEqual([1]); + expect(rankMass([1076, 1077], 0.5)).toEqual([2 / 3, 1 / 3]); + expect(rankMass([1076, 1077], 2)).toEqual([1 / 3, 2 / 3]); + expect(rankMass([2, 2], Number.MAX_VALUE)).toEqual([0.5, 0.5]); +}); + +function fixture(): Parameters[0] { + const option = buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "nothing needed" }, + }); + const ref = (objectiveId: string) => ({ + objectiveId, + revision: 2, + digest: judgmentDigest({ objectiveId, revision: 2 }), + }); + const snapshot = { + schemaVersion: 1 as const, + roundId: "review-round", + ...option.actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: PERSONAL_POLICY_V1.policyId, + policyRevision: PERSONAL_POLICY_V1.revision, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: { + clotho: ref("clotho-objective"), + lachesis: ref("lachesis-objective"), + atropos: ref("atropos-objective"), + }, + observationRef: null, + frozenNeuralRef: null, + situation: "autonomous" as const, + clockId: "clock", + sequence: 0, + bindingGeneration: 0, + }; + const digest = snapshotDigest(snapshot); + return { + policy: PERSONAL_POLICY_V1, + snapshot, + options: [option], + set: parseAssessmentSet({ + schemaVersion: 1, + roundId: snapshot.roundId, + snapshotDigest: digest, + assessments: MODULE_KINDS.map((moduleKind) => { + const objectiveRef = snapshot.objectiveProfileRefs[moduleKind]; + return { + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + inputDigest: assessmentInputDigest({ + snapshotDigest: digest, + objectiveRef, + mechanismRevision: 1, + }), + completeText: "assessment", + evidenceRefs: [], + proposedOptionKeys: [option.optionKey], + objectiveAssessments: [ + { + optionKey: option.optionKey, + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + }, + ], + recommendedOptionKeys: [option.optionKey], + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }; + }), + }), + eligibility: [ + { optionKey: option.optionKey, eligible: true, reason: null }, + ], + bias: {}, + }; +} + +for (const outcome of ["resolved", "held", "deferred"] as const) { + function round() { + const input = fixture(); + if (outcome === "deferred") input.eligibility = []; + if (outcome === "held") { + for (const assessment of input.set.assessments) { + assessment.objectiveAssessments = []; + assessment.recommendedOptionKeys = []; + } + } + return input; + } + + test(`valid frozen round remains ${outcome}`, () => { + const input = round(); + const before = structuredClone(input); + const result = resolvePersonalRound(input); + expect(result.resolution.status).toBe(outcome); + expect(input).toEqual(before); + if (outcome === "resolved") { + if (!result.spec) throw Error("missing selection spec"); + const expected = input.options[0]; + if (!expected) throw Error("missing fixture candidate"); + expect(result.spec.snapshotDigest).toBe(snapshotDigest(input.snapshot)); + expect(sampleSelection(result.spec, NaN).optionKey).toBe( + expected.optionKey, + ); + } else expect(result.spec).toBeNull(); + }); + + for (const change of [{ policyId: "foreign-policy" }, { revision: 2 }]) { + test(`rejects mismatched policy ${Object.keys(change)[0]} before ${outcome}`, () => { + const input = round(); + input.policy = { ...input.policy, ...change }; + expect(() => resolvePersonalRound(input)).toThrow( + "policy snapshot mismatch", + ); + }); + } + + for (const moduleKind of MODULE_KINDS) { + for (const change of [ + { objectiveId: "stale-objective" }, + { revision: 1 }, + { digest: judgmentDigest("stale-objective") }, + ]) { + test(`rejects ${moduleKind} stale ${Object.keys(change)[0]} with recomputed input digest before ${outcome}`, () => { + const input = round(); + const assessment = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + ); + if (!assessment) throw Error("missing assessment"); + assessment.objectiveRef = { ...assessment.objectiveRef, ...change }; + assessment.inputDigest = assessmentInputDigest(assessment); + expect(parseAssessmentSet(input.set)).toEqual(input.set); + expect(() => resolvePersonalRound(input)).toThrow( + "assessment objective mismatch", + ); + }); + } + + for (const field of [ + "proposedOptionKeys", + "objectiveAssessments", + "recommendedOptionKeys", + ] as const) { + test(`rejects ${moduleKind} foreign ${field} before ${outcome}`, () => { + const input = round(); + const assessment = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + ); + if (!assessment) throw Error("missing assessment"); + const foreign = buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: "foreign", + args: {}, + preconditions: { kind: "noop", reason: "foreign candidate" }, + }); + if (field !== "proposedOptionKeys") { + assessment.objectiveAssessments.push({ + optionKey: foreign.optionKey, + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + }); + } + if (field !== "objectiveAssessments") + assessment[field].push(foreign.optionKey); + input.set = parseAssessmentSet(input.set); + expect(() => resolvePersonalRound(input)).toThrow( + "unknown assessment option key", + ); + }); + } + } +} diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts index 1c2232e..6825766 100644 --- a/packages/lina-core/test/judgment-policy.test.ts +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -103,6 +103,7 @@ function snapshot(situation: Situation): JudgmentSnapshotRef { sourceRefs: [], workingRevision: 0, instructionRevision: 0, + policyId: PERSONAL_POLICY_V1.policyId, policyRevision: 1, identityRevision: 0, domainRevisions: {}, @@ -148,21 +149,20 @@ function fixture( const objectiveRef = snap.objectiveProfileRefs[moduleKind]; const objectiveAssessments = options.flatMap((o) => { const change = assess(moduleKind, o); - return change === null - ? [] - : [ - { - optionKey: o.optionKey, - stance: "accept", - severity: null, - unavailableReason: null, - gain: "gain", - loss: "loss", - uncertainty: "uncertainty", - evidenceRefs: [], - ...change, - }, - ]; + if (change === null) return []; + return [ + { + optionKey: o.optionKey, + stance: "accept", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + ...change, + }, + ]; }); return { schemaVersion: 1, @@ -339,7 +339,7 @@ test("(2) golden option parses byte-identically and rejects invalid boundary dat preconditions: { kind: "noop", reason: "nothing needed" }, effect: { owner: "none", scope: "scope" }, optionKey: - "personal.v1:noop:-:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "personal.v1:noop:-:87529c8e7be62a166697c588491676440f0dd893f67561788cb6c2462904ddcf", }; expect(JSON.stringify(parseCanonicalOption(golden))).toBe( JSON.stringify(golden), @@ -444,12 +444,12 @@ for (const situation of SITUATIONS) { ); const biased = resolve({ ...input, - bias: Object.fromEntries(options.map((o) => [o.optionKey, 100])), + bias: Object.fromEntries(options.map((o) => [o.optionKey, 1])), }); expect(biased.spec?.candidates.map((c) => c.p0)).toEqual( spec.candidates.map((c) => c.p0), ); - expect(biased.spec?.candidates.every((c) => c.b === 100)).toBe(true); + expect(biased.spec?.candidates.every((c) => c.b === 1)).toBe(true); }); } @@ -722,7 +722,7 @@ test("(9) normalized duplicate options cannot double activity mass", () => { test("(10) single positive candidate consumes no draw, including NaN", () => { const spec = samplingSpec([ - { optionKey: "a", p0: 0, b: 1000 }, + { optionKey: "a", p0: 0, b: 1 }, { optionKey: "b", p0: 1, b: null }, ]); expect(sampleSelection(spec, NaN)).toEqual({ @@ -737,8 +737,8 @@ test("(10) single positive candidate consumes no draw, including NaN", () => { test("(10) sampling uses normalized log weights and strict cumulative boundaries", () => { const spec = samplingSpec([ - { optionKey: "a", p0: 0.5, b: Math.log(3) }, - { optionKey: "b", p0: 0.5, b: 0 }, + { optionKey: "a", p0: 0.5, b: Math.log(3) / 2 }, + { optionKey: "b", p0: 0.5, b: -Math.log(3) / 2 }, ]); const sample = sampleSelection(spec, 0); expect(sample.optionKey).toBe("a"); @@ -772,16 +772,16 @@ test("(10) asymmetric and extreme log-space weights remain finite and normalized { optionKey: "b", p0: 1, b: null }, ], [ - { optionKey: "a", p0: 0.5, b: 1000 }, - { optionKey: "b", p0: 0.5, b: 999 }, + { optionKey: "a", p0: 0.5, b: 1 }, + { optionKey: "b", p0: 0.5, b: 0.999 }, ], [ - { optionKey: "a", p0: 0.5, b: -1000 }, - { optionKey: "b", p0: 0.5, b: -999 }, + { optionKey: "a", p0: 0.5, b: -1 }, + { optionKey: "b", p0: 0.5, b: -0.999 }, ], ]) { const result = sampleSelection( - samplingSpec(candidates), + samplingSpec(candidates, 1000), 1 - Number.EPSILON, ); expect(result.probabilities.every((c) => Number.isFinite(c.p))).toBe(true); diff --git a/packages/lina-core/test/judgment-round.test.ts b/packages/lina-core/test/judgment-round.test.ts index d26013f..df1ef90 100644 --- a/packages/lina-core/test/judgment-round.test.ts +++ b/packages/lina-core/test/judgment-round.test.ts @@ -190,6 +190,7 @@ function snapshotRef( sourceRefs: [], workingRevision: projection.workingRevision, instructionRevision: projection.instructionRevision, + policyId: PERSONAL_POLICY_V1.policyId, policyRevision: 1, identityRevision: 1, domainRevisions: {}, diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index 0895636..e5d73d6 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -93,6 +93,7 @@ function snapshot( sourceRefs: [], workingRevision: 2, instructionRevision: 1, + policyId: "personal.v1", policyRevision: 1, identityRevision: 1, domainRevisions: { life: 0 }, @@ -409,6 +410,57 @@ test("rounds freeze active references and reject duplicate ids or scope sequence ).toThrow("stale objective profile refs"); }); +for (const tamper of ["snapshot", "snapshot_digest"] as const) { + test(`3995117504: ${tamper} tampering is rejected on read and writes after reopen`, () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + close(store); + const db = database(); + db.prepare(`UPDATE rounds SET ${tamper} = ? WHERE round_id = ?`).run( + tamper === "snapshot" + ? JSON.stringify({ ...ref, clockId: "tampered-clock" }) + : "tampered-digest", + ref.roundId, + ); + store = open(); + expect(() => store.getRound(ref.roundId)).toThrow( + "snapshot digest mismatch", + ); + expect(() => store.putAssessment(assessment(ref, "clotho"))).toThrow( + "snapshot digest mismatch", + ); + expect(() => + store.recordResolution(ref.roundId, resolution(ref, "held"), null), + ).toThrow("snapshot digest mismatch"); + expect(db.prepare("SELECT count(*) AS n FROM assessments").get()).toEqual({ + n: 0, + }); + expect(store.getResolution(ref.roundId)).toBeNull(); + }); +} + +test("3995117504: snapshot digest is checked after parser normalization", () => { + const store = open(); + const ref = snapshot(store); + ref.sourceRefs = [ + { kind: "request", id: "a", revision: 1 }, + { kind: "request", id: "b", revision: 1 }, + ]; + store.openRound(ref); + database() + .prepare("UPDATE rounds SET snapshot = ? WHERE round_id = ?") + .run( + JSON.stringify({ ...ref, sourceRefs: [...ref.sourceRefs].reverse() }), + ref.roundId, + ); + expect(store.getRound(ref.roundId)).toEqual({ + snapshot: ref, + status: "open", + snapshotDigest: snapshotDigest(ref), + }); +}); + test("assessments require an open matching snapshot and exactly one of each module", () => { const store = open(); const ref = snapshot(store); @@ -474,6 +526,7 @@ for (const change of [ for (const status of ["resolved", "held", "deferred"] as const) { for (const change of [ { situation: "autonomous" as const }, + { policyId: "other-policy" }, { policyRevision: 999 }, ]) { test(`${status} resolution must match snapshot ${Object.keys(change)[0]}`, () => { From 67bf83b7f7c6301c445d6c3e403cdb435366c07f Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:33:22 +0900 Subject: [PATCH 19/47] test(runtime): finalize SQLite state at cold restart Model process-exit collection before replacement fleet storage validation. Force the delayed SQLite collection point in the real HTTP reopen test while preserving the private-copy audit and all approval assertions. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../test/life-author-session.test.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/lina-runtime/test/life-author-session.test.ts b/packages/lina-runtime/test/life-author-session.test.ts index 66bc13e..ec068d1 100644 --- a/packages/lina-runtime/test/life-author-session.test.ts +++ b/packages/lina-runtime/test/life-author-session.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, @@ -17,6 +17,7 @@ import { acquireTranscriptLease, } from "../../lina-core/src/index.ts"; import type { WorldAuthorGrant } from "../../lina-core/src/world/authoring-types.ts"; +import { WorldStore } from "../../lina-core/src/world/store.ts"; import type { ContextServices } from "../src/context/port.ts"; import { AgentFleet } from "../src/fleet/manager.ts"; import { startFleetServer } from "../src/fleet/server.ts"; @@ -188,6 +189,9 @@ async function fixture() { forbiddenCalls: () => forbiddenCalls, async reopen() { await server.stop(); + // Emulate process exit: finalize closed SQLite statements before the + // new fleet stamps/copies LIFE storage, not during its validation. + Bun.gc(true); fleet = open(); server = await startFleetServer(fleet, 0, process.cwd(), "lina", { lazy: true, @@ -279,9 +283,32 @@ test("dedicated HTTP grant/input/read/approval uses real runtime and isolated au ); const sessionId = author.binding.sessionId; await f.reopen(); - expect( - (await f.send(`/author-sessions/${grant.id}/open`, "POST", {})).status, - ).toBe(200); + // Force the CI collection point instead of relying on heap pressure. + // The real private-copy audit and close still run before collection. + const close = WorldStore.prototype.close; + const collect = spyOn(WorldStore.prototype, "close").mockImplementation( + function (this: WorldStore) { + close.call(this); + Bun.gc(true); + }, + ); + try { + const response = await f.send( + `/author-sessions/${grant.id}/open`, + "POST", + {}, + ); + expect({ + status: response.status, + body: await response.json(), + }).toMatchObject({ + status: 200, + body: { grant }, + }); + expect(collect).toHaveBeenCalledTimes(1); + } finally { + collect.mockRestore(); + } const reopened = await f.fleet().openWorldAuthor(grant.id); expect(reopened.binding.sessionId).toBe(sessionId); expect( From 2dabeda6a46250302141b2f6ffbbd63c6f0f3277 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 14:57:23 +0900 Subject: [PATCH 20/47] test(runtime): isolate the LIFE archive capacity boundary Prepare 256 real immutable archives with a durable manifest, exercise the 257th lifecycle normally, and retain every reopen/replay assertion. A temporary conversation-cap mutation fails at the boundary. Production durability and test timeouts remain unchanged. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-runtime/test/image-store-v2.test.ts | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/lina-runtime/test/image-store-v2.test.ts b/packages/lina-runtime/test/image-store-v2.test.ts index f597658..68ac093 100644 --- a/packages/lina-runtime/test/image-store-v2.test.ts +++ b/packages/lina-runtime/test/image-store-v2.test.ts @@ -11,6 +11,8 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { atomicJson } from "../../lina-core/src/attachments/filesystem.ts"; +import { ImageArchives } from "../src/images/image-store-archive.ts"; import { ImageJobStore } from "../src/images/store.ts"; const roots: string[] = []; @@ -236,21 +238,48 @@ test("LIFE dedupe freezes real origin and reference identity without conversatio test("257 LIFE identities survive terminal archival, reopen and replay without using the conversation cap", () => { const f = fixture(); let store = new ImageJobStore(f.root, owner, limits); - const ids: string[] = []; - for (let n = 0; n < 257; n++) { - const job = store.create({ - ...input, - origin: { ...input.origin, attemptId: `attempt-${n}` }, - }); - ids.push(job.id); - store.update(job.id, { state: "cancelled" }); - store.update(job.id, { - delivery: { kind: "life", receiptId: `receipt-${n}` }, - }); - store.archive(job.id); - } + const seed = store.create({ + ...input, + origin: { ...input.origin, attemptId: "attempt-0" }, + }); + store.update(seed.id, { state: "cancelled" }); + const terminal = store.update(seed.id, { + delivery: { kind: "life", receiptId: "receipt-0" }, + }); + // Persist the starting population once; the regression is crossing the + // conversation ceiling, then reopening and replaying every LIFE identity. + const prefix = Array.from({ length: 256 }, (_, n) => ({ + ...terminal, + id: n === 0 ? terminal.id : randomUUID(), + origin: { ...input.origin, attemptId: `attempt-${n}` }, + delivery: { kind: "life" as const, receiptId: `receipt-${n}` }, + })); + const archives = new ImageArchives( + join(f.root, "images"), + owner, + limits.maxArchiveBytes, + ); + atomicJson(f.file, { + version: 2, + owner, + jobs: [], + archives: prefix.map((job) => archives.write(job)), + }); + store = new ImageJobStore(f.root, owner, limits); + expect(store.list()).toEqual(prefix); + const job = store.create({ + ...input, + origin: { ...input.origin, attemptId: "attempt-256" }, + }); + store.update(job.id, { state: "cancelled" }); + store.update(job.id, { + delivery: { kind: "life", receiptId: "receipt-256" }, + }); + const last = store.archive(job.id); + const ids = [...prefix.map((item) => item.id), job.id]; store = new ImageJobStore(f.root, owner, limits); expect(store.list()).toHaveLength(257); + expect(store.list()).toEqual([...prefix, last]); for (let n = 0; n < 257; n++) expect( store.create({ From f8b48fa489a886b4008a81b3e5fae7fbfd800a5f Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:24:51 +0900 Subject: [PATCH 21/47] fix(core): enforce the immutable personal policy declaration Reject altered order, stance vocabulary, ratio or bias strength under personal.v1 revision one while accepting equivalent deserialized declarations. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-policy.ts | 3 ++ .../test/judgment-policy-review.test.ts | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index f8c049f..771804b 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { type AssessmentSet, type JudgmentSnapshotRef, @@ -105,6 +106,8 @@ export function resolvePersonalRound(input: { policy.revision !== snapshot.policyRevision ) throw Error("policy snapshot mismatch"); + if (!isDeepStrictEqual(policy, PERSONAL_POLICY_V1)) + throw Error("unsupported personal policy declaration"); for (const assessment of set.assessments) { const objective = snapshot.objectiveProfileRefs[assessment.moduleKind]; if ( diff --git a/packages/lina-core/test/judgment-policy-review.test.ts b/packages/lina-core/test/judgment-policy-review.test.ts index e866720..64d073c 100644 --- a/packages/lina-core/test/judgment-policy-review.test.ts +++ b/packages/lina-core/test/judgment-policy-review.test.ts @@ -114,6 +114,38 @@ function fixture(): Parameters[0] { }; } +test("personal.v1 revision one rejects altered or malformed declarations", () => { + const input = fixture(); + for (const change of [ + { ratio: 0.9 }, + { ratio: 0 }, + { orders: { ...PERSONAL_POLICY_V1.orders, autonomous: [] } }, + { + orders: { + ...PERSONAL_POLICY_V1.orders, + user_request: PERSONAL_POLICY_V1.orders.autonomous, + }, + }, + { lambda: { ...PERSONAL_POLICY_V1.lambda, user_request: 1 } }, + { lambda: { ...PERSONAL_POLICY_V1.lambda, autonomous: 0 } }, + { stanceOrder: [] }, + { stanceOrder: [...PERSONAL_POLICY_V1.stanceOrder].reverse() }, + ]) { + expect(() => + resolvePersonalRound({ + ...input, + policy: { ...PERSONAL_POLICY_V1, ...change }, + }), + ).toThrow("unsupported personal policy declaration"); + } + expect( + resolvePersonalRound({ + ...input, + policy: structuredClone(PERSONAL_POLICY_V1), + }), + ).toEqual(resolvePersonalRound(input)); +}); + for (const outcome of ["resolved", "held", "deferred"] as const) { function round() { const input = fixture(); From 6ea9cec69bb8b85c0237211b56fc4e519dca8077 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:25:25 +0900 Subject: [PATCH 22/47] fix(core): apply context ID bounds to instruction references Use the shared identifier validator in projection construction and restoration, rejecting embedded NUL and oversized IDs consistently. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/context/read-projection.ts | 28 ++++++++----------- .../test/context-read-projection.test.ts | 27 ++++++++++++++++++ 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/lina-core/src/context/read-projection.ts b/packages/lina-core/src/context/read-projection.ts index 98245dc..cb5dbc9 100644 --- a/packages/lina-core/src/context/read-projection.ts +++ b/packages/lina-core/src/context/read-projection.ts @@ -57,10 +57,6 @@ function sha256Hex(text: string): string { return createHash("sha256").update(text, "utf8").digest("hex"); } -function isNonBlankId(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - const ISO_8601_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(?:Z|[+-](\d{2}):(\d{2}))$/; @@ -152,13 +148,15 @@ function parseInstructionRef(value: unknown): InstructionRef { if (!["requestId", "entryId", "textDigest"].includes(key)) reject(`unknown context read projection instruction field ${key}`); } - const requestId = obj.requestId; - const entryId = obj.entryId; + const requestId = validId( + obj.requestId, + "context read projection instruction requestId", + ); + const entryId = validId( + obj.entryId, + "context read projection instruction entryId", + ); const textDigest = obj.textDigest; - if (!isNonBlankId(requestId)) - reject("invalid context read projection instruction requestId"); - if (!isNonBlankId(entryId)) - reject("invalid context read projection instruction entryId"); if (typeof textDigest !== "string" || !/^[0-9a-f]{64}$/.test(textDigest)) reject("invalid context read projection instruction textDigest"); return { requestId, entryId, textDigest }; @@ -196,17 +194,15 @@ function validateInput( // Validate working against the same bounds the store uses. try { parseWorking(working); + if (instruction !== null) { + validId(instruction.requestId, "instruction requestId"); + validId(instruction.entryId, "instruction entryId"); + } } catch { reject("invalid context read projection input"); } if (typeof projectedAt !== "string" || !isIso8601(projectedAt)) reject("invalid context read projection input"); - if (instruction !== null) { - if (!isNonBlankId(instruction.requestId)) - reject("invalid context read projection input"); - if (!isNonBlankId(instruction.entryId)) - reject("invalid context read projection input"); - } } /** diff --git a/packages/lina-core/test/context-read-projection.test.ts b/packages/lina-core/test/context-read-projection.test.ts index d779851..65ceaae 100644 --- a/packages/lina-core/test/context-read-projection.test.ts +++ b/packages/lina-core/test/context-read-projection.test.ts @@ -262,6 +262,33 @@ describe("ContextReadProjection", () => { ).toThrow(/invalid context read projection instructionRevision/); }); + it.each(["requestId", "entryId"] as const)( + "rejects invalid instruction %s in both builder and restored state", + (field) => { + const input = { + working: store.working(), + instruction: makeInstruction("bounded-entry", "hello"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }; + const projection = buildContextReadProjection(input); + for (const id of ["entry\0hidden", "x".repeat(100_000)]) { + expect(() => + buildContextReadProjection({ + ...input, + instruction: { ...input.instruction, [field]: id }, + }), + ).toThrow(/invalid context read projection/); + expect(() => + parseContextReadProjection({ + ...projection, + instruction: { ...projection.instruction, [field]: id }, + }), + ).toThrow(/invalid context read projection/); + } + }, + ); + it.each([ [""], [" "], From 6175e8a6af6b8f2de452dd5a27468080bc5152eb Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 15:26:27 +0900 Subject: [PATCH 23/47] fix(core): verify persisted judgment digests on retrieval Compare normalized objective, assessment, resolution, selection and intention records with their persisted digests before returning them or deriving writes. Cover valid JSON tampering, digest-column tampering, rollback and normalized reopen behavior using real SQLite. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-store.ts | 85 ++- .../test/judgment-store-digest-review.test.ts | 506 ++++++++++++++++++ 2 files changed, 569 insertions(+), 22 deletions(-) create mode 100644 packages/lina-core/test/judgment-store-digest-review.test.ts diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 6762762..7b98391 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -105,14 +105,18 @@ export class JudgmentStore { this.assertOpen(); const row = this.db .prepare( - "SELECT body FROM objective_profiles WHERE objective_id = ? AND revision = ?", + "SELECT body, digest FROM objective_profiles WHERE objective_id = ? AND revision = ?", ) .get( boundedId(objectiveId, "objective id"), revision(profileRevision, 1), ); - const { body: json } = row ?? {}; - return row ? parseObjectiveProfile(JSON.parse(String(json))) : null; + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseObjectiveProfile(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("objective profile digest mismatch"); + return parsed; } activateObjectiveProfile( @@ -170,12 +174,14 @@ export class JudgmentStore { ): Record | null { this.assertOpen(); const rows = this.db - .prepare(`SELECT p.body FROM objective_profile_active a JOIN objective_profiles p + .prepare(`SELECT p.body, p.digest FROM objective_profile_active a JOIN objective_profiles p ON p.objective_id = a.objective_id AND p.revision = a.revision WHERE a.agent_id = ? AND a.scope_id = ?`) .all(boundedId(agentId, "agent id"), boundedId(scopeId, "scope id")); const refs: Partial> = {}; - for (const { body: json } of rows) { + for (const { body: json, digest } of rows) { const profile = parseObjectiveProfile(JSON.parse(String(json))); + if (judgmentDigest(profile) !== digest) + throw Error("objective profile digest mismatch"); refs[profile.moduleKind] = parseObjectiveProfileRef({ objectiveId: profile.objectiveId, revision: profile.revision, @@ -291,12 +297,28 @@ export class JudgmentStore { this.assertOpen(); const round = this.getRound(roundId); const rows = this.db - .prepare("SELECT body FROM assessments WHERE round_id = ?") + .prepare( + "SELECT body, digest, input_digest, snapshot_digest FROM assessments WHERE round_id = ?", + ) .all(boundedId(roundId, "round id")); if (!round || rows.length !== MODULE_KINDS.length) throw Error("incomplete assessment set"); - const assessments = rows.map(({ body: json }) => - parseAssessment(JSON.parse(String(json))), + const assessments = rows.map( + ({ + body: json, + digest, + input_digest: inputDigest, + snapshot_digest: snapshot, + }) => { + const parsed = parseAssessment(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("assessment digest mismatch"); + if (parsed.inputDigest !== inputDigest) + throw Error("assessment input digest mismatch"); + if (parsed.snapshotDigest !== snapshot) + throw Error("assessment snapshot digest mismatch"); + return parsed; + }, ); return parseAssessmentSet({ schemaVersion: 1, @@ -374,19 +396,29 @@ export class JudgmentStore { getResolution(roundId: string): ResolutionRecord | null { this.assertOpen(); const row = this.db - .prepare("SELECT body FROM resolution_records WHERE round_id = ?") + .prepare("SELECT body, digest FROM resolution_records WHERE round_id = ?") .get(boundedId(roundId, "round id")); - const { body: json } = row ?? {}; - return row ? parseResolutionRecord(JSON.parse(String(json))) : null; + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseResolutionRecord(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("resolution digest mismatch"); + return parsed; } getSelectionSpec(roundId: string): SelectionSpec | null { this.assertOpen(); const row = this.db - .prepare("SELECT body FROM selection_specs WHERE round_id = ?") + .prepare( + "SELECT body, spec_digest FROM selection_specs WHERE round_id = ?", + ) .get(boundedId(roundId, "round id")); - const { body: json } = row ?? {}; - return row ? parseSelectionSpec(JSON.parse(String(json))) : null; + if (!row) return null; + const { body: json, spec_digest: digest } = row; + const parsed = parseSelectionSpec(JSON.parse(String(json))); + if (parsed.specDigest !== digest) + throw Error("selection spec digest mismatch"); + return parsed; } putIntention(record: IntentionRecord): void { @@ -474,10 +506,16 @@ export class JudgmentStore { getIntention(intentionId: string): IntentionRecord | null { this.assertOpen(); const row = this.db - .prepare("SELECT body FROM intention_records WHERE intention_id = ?") + .prepare( + "SELECT body, digest FROM intention_records WHERE intention_id = ?", + ) .get(boundedId(intentionId, "intention id")); - const { body: json } = row ?? {}; - return row ? parseIntentionRecord(JSON.parse(String(json))) : null; + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseIntentionRecord(JSON.parse(String(json))); + if (intentionDigest(parsed) !== digest) + throw Error("intention digest mismatch"); + return parsed; } listIntentions( @@ -494,17 +532,20 @@ export class JudgmentStore { status === undefined ? this.db .prepare( - "SELECT body FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id", + "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id", ) .all(agent, scope) : this.db .prepare( - "SELECT body FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", + "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", ) .all(agent, scope, status); - return rows.map(({ body: json }) => - parseIntentionRecord(JSON.parse(String(json))), - ); + return rows.map(({ body: json, digest }) => { + const parsed = parseIntentionRecord(JSON.parse(String(json))); + if (intentionDigest(parsed) !== digest) + throw Error("intention digest mismatch"); + return parsed; + }); } close(): void { diff --git a/packages/lina-core/test/judgment-store-digest-review.test.ts b/packages/lina-core/test/judgment-store-digest-review.test.ts new file mode 100644 index 0000000..924d29f --- /dev/null +++ b/packages/lina-core/test/judgment-store-digest-review.test.ts @@ -0,0 +1,506 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + type Assessment, + assessmentInputDigest, + type IntentionRecord, + type IntentionTransition, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfile, + parseAssessment, + parseAssessmentSet, + parseIntentionRecord, + parseJudgmentSnapshotRef, + parseObjectiveProfile, + parseResolutionRecord, + parseSelectionSpec, + type ResolutionRecord, + type SelectionSpec, + snapshotDigest, +} from "../src/agents/index.ts"; + +let dir: string; +let path: string; +let store: JudgmentStore; +let db: DatabaseSync; +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "lina-judgment-digest-")); + path = join(dir, "judgment.sqlite"); + store = new JudgmentStore(path, { now: () => 1234 }); + db = new DatabaseSync(path); +}); +afterEach(() => { + db.close(); + store.close(); + rmSync(dir, { recursive: true, force: true }); + expect(existsSync(dir)).toBe(false); +}); +function reopen(): void { + store.close(); + store = new JudgmentStore(path, { now: () => 1234 }); +} +function profile( + moduleKind: ModuleKind = "clotho", + revision = 1, +): ObjectiveProfile { + return { + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision, + objective: "Compare fixture outcomes", + comparisonCriteria: ["cost", "outcome"], + reconsiderationConditions: ["new evidence"], + }; +} +function snapshot( + store: JudgmentStore, + roundId = "round-1", + sequence = 1, +): JudgmentSnapshotRef { + for (const module of MODULE_KINDS) { + const ref = store.putObjectiveProfile(profile(module)); + store.activateObjectiveProfile("agent-1", "scope-1", ref); + } + const refs = store.activeObjectiveProfiles("agent-1", "scope-1"); + if (!refs) throw Error("fixture profiles missing"); + return parseJudgmentSnapshotRef({ + schemaVersion: 1, + roundId, + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [], + workingRevision: 2, + instructionRevision: 1, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 1, + domainRevisions: { life: 0 }, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence, + bindingGeneration: 0, + }); +} +function assessment( + ref: JudgmentSnapshotRef, + moduleKind: ModuleKind, + digest = snapshotDigest(ref), +): Assessment { + const input = { + snapshotDigest: digest, + objectiveRef: ref.objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + return parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: ref.roundId, + ...input, + inputDigest: assessmentInputDigest(input), + completeText: "Fixture assessment", + evidenceRefs: [], + proposedOptionKeys: ["a"], + objectiveAssessments: [ + { + optionKey: "a", + stance: "prefer", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + }, + ], + recommendedOptionKeys: ["a"], + detail: { + kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ + moduleKind + ], + body: { z: [{ b: 2, a: 1 }], a: true }, + }, + diagnostics: {}, + }); +} +function resolution( + ref: JudgmentSnapshotRef, + status: ResolutionRecord["status"] = "resolved", +): ResolutionRecord { + return parseResolutionRecord({ + schemaVersion: 1, + roundId: ref.roundId, + policyId: "personal.v1", + policyRevision: 1, + situation: ref.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: ["a"], lachesis: ["a"], atropos: ["a"] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [{ optionKey: "a", rank: 1 }], + conceded: [], + status, + holdReason: status === "resolved" ? null : "no eligible candidate", + }); +} +function spec( + ref: JudgmentSnapshotRef, + record = resolution(ref), +): SelectionSpec { + const body = { + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessmentSetDigest: judgmentDigest( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((m) => assessment(ref, m)), + }), + ), + objectiveProfileRefs: ref.objectiveProfileRefs, + resolutionDigest: judgmentDigest(record), + policyId: record.policyId, + policyRevision: record.policyRevision, + situation: ref.situation, + lambda: 0, + candidates: [{ optionKey: "a", p0: 1, b: null }], + eligibleDigest: judgmentDigest(["a"]), + }; + return parseSelectionSpec({ ...body, specDigest: judgmentDigest(body) }); +} +function intention(intentionId = "intention-1"): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId, + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind: "user_commitment", + purposeRef: "purpose-1", + text: "Complete fixture task", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} +function transition( + to: IntentionTransition["to"], +): Omit { + return { + to, + reason: "fixture transition", + evidenceRef: "request-1", + at: "2026-09-11T01:00:00.000Z", + }; +} + +function seed() { + const ref = snapshot(store); + store.openRound(ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + store.putIntention(intention()); + return ref; +} +function replaceBody(table: string, value: unknown, where = "1 = 1"): void { + expect( + db + .prepare(`UPDATE ${table} SET body = ? WHERE ${where}`) + .run(JSON.stringify(value)).changes, + ).toBe(1); +} +function rehashSpec(selection: SelectionSpec): SelectionSpec { + return parseSelectionSpec({ + ...selection, + specDigest: judgmentDigest({ ...selection, specDigest: undefined }), + }); +} + +for (const tamper of ["body", "digest"] as const) { + for (const action of [ + "get", + "active", + "put", + "activate", + "openRound", + ] as const) { + test(`3995355426: objective ${tamper} mismatch rejects ${action} after reopen`, () => { + const ref = seed(); + const changed = parseObjectiveProfile({ + ...profile(), + objective: "Different objective", + }); + const expected = tamper === "body" ? changed : profile(); + if (tamper === "body") + replaceBody("objective_profiles", changed, "module_kind = 'clotho'"); + else + db.prepare( + "UPDATE objective_profiles SET digest = ? WHERE module_kind = 'clotho'", + ).run(judgmentDigest(changed)); + reopen(); + const objectiveRef = { + ...ref.objectiveProfileRefs.clotho, + digest: judgmentDigest(expected), + }; + const before = db + .prepare("SELECT * FROM objective_profile_active ORDER BY module_kind") + .all(); + const run = { + get: () => + store.getObjectiveProfile(expected.objectiveId, expected.revision), + active: () => store.activeObjectiveProfiles(ref.agentId, ref.scopeId), + put: () => store.putObjectiveProfile(expected), + activate: () => + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + objectiveRef, + ), + openRound: () => + store.openRound({ + ...ref, + roundId: "round-2", + sequence: 2, + objectiveProfileRefs: { + ...ref.objectiveProfileRefs, + clotho: objectiveRef, + }, + }), + }; + expect(run[action]).toThrow("objective profile digest mismatch"); + expect( + db + .prepare( + "SELECT * FROM objective_profile_active ORDER BY module_kind", + ) + .all(), + ).toEqual(before); + expect(store.getRound("round-2")).toBeNull(); + }); + } + for (const action of ["get", "list", "filtered", "transition"] as const) { + test(`3995355426: intention ${tamper} mismatch rejects ${action} after reopen`, () => { + seed(); + const changed = parseIntentionRecord({ + ...intention(), + text: "Different commitment", + }); + if (tamper === "body") replaceBody("intention_records", changed); + else + db.prepare("UPDATE intention_records SET digest = ?").run( + judgmentDigest(changed), + ); + reopen(); + const before = db.prepare("SELECT * FROM intention_records").all(); + const run = { + get: () => store.getIntention("intention-1"), + list: () => store.listIntentions("agent-1", "scope-1"), + filtered: () => store.listIntentions("agent-1", "scope-1", "proposed"), + transition: () => + store.transitionIntention("intention-1", transition("adopted"), 0), + }; + expect(run[action]).toThrow("intention digest mismatch"); + expect(db.prepare("SELECT * FROM intention_records").all()).toEqual( + before, + ); + expect( + db.prepare("SELECT count(*) AS n FROM intention_transitions").get(), + ).toEqual({ n: 0 }); + }); + } + for (const target of ["resolution", "selection"] as const) { + test(`3995355426: ${target} ${tamper} mismatch rejected after reopen`, () => { + const ref = seed(); + const record = resolution(ref); + const selection = spec(ref); + store.recordResolution(ref.roundId, record, selection); + const changedRecord = parseResolutionRecord({ + ...record, + ranking: [{ optionKey: "a", rank: 2 }], + }); + // Keep the embedded selection digest valid: only its persisted anchor is stale. + const changedSelection = rehashSpec({ ...selection, lambda: 1 }); + if (target === "resolution") { + if (tamper === "body") replaceBody("resolution_records", changedRecord); + else + db.prepare("UPDATE resolution_records SET digest = ?").run( + judgmentDigest(changedRecord), + ); + } else { + if (tamper === "body") replaceBody("selection_specs", changedSelection); + else + db.prepare("UPDATE selection_specs SET spec_digest = ?").run( + changedSelection.specDigest, + ); + } + reopen(); + expect(() => + target === "resolution" + ? store.getResolution(ref.roundId) + : store.getSelectionSpec(ref.roundId), + ).toThrow( + `${target === "resolution" ? "resolution" : "selection spec"} digest mismatch`, + ); + }); + } +} + +for (const tamper of [ + "body", + "digest", + "input_digest", + "snapshot_digest", +] as const) { + for (const action of ["assessmentSet", "recordResolution"] as const) { + test(`3995355426: assessment ${tamper} mismatch rejects ${action} after reopen`, () => { + const ref = seed(); + const original = assessment(ref, "clotho"); + const changed = parseAssessment({ + ...original, + completeText: "Different assessment", + }); + if (tamper === "body") + replaceBody("assessments", changed, "module_kind = 'clotho'"); + else + db.prepare( + `UPDATE assessments SET ${tamper} = ? WHERE module_kind = 'clotho'`, + ).run(judgmentDigest(changed)); + // A matching downstream digest must not bless corruption in its source rows. + const expected = parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((module) => + module === "clotho" && tamper === "body" + ? changed + : assessment(ref, module), + ), + }); + const selection = rehashSpec({ + ...spec(ref), + assessmentSetDigest: judgmentDigest(expected), + }); + reopen(); + expect(() => + action === "assessmentSet" + ? store.assessmentSet(ref.roundId) + : store.recordResolution(ref.roundId, resolution(ref), selection), + ).toThrow( + tamper === "input_digest" + ? "assessment input digest mismatch" + : tamper === "snapshot_digest" + ? "assessment snapshot digest mismatch" + : "assessment digest mismatch", + ); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + }); + } +} + +test("3995355426: parser-normalized digests and valid lifecycle survive reopen", () => { + const ref = seed(); + const record = resolution(ref); + const selection = spec(ref); + store.recordResolution(ref.roundId, record, selection); + const initial = parseIntentionRecord({ + ...intention("intention-2"), + abortConditions: ["a", "b"], + }); + store.putIntention(initial); + const changed = { + ...profile(), + comparisonCriteria: [...profile().comparisonCriteria].reverse(), + }; + expect(judgmentDigest(changed)).not.toBe( + judgmentDigest(parseObjectiveProfile(changed)), + ); + replaceBody("objective_profiles", changed, "module_kind = 'clotho'"); + const originalAssessment = parseAssessment({ + ...assessment(ref, "clotho"), + evidenceRefs: ["a", "b"], + }); + // Update both anchor and body to a valid canonical assessment, then reorder its set-like list. + db.prepare( + "UPDATE assessments SET digest = ?, body = ? WHERE module_kind = 'clotho'", + ).run( + judgmentDigest(originalAssessment), + JSON.stringify({ ...originalAssessment, evidenceRefs: ["b", "a"] }), + ); + const originalResolution = parseResolutionRecord({ + ...record, + recommendations: { ...record.recommendations, clotho: ["a", "b"] }, + }); + db.prepare("UPDATE resolution_records SET digest = ?, body = ?").run( + judgmentDigest(originalResolution), + JSON.stringify({ + ...originalResolution, + recommendations: { ...record.recommendations, clotho: ["b", "a"] }, + }), + ); + replaceBody( + "intention_records", + { ...initial, abortConditions: ["b", "a"] }, + "intention_id = 'intention-2'", + ); + // Selection has no sortable set-like list; JSON key order/whitespace are not digest input. + replaceBody( + "selection_specs", + Object.fromEntries(Object.entries(selection).reverse()), + ); + reopen(); + expect(store.getObjectiveProfile("objective-clotho", 1)).toEqual(profile()); + expect(store.activeObjectiveProfiles(ref.agentId, ref.scopeId)).toEqual( + ref.objectiveProfileRefs, + ); + expect(store.assessmentSet(ref.roundId).assessments[0]).toEqual( + originalAssessment, + ); + expect(store.getResolution(ref.roundId)).toEqual(originalResolution); + expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); + expect(store.getIntention(initial.intentionId)).toEqual(initial); + expect(store.listIntentions(ref.agentId, ref.scopeId, "proposed")).toEqual([ + intention(), + initial, + ]); + const adopted = store.transitionIntention( + initial.intentionId, + transition("adopted"), + 0, + ); + reopen(); + expect(store.getIntention(initial.intentionId)).toEqual(adopted); + expect(store.listIntentions(ref.agentId, ref.scopeId, "adopted")).toEqual([ + adopted, + ]); + expect(store.getObjectiveProfile("missing", 1)).toBeNull(); + expect(store.getResolution("missing")).toBeNull(); + expect(store.getSelectionSpec("missing")).toBeNull(); + expect(store.getIntention("missing")).toBeNull(); + expect(store.listIntentions("missing", ref.scopeId)).toEqual([]); +}); From f443122ddd3bb92e48f5b7bc0d98764f8b94318c Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:01:24 +0900 Subject: [PATCH 24/47] fix(core): preserve LIFE persona ownership restrictions Lock projected dimensions for manual identities and reject schema derivation for agents outside the source world participants. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/persona-schema.ts | 17 +++--- .../lina-core/test/persona-schema.test.ts | 53 +++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/lina-core/src/agents/persona-schema.ts b/packages/lina-core/src/agents/persona-schema.ts index 86b1cac..7074d23 100644 --- a/packages/lina-core/src/agents/persona-schema.ts +++ b/packages/lina-core/src/agents/persona-schema.ts @@ -2,9 +2,11 @@ import { createHash } from "node:crypto"; import { lifeDigest } from "../world/life-json.ts"; import type { IdentityPolicySnapshot, + IdentityProfilePolicy, LifeDefinition, } from "../world/life-types.ts"; import { parseLifeDefinition } from "../world/life-validation.ts"; +import { knownAgents } from "../world/validation.ts"; import { DIMENSION_SOURCES, type DimensionSource } from "./behavior-types.ts"; import { boundedId } from "./validation.ts"; @@ -274,12 +276,7 @@ function compareDimensions(a: PersonaDimension, b: PersonaDimension): number { function profileFor( identity: IdentityPolicySnapshot | null, agentId: string, -): { - profileRevision: number; - lockedTraitIds: string[]; - lockedHabitIds: string[]; - lockedAttitudeIds: string[]; -} | null { +): IdentityProfilePolicy | null { if (identity === null) return null; for (const profile of identity.profiles) if (profile.agentId === agentId) return profile; @@ -287,15 +284,12 @@ function profileFor( } function lockedFor( - policy: { - lockedTraitIds: string[]; - lockedHabitIds: string[]; - lockedAttitudeIds: string[]; - } | null, + policy: IdentityProfilePolicy | null, kind: PersonaDimensionKind, id: string, ): boolean { if (policy === null) return false; + if (policy.evolution === "manual") return true; if (kind === "trait") return policy.lockedTraitIds.includes(id); if (kind === "habit") return policy.lockedHabitIds.includes(id); return policy.lockedAttitudeIds.includes(id); @@ -310,6 +304,7 @@ export function personaSchemaFromLifeDefinition(input: { const agentId = boundedId(input.agentId, "agent id"); const schemaRevision = revision(input.revision, "persona schema revision"); const definition = parseLifeDefinition(input.definition); + knownAgents([agentId], definition.participants); const policy = profileFor(input.identity, agentId); const ids = new Set(); for (const dimension of [ diff --git a/packages/lina-core/test/persona-schema.test.ts b/packages/lina-core/test/persona-schema.test.ts index 67ef6bc..208ef5a 100644 --- a/packages/lina-core/test/persona-schema.test.ts +++ b/packages/lina-core/test/persona-schema.test.ts @@ -178,6 +178,58 @@ test.each([ expect(parsePersonaSchema(schema)).toEqual(schema); }); +test.each([1, 2] as const)( + "manual v%i identity locks every projected kind without per-axis locks", + (version) => { + const profile = { + ...lockProfile, + evolution: "manual" as const, + lockedTraitIds: [], + }; + const schema = personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { + ...definition, + projection: { + ...definition.projection, + sharedTraitIds: ["warmth"], + }, + }, + identity: + version === 1 + ? { version, profiles: [profile] } + : { + version, + profiles: [ + { ...profile, personalBehavior: null, sourceStamp: null }, + ], + }, + }); + expect(schema.dimensions.map((row) => [row.id, row.locked])).toEqual([ + ["warmth", true], + ["tea", true], + ["trust", true], + ]); + expect(schema.sourceIdentity).toEqual({ profileRevision: 4 }); + expect(parsePersonaSchema(schema)).toEqual(schema); + }, +); + +test.each([null, identityV1, identityV2])( + "derivation rejects nonparticipants regardless of identity: %j", + (identity) => { + expect(() => + personaSchemaFromLifeDefinition({ + agentId: "lina", + revision: 1, + definition: { ...definition, participants: ["mira"] }, + identity, + }), + ).toThrow("Unknown world agent"); + }, +); + test("derivation digest is idempotent and ignores v2-only identity fields", () => { const first = derive(); const second = derive(); @@ -213,6 +265,7 @@ test("derivation digest is idempotent and ignores v2-only identity fields", () = { ...lockProfile, agentId: "mira", + evolution: "manual", personalBehavior: null, sourceStamp: null, }, From e5dcaaaaccfe16c338e5657689f435ccc951a1a0 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:02:08 +0900 Subject: [PATCH 25/47] fix(core): bound canonical option keys for valid targets Keep catalog and kind in the key prefix and bind the complete target through the semantic digest, so every allowed target produces a parseable key. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-catalog.ts | 3 +- .../test/judgment-catalog-review.test.ts | 39 ++++++++++++++++--- .../lina-core/test/judgment-policy.test.ts | 8 ++-- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-catalog.ts b/packages/lina-core/src/agents/judgment-catalog.ts index 7a6f3ce..94408f2 100644 --- a/packages/lina-core/src/agents/judgment-catalog.ts +++ b/packages/lina-core/src/agents/judgment-catalog.ts @@ -165,7 +165,8 @@ export function canonicalOptionKey( scope: boundedId(option.effect.scope, "effect scope"), }, }); - return `${option.catalogId}:${kind}:${targetId ?? "-"}:${hash}`; + // The digest binds the target without exceeding the shared ID bound. + return `${option.catalogId}:${kind}:${hash}`; } function parsePrecondition( diff --git a/packages/lina-core/test/judgment-catalog-review.test.ts b/packages/lina-core/test/judgment-catalog-review.test.ts index 1dc3194..ed2cb8e 100644 --- a/packages/lina-core/test/judgment-catalog-review.test.ts +++ b/packages/lina-core/test/judgment-catalog-review.test.ts @@ -69,6 +69,28 @@ test("identity fixtures cover every personal option kind", () => { }); for (const precondition of preconditions) { + for (const length of [100, 160]) { + test(`${precondition.kind} round-trips a ${length}-character target with a bounded key`, () => { + const original = option(precondition); + const targetId = "x".repeat(length); + const built = buildCanonicalOption({ ...original, targetId }); + expect(built.targetId).toBe(targetId); + expect(built.optionKey.length).toBeLessThanOrEqual(160); + expect(built.optionKey.length).toBe(original.optionKey.length); + expect(parseCanonicalOption(JSON.parse(JSON.stringify(built)))).toEqual( + built, + ); + const changed = buildCanonicalOption({ + ...built, + targetId: `${targetId.slice(0, -1)}y`, + }); + expect(changed.optionKey).not.toBe(built.optionKey); + expect(() => + parseCanonicalOption({ ...changed, optionKey: built.optionKey }), + ).toThrow("canonical option key mismatch"); + }); + } + for (const [field, value] of Object.entries(precondition)) { if (field === "kind") continue; test(`${precondition.kind} identity binds preconditions.${field}`, () => { @@ -164,19 +186,24 @@ for (const effect of [ }); } -test("keys retain catalog/kind/target prefix, argument sensitivity and null sentinel", () => { +test("bounded keys retain catalog/kind prefix and bind arguments and nullable target", () => { const original = option({ kind: "noop", reason: "nothing needed" }); - expect(original.optionKey).toMatch(/^personal\.v1:noop:target:[a-f0-9]{64}$/); - expect(canonicalOptionKey({ ...original, targetId: null })).toMatch( - /^personal\.v1:noop:-:[a-f0-9]{64}$/, - ); + expect(original.optionKey).toMatch(/^personal\.v1:noop:[a-f0-9]{64}$/); + const untargeted = buildCanonicalOption({ ...original, targetId: null }); + expect(untargeted.optionKey).toMatch(/^personal\.v1:noop:[a-f0-9]{64}$/); + expect(untargeted.optionKey).not.toBe(original.optionKey); + expect(parseCanonicalOption(untargeted)).toEqual(untargeted); expect(canonicalOptionKey({ ...original, targetId: "other" })).not.toBe( original.optionKey, ); expect( canonicalOptionKey({ ...original, args: { text: "different" } }), ).not.toBe(original.optionKey); - expect(() => canonicalOptionKey({ ...original, targetId: " - " })).toThrow(); + for (const targetId of [" - ", "x".repeat(161)]) { + expect(() => canonicalOptionKey({ ...original, targetId })).toThrow(); + expect(() => buildCanonicalOption({ ...original, targetId })).toThrow(); + expect(() => parseCanonicalOption({ ...original, targetId })).toThrow(); + } }); test("precondition text is not normalized like display arguments", () => { diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts index 6825766..d1ce1cf 100644 --- a/packages/lina-core/test/judgment-policy.test.ts +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -310,14 +310,16 @@ test("(1) canonical keys normalize case, target, sorted args, NFC, whitespace an expect(built.targetId).toBe("task"); expect(built.args).toEqual(b.args); expect(built.optionKey).toBe(canonicalOptionKey(b)); - expect(canonicalOptionKey({ ...base, targetId: null })).toContain(":-:"); + expect(canonicalOptionKey({ ...base, targetId: null })).not.toBe( + canonicalOptionKey(base), + ); }); test("reserved target sentinel is rejected without changing null keys", () => { const base = option("target"); const untargeted = buildCanonicalOption({ ...base, targetId: null }); expect(untargeted.targetId).toBeNull(); - expect(untargeted.optionKey).toContain(":noop:-:"); + expect(untargeted.optionKey).toMatch(/^personal\.v1:noop:[a-f0-9]{64}$/); expect(parseCanonicalOption(untargeted)).toEqual(untargeted); for (const targetId of ["-", " - "]) for (const action of [ @@ -339,7 +341,7 @@ test("(2) golden option parses byte-identically and rejects invalid boundary dat preconditions: { kind: "noop", reason: "nothing needed" }, effect: { owner: "none", scope: "scope" }, optionKey: - "personal.v1:noop:-:87529c8e7be62a166697c588491676440f0dd893f67561788cb6c2462904ddcf", + "personal.v1:noop:87529c8e7be62a166697c588491676440f0dd893f67561788cb6c2462904ddcf", }; expect(JSON.stringify(parseCanonicalOption(golden))).toBe( JSON.stringify(golden), From a8c93405034fa3750a37ac81c13e2bfe2e7b9318 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:02:51 +0900 Subject: [PATCH 26/47] fix(core): honor judgment source ownership contracts Require user acceptance for user commitments. Preserve context-owned source identifier bounds without widening agent or unknown-owner identifiers; cover real context-to-judgment persistence. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../src/agents/judgment-validation.ts | 27 ++- .../lina-core/test/judgment-contract.test.ts | 57 +++++ .../test/judgment-owner-review.test.ts | 220 ++++++++++++++++++ 3 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 packages/lina-core/test/judgment-owner-review.test.ts diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index b8a440c..781e705 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { validId } from "../context/validation.ts"; import { ACCEPTED_BY, type Assessment, @@ -289,9 +290,15 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { row["sourceRefs"], (value) => { const source = fields(value, ["kind", "id", "revision"], "source ref"); + const kind = boundedId(source["kind"], "source kind"); + // Context-owned refs keep their owner's bound; unknown kinds stay opaque. + const parseId = + kind === "request" || kind === "entry" || kind === "summary" + ? validId + : boundedId; return { - kind: boundedId(source["kind"], "source kind"), - id: boundedId(source["id"], "source id"), + kind, + id: parseId(source["id"], "source id"), revision: revision(source["revision"], "source revision"), }; }, @@ -826,6 +833,14 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { ["sourceRef", "acceptedBy", "policyRevision", "acceptedAt"], "intention acceptance", ); + const kind = enumeration(row["kind"], INTENTION_KINDS, "intention kind"); + const acceptedBy = enumeration( + acceptance["acceptedBy"], + ACCEPTED_BY, + "accepted by", + ); + if (kind === "user_commitment" && acceptedBy !== "user") + throw Error("user commitment requires user acceptance"); const sourceRef = boundedId(acceptance["sourceRef"], "acceptance source ref"); const history = list( row["history"], @@ -854,16 +869,12 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { agentId: boundedId(row["agentId"], "agent id"), scopeId: boundedId(row["scopeId"], "scope id"), revision: recordRevision, - kind: enumeration(row["kind"], INTENTION_KINDS, "intention kind"), + kind, purposeRef: boundedId(row["purposeRef"], "purpose ref"), text: boundedText(row["text"], "intention text"), acceptance: { sourceRef, - acceptedBy: enumeration( - acceptance["acceptedBy"], - ACCEPTED_BY, - "accepted by", - ), + acceptedBy, policyRevision: revision( acceptance["policyRevision"], "acceptance policy revision", diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 491d495..395a5a1 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -400,6 +400,42 @@ test("snapshot opaque refs and independently bounded revisions", () => { ).toThrow(/unknown/); }); +test("snapshot source IDs use context owner bounds without widening agent or unknown-owner IDs", () => { + for (const kind of ["request", "entry", "summary"]) { + for (const length of [160, 161, 256]) { + const source = { kind, id: "x".repeat(length), revision: 0 }; + expect( + parseJudgmentSnapshotRef({ ...snapshot, sourceRefs: [source] }) + .sourceRefs, + ).toEqual([source]); + } + for (const id of ["x".repeat(257), "", " ", "x\u0000y", null, 42]) + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [{ kind, id, revision: 0 }], + }), + ).toThrow("invalid source id"); + } + for (const kind of ["custom-owner", "Entry", " entry "]) { + const source = { kind, id: "x".repeat(160), revision: 0 }; + expect( + parseJudgmentSnapshotRef({ ...snapshot, sourceRefs: [source] }) + .sourceRefs, + ).toEqual([source]); + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [{ ...source, id: "x".repeat(161) }], + }), + ).toThrow("invalid source id"); + } + for (const agentId of ["x".repeat(161), "x".repeat(256)]) + expect(() => parseJudgmentSnapshotRef({ ...snapshot, agentId })).toThrow( + "invalid agent id", + ); +}); + test("snapshot requires a bounded policy identity without a fallback", () => { const { policyRevision: _revision, ...withoutRevision } = snapshot; expect(() => parseJudgmentSnapshotRef(withoutRevision)).toThrow(); @@ -608,6 +644,27 @@ test("resolution hold reason is present exactly when unresolved", () => { ).toThrow(); }); +test("intention acceptance requires user authority only for user commitments", () => { + for (const kind of INTENTION_KINDS) { + for (const acceptedBy of ACCEPTED_BY) { + const record = { + ...intention, + kind, + acceptance: { ...intention.acceptance, acceptedBy }, + }; + if (kind === "user_commitment" && acceptedBy === "host_autonomy") { + for (const candidate of [record, adopt(record)]) + expect(() => parseIntentionRecord(candidate)).toThrow( + "user commitment requires user acceptance", + ); + } else { + expect(parseIntentionRecord(record)).toEqual(record); + expect(parseIntentionRecord(adopt(record))).toEqual(adopt(record)); + } + } + } +}); + function adopt(record = intention): IntentionRecord { return transitionIntention(record, { to: "adopted", diff --git a/packages/lina-core/test/judgment-owner-review.test.ts b/packages/lina-core/test/judgment-owner-review.test.ts new file mode 100644 index 0000000..d84d6d1 --- /dev/null +++ b/packages/lina-core/test/judgment-owner-review.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { + ACCEPTED_BY, + INTENTION_KINDS, + type IntentionRecord, + type JudgmentSnapshotRef, + JudgmentStore, + MODULE_KINDS, + parseJudgmentSnapshotRef, + snapshotDigest, +} from "../src/agents/index.ts"; +import { + buildContextReadProjection, + parseContextReadProjection, +} from "../src/context/index.ts"; +import { ContextStore } from "../src/context/store.ts"; +import { entry, Fixture } from "./fixture.ts"; + +let fixture: Fixture; +const stores = new Set(); +beforeEach(() => { + fixture = new Fixture(); +}); +afterEach(() => { + for (const store of stores) store.close(); + stores.clear(); + fixture.close(); +}); + +const at = "2026-09-12T00:00:00.000Z"; + +function openJudgment(): JudgmentStore { + const store = new JudgmentStore(join(fixture.dir, "judgment.sqlite"), { + now: () => 1234, + }); + stores.add(store); + return store; +} + +function snapshot(store: JudgmentStore): JudgmentSnapshotRef { + for (const moduleKind of MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: `objective-${moduleKind}`, + moduleKind, + revision: 1, + objective: "Compare outcomes", + comparisonCriteria: ["cost"], + reconsiderationConditions: ["new evidence"], + }); + store.activateObjectiveProfile("agent-1", "scope-1", ref); + } + const refs = store.activeObjectiveProfiles("agent-1", "scope-1"); + if (!refs) throw Error("missing fixture profiles"); + return { + schemaVersion: 1, + roundId: "round-1", + agentId: "agent-1", + scopeId: "scope-1", + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 1, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock-1", + sequence: 1, + bindingGeneration: 0, + }; +} + +test("durable 256-character entry survives context projection and persisted judgment round", () => { + const durable = fixture.store(); + // Durable requests are capped at 128; context entry IDs allow 256. + const requestId = "r".repeat(128); + const entryId = "e".repeat(256); + const input = entry(entryId, { role: "user", text: "Keep the commitment" }); + expect(durable.createRequest(requestId, input.text).created).toBe(true); + durable.registerRequestSource({ + version: 1, + purpose: "conversation", + sessionId: fixture.binding.sessionId, + requestId, + nativeEpoch: 1, + scopeDigest: "a".repeat(64), + contextReceiptIds: [], + }); + expect(durable.appendSourceEntry(input, requestId)).toBe(true); + durable.setRequest(requestId, "accepted", { entryId }); + durable.setRequest(requestId, "settled"); + const context = fixture.keep( + new ContextStore( + join(fixture.dir, "context.sqlite"), + fixture.binding, + (id) => durable.sourceEntry(id), + { + lookupRequest: (id) => + durable.sourceEntry(durable.request(id)?.entryId ?? ""), + }, + ), + ); + const working = context.updateWorking( + 0, + { goal: input.text, sourceEntryIds: [entryId] }, + { activeRequestId: requestId }, + ); + expect(context.working()).toEqual(working); + expect(working.sourceEntryIds).toEqual([entryId]); + const projection = buildContextReadProjection({ + working, + instruction: { requestId, entryId, text: input.text }, + previous: null, + projectedAt: at, + }); + expect( + parseContextReadProjection(JSON.parse(JSON.stringify(projection))), + ).toEqual(projection); + const instruction = projection.instruction; + if (!instruction) throw Error("missing fixture instruction"); + const store = openJudgment(); + const ref = { + ...snapshot(store), + workingRevision: projection.workingRevision, + instructionRevision: projection.instructionRevision, + sourceRefs: [ + { kind: "entry", id: instruction.entryId, revision: 0 }, + { kind: "request", id: instruction.requestId, revision: 0 }, + ], + }; + expect(parseJudgmentSnapshotRef(ref)).toEqual(ref); + expect(store.openRound(ref)).toEqual({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + }); + expect(store.getRound(ref.roundId)?.snapshot).toEqual(ref); + store.close(); + stores.delete(store); + expect(openJudgment().getRound(ref.roundId)).toEqual({ + snapshot: ref, + status: "open", + snapshotDigest: snapshotDigest(ref), + }); +}); + +test("context builder and parser preserve 256-character request and entry references for judgment", () => { + const projection = buildContextReadProjection({ + working: { + revision: 0, + goal: "", + decisions: [], + openItems: [], + nextSteps: [], + sourceEntryIds: [], + }, + instruction: { + requestId: "r".repeat(256), + entryId: "e".repeat(256), + text: "Instruction", + }, + previous: null, + projectedAt: at, + }); + const { instruction } = parseContextReadProjection(projection); + if (!instruction) throw Error("missing fixture instruction"); + const ref = { + ...snapshot(openJudgment()), + sourceRefs: [ + { kind: "entry", id: instruction.entryId, revision: 0 }, + { kind: "request", id: instruction.requestId, revision: 0 }, + ], + }; + expect(parseJudgmentSnapshotRef(ref)).toEqual(ref); +}); + +test("store rejects host-accepted user commitments without rejecting other authority pairs", () => { + const store = openJudgment(); + for (const kind of INTENTION_KINDS) { + for (const acceptedBy of ACCEPTED_BY) { + const record: IntentionRecord = { + schemaVersion: 1, + intentionId: `${kind}-${acceptedBy}`, + agentId: "agent-1", + scopeId: "scope-1", + revision: 0, + kind, + purposeRef: "purpose-1", + text: "Keep the commitment", + acceptance: { + sourceRef: "source-1", + acceptedBy, + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "Outcome receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }; + if (kind === "user_commitment" && acceptedBy === "host_autonomy") { + expect(() => store.putIntention(record)).toThrow( + "user commitment requires user acceptance", + ); + expect(store.getIntention(record.intentionId)).toBeNull(); + } else { + store.putIntention(record); + expect(store.getIntention(record.intentionId)).toEqual(record); + } + } + } +}); From d9cadf7e39353d1a82e23cea44066e57c0f8f739 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:06:33 +0900 Subject: [PATCH 27/47] fix(core): bind candidates and concessions to the round Reject foreign agent/scope candidates before eligibility. Record concessions only for preferred candidates that reached ordering, preserving earlier exclusion reasons separately. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-policy.ts | 10 ++- .../test/judgment-policy-review.test.ts | 86 +++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index 771804b..a9a98ae 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -90,6 +90,13 @@ export function resolvePersonalRound(input: { ); const keys = new Set(options.map((o) => o.optionKey)); if (keys.size !== options.length) throw Error("duplicate option key"); + for (const option of options) { + if ( + option.actor.agentId !== snapshot.agentId || + option.actor.scopeId !== snapshot.scopeId + ) + throw Error("option actor snapshot mismatch"); + } const eligibility = new Map(); for (const row of input.eligibility) { if (!keys.has(row.optionKey)) throw Error("unknown eligibility option key"); @@ -265,10 +272,11 @@ export function resolvePersonalRound(input: { const winners = new Set( record.ranking.filter((r) => r.rank === 1).map((r) => r.optionKey), ); + const ranked = new Set(record.ranking.map((r) => r.optionKey)); for (const assessment of set.assessments) { for (const opinion of assessment.objectiveAssessments) { if ( - keys.has(opinion.optionKey) && + ranked.has(opinion.optionKey) && opinion.stance === "prefer" && !winners.has(opinion.optionKey) ) diff --git a/packages/lina-core/test/judgment-policy-review.test.ts b/packages/lina-core/test/judgment-policy-review.test.ts index 64d073c..7e90a23 100644 --- a/packages/lina-core/test/judgment-policy-review.test.ts +++ b/packages/lina-core/test/judgment-policy-review.test.ts @@ -114,6 +114,92 @@ function fixture(): Parameters[0] { }; } +for (const field of ["agentId", "scopeId"] as const) { + test(`rejects candidate ${field} outside its frozen round`, () => { + const input = fixture(); + const original = input.options[0]; + if (!original) throw Error("missing candidate"); + const foreign = buildCanonicalOption({ + ...original, + actor: { ...original.actor, [field]: "foreign" }, + }); + input.options = [foreign]; + input.eligibility = [ + { optionKey: foreign.optionKey, eligible: true, reason: null }, + ]; + for (const assessment of input.set.assessments) { + assessment.proposedOptionKeys = [foreign.optionKey]; + assessment.recommendedOptionKeys = [foreign.optionKey]; + for (const opinion of assessment.objectiveAssessments) + opinion.optionKey = foreign.optionKey; + } + expect(parseAssessmentSet(input.set)).toEqual(input.set); + expect(() => resolvePersonalRound(input)).toThrow( + "option actor snapshot mismatch", + ); + }); +} + +for (const removal of ["host", "commitment", "infeasible", "ranked"] as const) { + test(`concessions distinguish ${removal} exclusion from a ranked preference loss`, () => { + const input = fixture(); + const preferred = input.options[0]; + if (!preferred) throw Error("missing candidate"); + const alternative = buildCanonicalOption({ + ...preferred, + targetId: "alternative", + }); + input.options.push(alternative); + input.eligibility = [ + { + optionKey: preferred.optionKey, + eligible: removal !== "host", + reason: removal === "host" ? "not authorized" : null, + }, + { optionKey: alternative.optionKey, eligible: true, reason: null }, + ]; + for (const assessment of input.set.assessments) { + const opinion = assessment.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + assessment.objectiveAssessments.push({ + ...opinion, + optionKey: alternative.optionKey, + stance: + removal === "ranked" && assessment.moduleKind === "lachesis" + ? "prefer" + : "accept", + }); + if (removal === "ranked" && assessment.moduleKind === "lachesis") + opinion.stance = "accept"; + if (removal === "commitment" && assessment.moduleKind === "atropos") { + opinion.stance = "oppose"; + opinion.severity = "commitment_breach"; + } + if (removal === "infeasible" && assessment.moduleKind === "clotho") { + opinion.stance = "oppose"; + opinion.severity = "infeasible"; + } + } + const { resolution } = resolvePersonalRound(input); + expect(resolution.status).toBe("resolved"); + if (removal === "ranked") { + expect(resolution.conceded).toContainEqual({ + moduleKind: "clotho", + optionKey: preferred.optionKey, + }); + expect(resolution.ranking).toContainEqual({ + optionKey: preferred.optionKey, + rank: 2, + }); + } else { + expect(resolution.excluded.map((row) => row.optionKey)).toContain( + preferred.optionKey, + ); + expect(resolution.conceded).toEqual([]); + } + }); +} + test("personal.v1 revision one rejects altered or malformed declarations", () => { const input = fixture(); for (const change of [ From 1370e902fa41c3e33e7cc454ee56959609457639 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:15:35 +0900 Subject: [PATCH 28/47] test(core): isolate stable orphan quota adoption setup Seed the inert attachment population as real files and one durable manifest, preserving actual full-quota admission, orphan adoption, restart, replay and integrity checks. Seven quota and replay mutations remain detectable; no production durability or timeout changes. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../test/attachments-image-quota.test.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/lina-core/test/attachments-image-quota.test.ts b/packages/lina-core/test/attachments-image-quota.test.ts index ae3b67f..f69d36a 100644 --- a/packages/lina-core/test/attachments-image-quota.test.ts +++ b/packages/lina-core/test/attachments-image-quota.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect, test } from "bun:test"; +import { randomUUID } from "node:crypto"; import { mkdirSync, mkdtempSync, @@ -8,12 +9,14 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { atomicJson } from "../src/attachments/filesystem.ts"; import { ATTACHMENT_MAX_BYTES, ATTACHMENT_MAX_FILES, ATTACHMENT_MAX_TOTAL_BYTES, AttachmentStore, } from "../src/attachments/store.ts"; +import type { AttachmentManifest } from "../src/attachments/types.ts"; const roots: string[] = []; afterEach(() => { @@ -43,12 +46,30 @@ test.each(["files", "bytes"] as const)( ? ATTACHMENT_MAX_TOTAL_BYTES / bytes.length : ATTACHMENT_MAX_FILES; const store = new AttachmentStore(root, binding); - for (let index = 0; index < limit - 1; index++) - store.put(`saved-${index}.txt`, bytes); + const seed = store.put("saved-0.txt", bytes); store.close(); + // Persist the inert population once, using a real upload's metadata. + // Reopen still validates every file; adoption and replay use the real store. + const files = [seed]; + for (let index = 1; index < limit - 1; index++) { + const saved = { + ...seed, + id: randomUUID(), + name: `saved-${index}.txt`, + }; + writeFileSync(join(root, "attachments/files", saved.id), bytes); + files.push(saved); + } + atomicJson(join(root, "attachments/manifest.json"), { + version: 1, + binding, + files, + totalBytes: bytes.length * files.length, + } satisfies AttachmentManifest); const id = "33333333-3333-4333-8333-333333333333"; writeFileSync(join(root, "attachments/files", id), bytes); const recovered = new AttachmentStore(root, binding); + expect(() => recovered.get(id)).toThrow(/not found/); expect(() => recovered.preflight(1)).toThrow(/quota/); expect(() => recovered.put("overflow.txt", new TextEncoder().encode("x")), @@ -63,6 +84,7 @@ test.each(["files", "bytes"] as const)( const reopened = new AttachmentStore(root, binding); expect(() => reopened.preflight(1)).toThrow(/quota/); expect(reopened.put("result.txt", bytes, id)).toEqual(receipt); + expect(reopened.bytes(id)).toEqual(bytes); expect(() => reopened.put("overflow.txt", new TextEncoder().encode("x")), ).toThrow(/quota/); @@ -70,6 +92,7 @@ test.each(["files", "bytes"] as const)( readFileSync(join(root, "attachments/manifest.json"), "utf8"), ); expect(manifest.files).toHaveLength(limit); + expect(manifest.files).toEqual([...files, receipt]); expect(manifest.totalBytes).toBe(bytes.length * limit); reopened.close(); }, From 7bff0aea7dad8ab8f6e60df66c935596661ecaf1 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:53:07 +0900 Subject: [PATCH 29/47] fix(core): validate identity policy before persona derivation Reject unsupported, duplicate and malformed identity policies through the existing LIFE parser before selecting locks. Preserve valid v1/v2 and null identity behavior. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/persona-schema.ts | 7 +- .../lina-core/test/persona-schema.test.ts | 90 ++++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/lina-core/src/agents/persona-schema.ts b/packages/lina-core/src/agents/persona-schema.ts index 7074d23..df9bcec 100644 --- a/packages/lina-core/src/agents/persona-schema.ts +++ b/packages/lina-core/src/agents/persona-schema.ts @@ -5,7 +5,10 @@ import type { IdentityProfilePolicy, LifeDefinition, } from "../world/life-types.ts"; -import { parseLifeDefinition } from "../world/life-validation.ts"; +import { + parseIdentityPolicy, + parseLifeDefinition, +} from "../world/life-validation.ts"; import { knownAgents } from "../world/validation.ts"; import { DIMENSION_SOURCES, type DimensionSource } from "./behavior-types.ts"; import { boundedId } from "./validation.ts"; @@ -278,7 +281,7 @@ function profileFor( agentId: string, ): IdentityProfilePolicy | null { if (identity === null) return null; - for (const profile of identity.profiles) + for (const profile of parseIdentityPolicy(identity).profiles) if (profile.agentId === agentId) return profile; return null; } diff --git a/packages/lina-core/test/persona-schema.test.ts b/packages/lina-core/test/persona-schema.test.ts index 208ef5a..e591a4e 100644 --- a/packages/lina-core/test/persona-schema.test.ts +++ b/packages/lina-core/test/persona-schema.test.ts @@ -14,6 +14,8 @@ import { personaSchemaDigest, personaSchemaFromLifeDefinition, } from "../src/agents/index.ts"; +import type { IdentityPolicySnapshot } from "../src/world/life-types.ts"; +import { parseIdentityPolicy } from "../src/world/life-validation.ts"; const HASH = "a".repeat(64); const PINNED_BEHAVIOR_FINGERPRINT = @@ -64,7 +66,7 @@ const identityV2 = { }; function derive( - identity: typeof identityV1 | typeof identityV2 | null = identityV2, + identity: IdentityPolicySnapshot | null = identityV2, ): PersonaSchema { return personaSchemaFromLifeDefinition({ agentId: "lina", @@ -277,6 +279,92 @@ test("derivation digest is idempotent and ignores v2-only identity fields", () = expect(missing.digest).toBe(unlocked.digest); }); +test.each([identityV1, identityV2])( + "derivation rejects duplicate profiles: %j", + (identity) => { + const profile = identity.profiles[0]; + if (!profile) throw Error("expected profile"); + const manual = { ...profile, evolution: "manual" as const }; + for (const profiles of [ + [profile, manual], + [manual, profile], + ]) { + const invalid = { ...identity, profiles } as IdentityPolicySnapshot; + expect(() => parseIdentityPolicy(invalid)).toThrow(); + expect(() => derive(invalid)).toThrow(); + } + }, +); + +test.each([ + { ...identityV1, version: 3 }, + { ...identityV1, extra: true as const }, + { profiles: identityV1.profiles }, + { version: 1, profiles: {} }, +])("derivation rejects malformed identity snapshots: %j", (identity) => { + expect(() => parseIdentityPolicy(identity)).toThrow(); + expect(() => derive(identity as IdentityPolicySnapshot)).toThrow(); +}); + +test.each([ + { evolution: "automatic" }, + { profileRevision: 0 }, + { extra: true }, + { lockedTraitIds: "warmth" }, + { lockedTraitIds: ["warmth", "warmth"] }, + { lockedHabitIds: [1] }, + { lockedAttitudeIds: null }, + { personalBehavior: {} }, + { personalBehavior: { traits: [], habits: [] } }, + { sourceStamp: {} }, + { + personalBehavior: { traits: [], habits: [] }, + sourceStamp: { + digest: HASH, + receiptRevision: 1, + profileRevision: 5, + definitionRevision: 1, + projectionRevision: 1, + }, + }, +])( + "derivation validates every identity profile before selection: %j", + (patch) => { + const valid = { ...lockProfile, personalBehavior: null, sourceStamp: null }; + const invalid = { ...valid, ...patch }; + const unrelated = { ...invalid, agentId: "mira" }; + for (const profiles of [[invalid], [valid, unrelated], [unrelated]]) { + const identity = { version: 2, profiles } as IdentityPolicySnapshot; + expect(() => parseIdentityPolicy(identity)).toThrow(); + expect(() => derive(identity)).toThrow(); + } + }, +); + +test("valid v2 personal values and source anchors do not affect derivation", () => { + const identity: IdentityPolicySnapshot = { + version: 2, + profiles: [ + { + ...lockProfile, + personalBehavior: { + traits: [{ axisId: "other-world-axis", value: 42 }], + habits: [{ habitId: "other-world-habit", value: false }], + }, + sourceStamp: { + digest: HASH, + receiptRevision: 1, + profileRevision: lockProfile.profileRevision, + definitionRevision: 2, + projectionRevision: 2, + }, + }, + ], + }; + expect(parseIdentityPolicy(identity)).toEqual(identity); + expect(derive(identity)).toEqual(derive(identityV1)); +}); + test("parsePersonaSchema round-trips a derived schema", () => { const schema = derive(); const parsed = parsePersonaSchema(schema); From 3b769ebaefa2891d65d93c2248f8f938e7d404d2 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:53:57 +0900 Subject: [PATCH 30/47] fix(core): preserve abstention and neural failure semantics Omit nonparticipating modules from concessions. A missing bias for any positive-mass candidate disables decision-wide modulation while preserving original lookup evidence and excluded-candidate behavior. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-policy.ts | 8 +- .../test/judgment-policy-review.test.ts | 95 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index a9a98ae..b704a70 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -274,6 +274,7 @@ export function resolvePersonalRound(input: { ); const ranked = new Set(record.ranking.map((r) => r.optionKey)); for (const assessment of set.assessments) { + if (!orderingModules.includes(assessment.moduleKind)) continue; for (const opinion of assessment.objectiveAssessments) { if ( ranked.has(opinion.optionKey) && @@ -347,7 +348,10 @@ export function sampleSelection( }; if (!Number.isFinite(u) || u < 0 || u >= 1) throw Error("invalid selection draw"); - const logs = positive.map((c) => Math.log(c.p0) + parsed.lambda * (c.b ?? 0)); + // Preserve lookup evidence in the spec; a partial failure disables the + // decision's entire modulation, not just the unavailable candidate's bias. + const lambda = positive.some((c) => c.b === null) ? 0 : parsed.lambda; + const logs = positive.map((c) => Math.log(c.p0) + lambda * (c.b ?? 0)); const max = Math.max(...logs); const total = logs.reduce((sum, log) => sum + Math.exp(log - max), 0); const probabilities = parsed.candidates.map((c) => ({ @@ -355,7 +359,7 @@ export function sampleSelection( p: c.p0 === 0 ? 0 - : Math.exp(Math.log(c.p0) + parsed.lambda * (c.b ?? 0) - max) / total, + : Math.exp(Math.log(c.p0) + lambda * (c.b ?? 0) - max) / total, })); let cumulative = 0; let lastPositive: OptionKey | undefined; diff --git a/packages/lina-core/test/judgment-policy-review.test.ts b/packages/lina-core/test/judgment-policy-review.test.ts index 7e90a23..4d5a7e2 100644 --- a/packages/lina-core/test/judgment-policy-review.test.ts +++ b/packages/lina-core/test/judgment-policy-review.test.ts @@ -232,6 +232,101 @@ test("personal.v1 revision one rejects altered or malformed declarations", () => ).toEqual(resolvePersonalRound(input)); }); +test("a module omitted from round ordering makes no concession", () => { + const input = fixture(); + const original = input.options[0]; + if (!original) throw Error("missing candidate"); + const alternative = buildCanonicalOption({ + ...original, + targetId: "alternative", + }); + input.options.push(alternative); + input.eligibility.push({ + optionKey: alternative.optionKey, + eligible: true, + reason: null, + }); + for (const assessment of input.set.assessments) { + const opinion = assessment.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + opinion.stance = assessment.moduleKind === "clotho" ? "prefer" : "accept"; + assessment.objectiveAssessments.push({ + ...opinion, + optionKey: alternative.optionKey, + stance: assessment.moduleKind === "clotho" ? "unavailable" : "prefer", + unavailableReason: + assessment.moduleKind === "clotho" ? "missing forecast" : null, + }); + } + const result = resolvePersonalRound(input); + expect(result.resolution.ranking).toContainEqual({ + optionKey: original.optionKey, + rank: 2, + }); + expect(result.resolution.abstentions).toContainEqual({ + moduleKind: "clotho", + optionKey: alternative.optionKey, + reason: "missing forecast", + }); + expect(result.resolution.conceded).toEqual([]); +}); + +test("partial neural lookup failure keeps the entire decision at its baseline distribution", () => { + const input = fixture(); + const original = input.options[0]; + if (!original) throw Error("missing candidate"); + const alternative = buildCanonicalOption({ + ...original, + targetId: "alternative", + }); + input.options.push(alternative); + input.eligibility.push({ + optionKey: alternative.optionKey, + eligible: true, + reason: null, + }); + for (const assessment of input.set.assessments) { + const opinion = assessment.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + assessment.objectiveAssessments.push({ + ...opinion, + optionKey: alternative.optionKey, + stance: "accept", + }); + } + input.bias = { [original.optionKey]: null, [alternative.optionKey]: 1 }; + const { spec } = resolvePersonalRound(input); + if (!spec) throw Error("missing selection spec"); + const restored = JSON.parse(JSON.stringify(spec)); + const sampled = sampleSelection(restored, 0.5); + expect( + sampled.probabilities.find((row) => row.optionKey === original.optionKey) + ?.p, + ).toBeCloseTo(2 / 3, 12); + expect( + sampled.probabilities.find((row) => row.optionKey === alternative.optionKey) + ?.p, + ).toBeCloseTo(1 / 3, 12); + + const excluded = buildCanonicalOption({ + ...original, + targetId: "excluded", + }); + input.options.push(excluded); + input.bias = { [original.optionKey]: 0, [alternative.optionKey]: 1 }; + const complete = resolvePersonalRound(input).spec; + if (!complete) throw Error("missing complete spec"); + const modulated = sampleSelection(complete, 0.5); + expect( + modulated.probabilities.find((row) => row.optionKey === original.optionKey) + ?.p, + ).toBeCloseTo(2 / (2 + Math.E), 12); + expect( + modulated.probabilities.find((row) => row.optionKey === excluded.optionKey) + ?.p, + ).toBe(0); +}); + for (const outcome of ["resolved", "held", "deferred"] as const) { function round() { const input = fixture(); From 49b2dbbdf6ecc2d2a1137c1801ac1ef7c789b0ad Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:55:57 +0900 Subject: [PATCH 31/47] fix(core): audit judgment ledger before scoped operations Validate digests, indexed metadata, transition history and frozen references before filters can hide corrupt rows. Bind selection candidates, ranks and baseline mass to persisted assessments. Reuse successful audits across owner writes and revalidate external commits within one read snapshot. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-store.ts | 684 +++++++++++++----- .../test/judgment-store-digest-review.test.ts | 61 +- .../lina-core/test/judgment-store.test.ts | 504 ++++++++++++- 3 files changed, 1070 insertions(+), 179 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 7b98391..f114e91 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -16,6 +16,7 @@ import { type RoundStatus, type SelectionSpec, } from "./judgment.ts"; +import { PERSONAL_POLICY_V1, rankMass } from "./judgment-policy.ts"; import { initializeJudgmentSchema } from "./judgment-schema.ts"; import { canonicalJson, @@ -44,11 +45,126 @@ function revision(value: number, minimum = 0): number { return value; } +function validateCandidateBinding( + resolution: ResolutionRecord, + selection: SelectionSpec, + set: AssessmentSet, + snapshot: JudgmentSnapshotRef, +): void { + if (selection.assessmentSetDigest !== judgmentDigest(set)) + throw Error("selection assessment set mismatch"); + if (selection.resolutionDigest !== judgmentDigest(resolution)) + throw Error("selection resolution digest mismatch"); + if ( + selection.policyId !== resolution.policyId || + selection.policyRevision !== resolution.policyRevision || + selection.situation !== resolution.situation + ) + throw Error("selection policy mismatch"); + if ( + body(selection.objectiveProfileRefs) !== body(snapshot.objectiveProfileRefs) + ) + throw Error("selection objective refs mismatch"); + const ranked = new Set(resolution.ranking.map((r) => r.optionKey)); + const universe = new Set([ + ...ranked, + ...resolution.excluded.map((r) => r.optionKey), + ]); + if ( + universe.size !== ranked.size + resolution.excluded.length || + selection.candidates.length !== universe.size || + selection.candidates.some((c) => !universe.has(c.optionKey)) || + [ + ...Object.values(resolution.recommendations).flat(), + ...resolution.conflicts.map((r) => r.optionKey), + ...resolution.abstentions.map((r) => r.optionKey), + ...resolution.conceded.map((r) => r.optionKey), + ...set.assessments.flatMap((a) => [ + ...a.proposedOptionKeys, + ...a.recommendedOptionKeys, + ...a.objectiveAssessments.map((o) => o.optionKey), + ]), + ].some((key) => !universe.has(key)) + ) + throw Error("selection candidate universe mismatch"); + // Only this policy has a declared baseline in F1. Do not guess a ratio for + // another catalog/revision; held/deferred records do not need a baseline. + const policy = PERSONAL_POLICY_V1; + if ( + selection.policyId !== policy.policyId || + selection.policyRevision !== policy.revision + ) + throw Error("unsupported selection policy declaration"); + if ( + body(resolution.order) !== body(policy.orders[selection.situation]) || + selection.lambda !== policy.lambda[selection.situation] + ) + throw Error("selection policy mismatch"); + const opinions = new Map( + set.assessments.map((a) => [ + a.moduleKind, + new Map(a.objectiveAssessments.map((o) => [o.optionKey, o])), + ]), + ); + for (const assessment of set.assessments) { + if ( + body(resolution.recommendations[assessment.moduleKind]) !== + body(assessment.recommendedOptionKeys) + ) + throw Error("selection assessment recommendations mismatch"); + if ( + [...ranked].some((key) => !opinions.get(assessment.moduleKind)?.has(key)) + ) + throw Error("selection missing ranked assessment"); + } + // Unavailable modules are dropped for the whole ranking, as in the policy. + const order = policy.orders[selection.situation].filter((m) => + [...ranked].every( + (key) => opinions.get(m)?.get(key)?.stance !== "unavailable", + ), + ); + if (order.length === 0) throw Error("selection unavailable ranking"); + const compare = (a: string, b: string): number => { + for (const module of order) { + const left = opinions.get(module)?.get(a); + const right = opinions.get(module)?.get(b); + if (!left || !right) throw Error("selection missing ranked assessment"); + const difference = + policy.stanceOrder.indexOf(left.stance) - + policy.stanceOrder.indexOf(right.stance); + if (difference !== 0) return difference; + } + return 0; + }; + let rank = 0; + let previous: string | undefined; + for (const item of resolution.ranking) { + const difference = + previous === undefined ? -1 : compare(previous, item.optionKey); + if (difference > 0) throw Error("selection ranking mismatch"); + if (difference < 0) rank += 1; + if (item.rank !== rank) throw Error("selection ranking mismatch"); + previous = item.optionKey; + } + const masses = rankMass( + resolution.ranking.map((r) => r.rank), + policy.ratio, + ); + const baseline = new Map( + resolution.ranking.map((r, i) => [r.optionKey, masses[i]]), + ); + if ( + selection.candidates.some((c) => c.p0 !== (baseline.get(c.optionKey) ?? 0)) + ) + throw Error("selection baseline mismatch"); +} + /** The Host owns the single writer; this ledger is independent of session lifetimes. */ export class JudgmentStore { private readonly db: DatabaseSync; private readonly now: () => number; private closed = false; + private validatedDataVersion: number | undefined; constructor(path: string, options: { now?: () => number } = {}) { const opened = openCheckedDatabase(path); @@ -102,21 +218,22 @@ export class JudgmentStore { objectiveId: string, profileRevision: number, ): ObjectiveProfile | null { - this.assertOpen(); - const row = this.db - .prepare( - "SELECT body, digest FROM objective_profiles WHERE objective_id = ? AND revision = ?", - ) - .get( - boundedId(objectiveId, "objective id"), - revision(profileRevision, 1), - ); - if (!row) return null; - const { body: json, digest } = row; - const parsed = parseObjectiveProfile(JSON.parse(String(json))); - if (judgmentDigest(parsed) !== digest) - throw Error("objective profile digest mismatch"); - return parsed; + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, digest FROM objective_profiles WHERE objective_id = ? AND revision = ?", + ) + .get( + boundedId(objectiveId, "objective id"), + revision(profileRevision, 1), + ); + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseObjectiveProfile(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("objective profile digest mismatch"); + return parsed; + }, false); } activateObjectiveProfile( @@ -172,28 +289,29 @@ export class JudgmentStore { agentId: string, scopeId: string, ): Record | null { - this.assertOpen(); - const rows = this.db - .prepare(`SELECT p.body, p.digest FROM objective_profile_active a JOIN objective_profiles p - ON p.objective_id = a.objective_id AND p.revision = a.revision WHERE a.agent_id = ? AND a.scope_id = ?`) - .all(boundedId(agentId, "agent id"), boundedId(scopeId, "scope id")); - const refs: Partial> = {}; - for (const { body: json, digest } of rows) { - const profile = parseObjectiveProfile(JSON.parse(String(json))); - if (judgmentDigest(profile) !== digest) - throw Error("objective profile digest mismatch"); - refs[profile.moduleKind] = parseObjectiveProfileRef({ - objectiveId: profile.objectiveId, - revision: profile.revision, - digest: judgmentDigest(profile), - }); - } - if (!refs.clotho || !refs.lachesis || !refs.atropos) return null; - return { - clotho: refs.clotho, - lachesis: refs.lachesis, - atropos: refs.atropos, - }; + return this.transaction(() => { + const rows = this.db + .prepare(`SELECT p.body, p.digest FROM objective_profile_active a JOIN objective_profiles p + ON p.objective_id = a.objective_id AND p.revision = a.revision WHERE a.agent_id = ? AND a.scope_id = ?`) + .all(boundedId(agentId, "agent id"), boundedId(scopeId, "scope id")); + const refs: Partial> = {}; + for (const { body: json, digest } of rows) { + const profile = parseObjectiveProfile(JSON.parse(String(json))); + if (judgmentDigest(profile) !== digest) + throw Error("objective profile digest mismatch"); + refs[profile.moduleKind] = parseObjectiveProfileRef({ + objectiveId: profile.objectiveId, + revision: profile.revision, + digest: judgmentDigest(profile), + }); + } + if (!refs.clotho || !refs.lachesis || !refs.atropos) return null; + return { + clotho: refs.clotho, + lachesis: refs.lachesis, + atropos: refs.atropos, + }; + }, false); } openRound(snapshot: JudgmentSnapshotRef): { @@ -234,22 +352,23 @@ export class JudgmentStore { status: RoundStatus; snapshotDigest: string; } | null { - this.assertOpen(); - const row = this.db - .prepare( - "SELECT snapshot, status, snapshot_digest FROM rounds WHERE round_id = ?", - ) - .get(boundedId(roundId, "round id")); - if (!row) return null; - const { snapshot, status, snapshot_digest: digest } = row; - const parsed = parseJudgmentSnapshotRef(JSON.parse(String(snapshot))); - if (snapshotDigest(parsed) !== digest) - throw Error("snapshot digest mismatch"); - return { - snapshot: parsed, - status: status as RoundStatus, - snapshotDigest: String(digest), - }; + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT snapshot, status, snapshot_digest FROM rounds WHERE round_id = ?", + ) + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { snapshot, status, snapshot_digest: digest } = row; + const parsed = parseJudgmentSnapshotRef(JSON.parse(String(snapshot))); + if (snapshotDigest(parsed) !== digest) + throw Error("snapshot digest mismatch"); + return { + snapshot: parsed, + status: status as RoundStatus, + snapshotDigest: String(digest), + }; + }, false); } putAssessment(assessment: Assessment): void { @@ -294,40 +413,41 @@ export class JudgmentStore { } assessmentSet(roundId: string): AssessmentSet { - this.assertOpen(); - const round = this.getRound(roundId); - const rows = this.db - .prepare( - "SELECT body, digest, input_digest, snapshot_digest FROM assessments WHERE round_id = ?", - ) - .all(boundedId(roundId, "round id")); - if (!round || rows.length !== MODULE_KINDS.length) - throw Error("incomplete assessment set"); - const assessments = rows.map( - ({ - body: json, - digest, - input_digest: inputDigest, - snapshot_digest: snapshot, - }) => { - const parsed = parseAssessment(JSON.parse(String(json))); - if (judgmentDigest(parsed) !== digest) - throw Error("assessment digest mismatch"); - if (parsed.inputDigest !== inputDigest) - throw Error("assessment input digest mismatch"); - if (parsed.snapshotDigest !== snapshot) - throw Error("assessment snapshot digest mismatch"); - return parsed; - }, - ); - return parseAssessmentSet({ - schemaVersion: 1, - roundId, - snapshotDigest: round.snapshotDigest, - assessments: MODULE_KINDS.map((module) => - assessments.find((item) => item.moduleKind === module), - ), - }); + return this.transaction(() => { + const round = this.getRound(roundId); + const rows = this.db + .prepare( + "SELECT body, digest, input_digest, snapshot_digest FROM assessments WHERE round_id = ?", + ) + .all(boundedId(roundId, "round id")); + if (!round || rows.length !== MODULE_KINDS.length) + throw Error("incomplete assessment set"); + const assessments = rows.map( + ({ + body: json, + digest, + input_digest: inputDigest, + snapshot_digest: snapshot, + }) => { + const parsed = parseAssessment(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("assessment digest mismatch"); + if (parsed.inputDigest !== inputDigest) + throw Error("assessment input digest mismatch"); + if (parsed.snapshotDigest !== snapshot) + throw Error("assessment snapshot digest mismatch"); + return parsed; + }, + ); + return parseAssessmentSet({ + schemaVersion: 1, + roundId, + snapshotDigest: round.snapshotDigest, + assessments: MODULE_KINDS.map((module) => + assessments.find((item) => item.moduleKind === module), + ), + }); + }, false); } recordResolution( @@ -357,21 +477,7 @@ export class JudgmentStore { throw Error("selection spec mismatch"); if (selection) { const set = this.assessmentSet(id); - if (selection.assessmentSetDigest !== judgmentDigest(set)) - throw Error("selection assessment set mismatch"); - if (selection.resolutionDigest !== judgmentDigest(parsed)) - throw Error("selection resolution digest mismatch"); - if ( - selection.policyId !== parsed.policyId || - selection.policyRevision !== parsed.policyRevision || - selection.situation !== parsed.situation - ) - throw Error("selection policy mismatch"); - if ( - canonicalJson(selection.objectiveProfileRefs) !== - canonicalJson(round.snapshot.objectiveProfileRefs) - ) - throw Error("selection objective refs mismatch"); + validateCandidateBinding(parsed, selection, set, round.snapshot); } const now = this.now(); this.db @@ -394,31 +500,35 @@ export class JudgmentStore { } getResolution(roundId: string): ResolutionRecord | null { - this.assertOpen(); - const row = this.db - .prepare("SELECT body, digest FROM resolution_records WHERE round_id = ?") - .get(boundedId(roundId, "round id")); - if (!row) return null; - const { body: json, digest } = row; - const parsed = parseResolutionRecord(JSON.parse(String(json))); - if (judgmentDigest(parsed) !== digest) - throw Error("resolution digest mismatch"); - return parsed; + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, digest FROM resolution_records WHERE round_id = ?", + ) + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseResolutionRecord(JSON.parse(String(json))); + if (judgmentDigest(parsed) !== digest) + throw Error("resolution digest mismatch"); + return parsed; + }, false); } getSelectionSpec(roundId: string): SelectionSpec | null { - this.assertOpen(); - const row = this.db - .prepare( - "SELECT body, spec_digest FROM selection_specs WHERE round_id = ?", - ) - .get(boundedId(roundId, "round id")); - if (!row) return null; - const { body: json, spec_digest: digest } = row; - const parsed = parseSelectionSpec(JSON.parse(String(json))); - if (parsed.specDigest !== digest) - throw Error("selection spec digest mismatch"); - return parsed; + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, spec_digest FROM selection_specs WHERE round_id = ?", + ) + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { body: json, spec_digest: digest } = row; + const parsed = parseSelectionSpec(JSON.parse(String(json))); + if (parsed.specDigest !== digest) + throw Error("selection spec digest mismatch"); + return parsed; + }, false); } putIntention(record: IntentionRecord): void { @@ -504,18 +614,19 @@ export class JudgmentStore { } getIntention(intentionId: string): IntentionRecord | null { - this.assertOpen(); - const row = this.db - .prepare( - "SELECT body, digest FROM intention_records WHERE intention_id = ?", - ) - .get(boundedId(intentionId, "intention id")); - if (!row) return null; - const { body: json, digest } = row; - const parsed = parseIntentionRecord(JSON.parse(String(json))); - if (intentionDigest(parsed) !== digest) - throw Error("intention digest mismatch"); - return parsed; + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT body, digest FROM intention_records WHERE intention_id = ?", + ) + .get(boundedId(intentionId, "intention id")); + if (!row) return null; + const { body: json, digest } = row; + const parsed = parseIntentionRecord(JSON.parse(String(json))); + if (intentionDigest(parsed) !== digest) + throw Error("intention digest mismatch"); + return parsed; + }, false); } listIntentions( @@ -523,29 +634,30 @@ export class JudgmentStore { scopeId: string, status?: IntentionStatus, ): IntentionRecord[] { - this.assertOpen(); - const agent = boundedId(agentId, "agent id"); - const scope = boundedId(scopeId, "scope id"); - if (status !== undefined && !INTENTION_STATUSES.includes(status)) - throw Error("invalid intention status"); - const rows = - status === undefined - ? this.db - .prepare( - "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id", - ) - .all(agent, scope) - : this.db - .prepare( - "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", - ) - .all(agent, scope, status); - return rows.map(({ body: json, digest }) => { - const parsed = parseIntentionRecord(JSON.parse(String(json))); - if (intentionDigest(parsed) !== digest) - throw Error("intention digest mismatch"); - return parsed; - }); + return this.transaction(() => { + const agent = boundedId(agentId, "agent id"); + const scope = boundedId(scopeId, "scope id"); + if (status !== undefined && !INTENTION_STATUSES.includes(status)) + throw Error("invalid intention status"); + const rows = + status === undefined + ? this.db + .prepare( + "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id", + ) + .all(agent, scope) + : this.db + .prepare( + "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", + ) + .all(agent, scope, status); + return rows.map(({ body: json, digest }) => { + const parsed = parseIntentionRecord(JSON.parse(String(json))); + if (intentionDigest(parsed) !== digest) + throw Error("intention digest mismatch"); + return parsed; + }); + }, false); } close(): void { @@ -566,11 +678,261 @@ export class JudgmentStore { return round; } - private transaction(fn: () => T): T { + /** Scan before any indexed filter, on the same SQLite snapshot as the call. + * Own writes preserve these invariants; only an external commit (or reopen) + * requires another scan. Never recurse through public getters here. + */ + private validateLedger(): void { + const rows = ( + table: string, + label: string, + parse: (value: unknown) => T, + metadata: (value: T) => Record, + digest: (value: T) => string = judgmentDigest, + jsonColumn = "body", + digestColumn = "digest", + ) => + this.db + .prepare(`SELECT * FROM ${table}`) + .all() + .map((row) => { + const value = parse(JSON.parse(String(row[jsonColumn]))); + if (digest(value) !== row[digestColumn]) + throw Error(`${label} digest mismatch`); + for (const [column, expected] of Object.entries(metadata(value))) { + if (row[column] !== expected) { + if (table === "assessments" && column === "input_digest") + throw Error("assessment input digest mismatch"); + if (table === "assessments" && column === "snapshot_digest") + throw Error("assessment snapshot digest mismatch"); + throw Error(`${label} metadata mismatch`); + } + } + return { row, value }; + }); + const profiles = rows( + "objective_profiles", + "objective profile", + parseObjectiveProfile, + (p) => ({ + objective_id: p.objectiveId, + revision: p.revision, + module_kind: p.moduleKind, + }), + ); + const profilesByRef = new Map( + profiles.map((entry) => [ + body([entry.value.objectiveId, entry.value.revision]), + entry, + ]), + ); + for (const row of this.db + .prepare("SELECT * FROM objective_profile_active") + .all()) { + const { + objective_id, + revision: profileRevision, + module_kind, + agent_id, + scope_id, + activation_revision, + } = row; + if ( + profilesByRef.get(body([objective_id, profileRevision]))?.value + .moduleKind !== module_kind + ) + throw Error("objective activation metadata mismatch"); + boundedId(agent_id, "agent id"); + boundedId(scope_id, "scope id"); + revision(Number(activation_revision), 1); + } + const rounds = rows( + "rounds", + "snapshot", + parseJudgmentSnapshotRef, + (p) => ({ + round_id: p.roundId, + agent_id: p.agentId, + scope_id: p.scopeId, + situation: p.situation, + sequence: p.sequence, + }), + snapshotDigest, + "snapshot", + "snapshot_digest", + ); + const assessments = rows( + "assessments", + "assessment", + parseAssessment, + (p) => ({ + round_id: p.snapshotId, + module_kind: p.moduleKind, + input_digest: p.inputDigest, + snapshot_digest: p.snapshotDigest, + }), + ); + const resolutions = rows( + "resolution_records", + "resolution", + parseResolutionRecord, + (p) => ({ round_id: p.roundId }), + ); + const statuses = new Map( + resolutions.map(({ value: p }) => [p.roundId, p.status]), + ); + for (const { + row: { status }, + value, + } of rounds) { + if (status !== (statuses.get(value.roundId) ?? "open")) + throw Error("round status metadata mismatch"); + for (const module of MODULE_KINDS) { + const ref = value.objectiveProfileRefs[module]; + const profile = profilesByRef.get( + body([ref.objectiveId, ref.revision]), + ); + if ( + !profile || + profile.value.moduleKind !== module || + profile.row["digest"] !== ref.digest + ) + throw Error("snapshot objective profile mismatch"); + } + } + const selections = rows( + "selection_specs", + "selection spec", + parseSelectionSpec, + (p) => ({ round_id: p.roundId }), + (p) => p.specDigest, + "body", + "spec_digest", + ); + const selectedRounds = new Set( + selections.map(({ value }) => value.roundId), + ); + for (const { value } of resolutions) { + if ((value.status === "resolved") !== selectedRounds.has(value.roundId)) + throw Error("selection spec metadata mismatch"); + } + const snapshots = new Map( + rounds.map(({ value }) => [value.roundId, value]), + ); + const records = new Map( + resolutions.map(({ value }) => [value.roundId, value]), + ); + for (const { value } of resolutions) { + const snapshot = snapshots.get(value.roundId); + if ( + !snapshot || + value.policyId !== snapshot.policyId || + value.policyRevision !== snapshot.policyRevision || + value.situation !== snapshot.situation + ) + throw Error("resolution snapshot mismatch"); + } + const sets = new Map(); + for (const { value } of assessments) { + const snapshot = snapshots.get(value.snapshotId); + if (!snapshot) throw Error("judgment foreign key mismatch"); + if (value.snapshotDigest !== snapshotDigest(snapshot)) + throw Error("assessment snapshot mismatch"); + if ( + body(value.objectiveRef) !== + body(snapshot.objectiveProfileRefs[value.moduleKind]) + ) + throw Error("assessment objective mismatch"); + const set = sets.get(value.snapshotId) ?? []; + set.push(value); + sets.set(value.snapshotId, set); + } + for (const { value: selection } of selections) { + const snapshot = snapshots.get(selection.roundId); + const record = records.get(selection.roundId); + if ( + !snapshot || + !record || + record.status !== "resolved" || + selection.snapshotDigest !== snapshotDigest(snapshot) + ) + throw Error("selection spec metadata mismatch"); + const set = parseAssessmentSet({ + schemaVersion: 1, + roundId: selection.roundId, + snapshotDigest: selection.snapshotDigest, + assessments: MODULE_KINDS.map((module) => + sets.get(selection.roundId)?.find((a) => a.moduleKind === module), + ), + }); + validateCandidateBinding(record, selection, set, snapshot); + } + const intentions = rows( + "intention_records", + "intention", + parseIntentionRecord, + (p) => ({ + intention_id: p.intentionId, + agent_id: p.agentId, + scope_id: p.scopeId, + revision: p.revision, + status: p.status, + }), + intentionDigest, + ); + const histories = new Map( + intentions.map(({ value: p }) => [p.intentionId, p.history]), + ); + const transitions = this.db + .prepare("SELECT * FROM intention_transitions") + .all(); + if ( + transitions.length !== + intentions.reduce((n, { value }) => n + value.history.length, 0) + ) + throw Error("intention transition metadata mismatch"); + for (const row of transitions) { + const { + intention_id, + revision: transitionRevision, + from_status, + to_status, + reason, + evidence_ref, + at, + } = row; + const history = histories.get(String(intention_id)); + const entry = history?.[Number(transitionRevision) - 1]; + if ( + !entry || + entry.from !== from_status || + entry.to !== to_status || + entry.reason !== reason || + entry.evidenceRef !== evidence_ref || + entry.at !== at + ) + throw Error("intention transition metadata mismatch"); + } + if (this.db.prepare("PRAGMA foreign_key_check").all().length > 0) + throw Error("judgment foreign key mismatch"); + } + + private transaction(fn: () => T, write = true): T { this.assertOpen(); if (this.db.isTransaction) return fn(); - this.db.exec("BEGIN IMMEDIATE"); + this.db.exec(write ? "BEGIN IMMEDIATE" : "BEGIN"); try { + // Establish the read snapshot before observing data_version. Otherwise an + // external commit could be scanned under one version and cached as another. + this.db + .prepare("SELECT value FROM judgment_meta WHERE key = 'store'") + .get(); + const { data_version: dataVersion } = + this.db.prepare("PRAGMA data_version").get() ?? {}; + if (dataVersion !== this.validatedDataVersion) { + this.validateLedger(); + this.validatedDataVersion = Number(dataVersion); + } const result = fn(); this.db.exec("COMMIT"); return result; diff --git a/packages/lina-core/test/judgment-store-digest-review.test.ts b/packages/lina-core/test/judgment-store-digest-review.test.ts index 924d29f..47a223e 100644 --- a/packages/lina-core/test/judgment-store-digest-review.test.ts +++ b/packages/lina-core/test/judgment-store-digest-review.test.ts @@ -299,7 +299,14 @@ for (const tamper of ["body", "digest"] as const) { ) .all(), ).toEqual(before); - expect(store.getRound("round-2")).toBeNull(); + expect(() => store.getRound("round-2")).toThrow( + "objective profile digest mismatch", + ); + expect( + db + .prepare("SELECT round_id FROM rounds WHERE round_id = 'round-2'") + .get(), + ).toBeUndefined(); }); } for (const action of ["get", "list", "filtered", "transition"] as const) { @@ -416,9 +423,19 @@ for (const tamper of [ ? "assessment snapshot digest mismatch" : "assessment digest mismatch", ); - expect(store.getRound(ref.roundId)?.status).toBe("open"); - expect(store.getResolution(ref.roundId)).toBeNull(); - expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + // A corrupt ledger fails closed even for queries that would return no rows. + expect(() => store.getResolution(ref.roundId)).toThrow( + /assessment .*mismatch/, + ); + expect(db.prepare("SELECT status FROM rounds").get()).toEqual({ + status: "open", + }); + expect( + db.prepare("SELECT count(*) AS n FROM resolution_records").get(), + ).toEqual({ n: 0 }); + expect( + db.prepare("SELECT count(*) AS n FROM selection_specs").get(), + ).toEqual({ n: 0 }); }); } } @@ -452,16 +469,12 @@ test("3995355426: parser-normalized digests and valid lifecycle survive reopen", judgmentDigest(originalAssessment), JSON.stringify({ ...originalAssessment, evidenceRefs: ["b", "a"] }), ); - const originalResolution = parseResolutionRecord({ - ...record, - recommendations: { ...record.recommendations, clotho: ["a", "b"] }, - }); + const originalResolution = record; db.prepare("UPDATE resolution_records SET digest = ?, body = ?").run( judgmentDigest(originalResolution), - JSON.stringify({ - ...originalResolution, - recommendations: { ...record.recommendations, clotho: ["b", "a"] }, - }), + JSON.stringify( + Object.fromEntries(Object.entries(originalResolution).reverse()), + ), ); replaceBody( "intention_records", @@ -469,9 +482,25 @@ test("3995355426: parser-normalized digests and valid lifecycle survive reopen", "intention_id = 'intention-2'", ); // Selection has no sortable set-like list; JSON key order/whitespace are not digest input. - replaceBody( - "selection_specs", - Object.fromEntries(Object.entries(selection).reverse()), + // Keep the downstream anchor consistent with the changed assessment evidence. + const normalizedSelection = rehashSpec({ + ...selection, + assessmentSetDigest: judgmentDigest( + parseAssessmentSet({ + schemaVersion: 1, + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + assessments: MODULE_KINDS.map((module) => + module === "clotho" ? originalAssessment : assessment(ref, module), + ), + }), + ), + }); + db.prepare("UPDATE selection_specs SET body = ?, spec_digest = ?").run( + JSON.stringify( + Object.fromEntries(Object.entries(normalizedSelection).reverse()), + ), + normalizedSelection.specDigest, ); reopen(); expect(store.getObjectiveProfile("objective-clotho", 1)).toEqual(profile()); @@ -482,7 +511,7 @@ test("3995355426: parser-normalized digests and valid lifecycle survive reopen", originalAssessment, ); expect(store.getResolution(ref.roundId)).toEqual(originalResolution); - expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); + expect(store.getSelectionSpec(ref.roundId)).toEqual(normalizedSelection); expect(store.getIntention(initial.intentionId)).toEqual(initial); expect(store.listIntentions(ref.agentId, ref.scopeId, "proposed")).toEqual([ intention(), diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index e5d73d6..18a33c3 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { mkdirSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -436,7 +436,12 @@ for (const tamper of ["snapshot", "snapshot_digest"] as const) { expect(db.prepare("SELECT count(*) AS n FROM assessments").get()).toEqual({ n: 0, }); - expect(store.getResolution(ref.roundId)).toBeNull(); + expect(() => store.getResolution(ref.roundId)).toThrow( + "snapshot digest mismatch", + ); + expect( + db.prepare("SELECT count(*) AS n FROM resolution_records").get(), + ).toEqual({ n: 0 }); }); } @@ -902,6 +907,403 @@ test("every public method rejects use after close", () => { expect(action).toThrow(/closed/); }); +for (const reopen of [false, true]) { + for (const [table, column, value] of [ + ["intention_records", "intention_id", "hidden"], + ["intention_records", "agent_id", "hidden"], + ["intention_records", "scope_id", "hidden"], + ["intention_records", "status", "completed"], + ["intention_records", "revision", 99], + ["objective_profiles", "objective_id", "hidden"], + ["objective_profiles", "revision", 99], + ["objective_profiles", "module_kind", "lachesis"], + ["objective_profile_active", "module_kind", "hidden"], + ["rounds", "round_id", "hidden"], + ["rounds", "agent_id", "hidden"], + ["rounds", "scope_id", "hidden"], + ["rounds", "situation", "autonomous"], + ["rounds", "sequence", 99], + ["rounds", "status", "open"], + ["assessments", "round_id", "hidden"], + ["assessments", "module_kind", "hidden"], + ["resolution_records", "round_id", "hidden"], + ["selection_specs", "round_id", "hidden"], + ] as const) { + test(`3995956579/3995958859: ${table}.${column} cannot hide a row (reopen=${reopen})`, () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + store.putIntention(intention()); + expect( + store.listIntentions("agent-1", "scope-1", "proposed"), + ).toHaveLength(1); + const db = database(); + db.exec("PRAGMA foreign_keys = OFF"); + db.prepare( + `UPDATE ${table} SET ${column} = ? WHERE rowid = (SELECT min(rowid) FROM ${table})`, + ).run(value); + if (reopen) { + close(store); + store = open(); + } + expect(() => + store.listIntentions("agent-1", "scope-1", "proposed"), + ).toThrow(/metadata mismatch/); + expect(() => store.putIntention(intention("new"))).toThrow( + /metadata mismatch/, + ); + expect( + db.prepare("SELECT count(*) AS n FROM intention_records").get(), + ).toEqual({ n: 1 }); + }); + } + for (const sql of [ + "UPDATE intention_transitions SET intention_id = 'hidden'", + "UPDATE intention_transitions SET revision = 99", + "UPDATE intention_transitions SET from_status = 'active'", + "UPDATE intention_transitions SET to_status = 'cancelled'", + "UPDATE intention_transitions SET reason = 'changed'", + "UPDATE intention_transitions SET evidence_ref = NULL", + "UPDATE intention_transitions SET at = '2026-09-12T00:00:00.000Z'", + "DELETE FROM intention_transitions", + "INSERT INTO intention_transitions SELECT intention_id, 2, from_status, to_status, reason, evidence_ref, at FROM intention_transitions", + ]) { + test(`3995958859: transition ledger agrees with history (reopen=${reopen}): ${sql}`, () => { + let store = open(); + store.putIntention(intention()); + store.transitionIntention("intention-1", transition("adopted"), 0); + database().exec(`PRAGMA foreign_keys = OFF; ${sql}`); + if (reopen) { + close(store); + store = open(); + } + expect(() => store.getIntention("intention-1")).toThrow( + "intention transition metadata mismatch", + ); + expect(() => + store.transitionIntention("intention-1", transition("active"), 1), + ).toThrow("intention transition metadata mismatch"); + }); + } +} + +function rehashSelection(selection: SelectionSpec): SelectionSpec { + return parseSelectionSpec({ + ...selection, + eligibleDigest: judgmentDigest( + selection.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + specDigest: judgmentDigest({ + ...selection, + specDigest: undefined, + eligibleDigest: judgmentDigest( + selection.candidates.filter((c) => c.p0 > 0).map((c) => c.optionKey), + ), + }), + }); +} + +test("3995958855: valid hashes cannot substitute a candidate", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + const record = resolution(ref); + const original = spec(ref); + for (const candidates of [ + [{ optionKey: "forged", p0: 1, b: null }], + [ + { optionKey: "a", p0: 1, b: null }, + { optionKey: "forged", p0: 0, b: null }, + ], + ]) { + const forged = rehashSelection({ ...original, candidates }); + expect(() => store.recordResolution(ref.roundId, record, forged)).toThrow( + "selection candidate universe mismatch", + ); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + } + const forgedRecord = parseResolutionRecord({ + ...record, + ranking: [{ optionKey: "forged", rank: 1 }], + }); + const forged = rehashSelection({ + ...original, + resolutionDigest: judgmentDigest(forgedRecord), + candidates: [{ optionKey: "forged", p0: 1, b: null }], + }); + expect(() => + store.recordResolution(ref.roundId, forgedRecord, forged), + ).toThrow("selection candidate universe mismatch"); + store.recordResolution(ref.roundId, record, original); +}); + +test("3995958855: baseline uses declared ratio and retains unassessed host exclusions", () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const m of MODULE_KINDS) { + const original = assessment(ref, m); + store.putAssessment( + parseAssessment({ + ...original, + objectiveAssessments: [ + ...original.objectiveAssessments, + { + ...original.objectiveAssessments[0], + optionKey: "b", + stance: "accept", + }, + ], + }), + ); + } + const record = parseResolutionRecord({ + ...resolution(ref), + ranking: [ + { optionKey: "a", rank: 1 }, + { optionKey: "b", rank: 2 }, + ], + excluded: [ + { + optionKey: "excluded", + stage: "host_eligibility", + byModule: null, + reason: "not eligible", + }, + ], + }); + const candidates = [ + { optionKey: "a", p0: 2 / 3, b: null }, + { optionKey: "b", p0: 1 / 3, b: null }, + { optionKey: "excluded", p0: 0, b: null }, + ]; + const selection = rehashSelection({ + ...spec(ref, record), + assessmentSetDigest: judgmentDigest(store.assessmentSet(ref.roundId)), + candidates, + }); + for (const altered of [ + { + candidates: candidates.map((c) => ({ + ...c, + p0: c.optionKey === "excluded" ? 0 : 0.5, + })), + }, + { + candidates: candidates.map((c) => ({ + ...c, + p0: c.optionKey === "excluded" ? 0.1 : 0.45, + })), + }, + { candidates: candidates.filter((c) => c.optionKey !== "excluded") }, + { lambda: 1 }, + ]) { + const invalid = rehashSelection({ ...selection, ...altered }); + expect(() => store.recordResolution(ref.roundId, record, invalid)).toThrow( + /selection (baseline|candidate universe|policy) mismatch/, + ); + expect(store.getResolution(ref.roundId)).toBeNull(); + } + store.recordResolution(ref.roundId, record, selection); + close(store); + store = open(); + expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); + expect(store.assessmentSet(ref.roundId).assessments).toHaveLength(3); +}); + +test("3995956579: owner writes reuse validation; reopen and external commits invalidate it", () => { + let store = open(); + const prepare = spyOn(DatabaseSync.prototype, "prepare"); + const scans = () => + prepare.mock.calls.filter( + ([sql]) => sql === "SELECT * FROM intention_records", + ).length; + try { + for (let i = 0; i < 12; i += 1) { + const id = `intention-${i}`; + store.putIntention(intention(id)); + store.transitionIntention(id, transition("adopted"), 0); + expect(store.getIntention(id)?.status).toBe("adopted"); + } + expect(scans()).toBe(1); + close(store); + store = open(); + expect(store.listIntentions("agent-1", "scope-1", "adopted")).toHaveLength( + 12, + ); + expect(scans()).toBe(2); + database().exec("UPDATE intention_records SET created_at = created_at + 1"); + expect(store.listIntentions("agent-1", "scope-1", "adopted")).toHaveLength( + 12, + ); + expect(scans()).toBe(3); + expect(store.getIntention("intention-0")?.revision).toBe(1); + expect(scans()).toBe(3); + } finally { + prepare.mockRestore(); + } +}); + +test("3995956579: validation and filtered read share one SQLite snapshot", () => { + const store = open(); + store.putIntention(intention()); + const db = database(); + const original = DatabaseSync.prototype.prepare; + let changed = false; + const prepare = spyOn(DatabaseSync.prototype, "prepare").mockImplementation( + function (this: DatabaseSync, sql: string) { + if (!changed && sql.includes("FROM intention_records WHERE agent_id")) { + changed = true; + db.exec("UPDATE intention_records SET status = 'completed'"); + } + return original.call(this, sql); + }, + ); + try { + expect(store.listIntentions("agent-1", "scope-1", "proposed")).toEqual([ + intention(), + ]); + expect(changed).toBe(true); + expect(() => + store.listIntentions("agent-1", "scope-1", "proposed"), + ).toThrow("intention metadata mismatch"); + } finally { + prepare.mockRestore(); + } +}); + +test("3995958855: rehashed ranking must reflect persisted stances", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const module of MODULE_KINDS) { + const original = assessment(ref, module); + store.putAssessment( + parseAssessment({ + ...original, + objectiveAssessments: [ + ...original.objectiveAssessments, + { + ...original.objectiveAssessments[0], + optionKey: "b", + stance: "accept", + }, + ], + }), + ); + } + for (const ranking of [ + [ + { optionKey: "b", rank: 1 }, + { optionKey: "a", rank: 2 }, + ], + [ + { optionKey: "a", rank: 1 }, + { optionKey: "b", rank: 1 }, + ], + [ + { optionKey: "a", rank: 2 }, + { optionKey: "b", rank: 3 }, + ], + ]) { + const record = parseResolutionRecord({ ...resolution(ref), ranking }); + const selection = rehashSelection({ + ...spec(ref, record), + assessmentSetDigest: judgmentDigest(store.assessmentSet(ref.roundId)), + candidates: [ + { optionKey: "a", p0: 0.5, b: null }, + { optionKey: "b", p0: 0.5, b: null }, + ], + }); + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow("selection ranking mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + } +}); + +test("3995958855: missing ranked assessments reject without inventing excluded coverage", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + const record = parseResolutionRecord({ + ...resolution(ref), + ranking: [ + { optionKey: "a", rank: 1 }, + { optionKey: "b", rank: 2 }, + ], + }); + const selection = rehashSelection({ + ...spec(ref, record), + candidates: [ + { optionKey: "a", p0: 2 / 3, b: null }, + { optionKey: "b", p0: 1 / 3, b: null }, + ], + }); + expect(() => store.recordResolution(ref.roundId, record, selection)).toThrow( + "selection missing ranked assessment", + ); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); +}); + +for (const reopen of [false, true]) { + test(`3995958855: persisted rehashed candidate remains bound to assessments (reopen=${reopen})`, () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + const record = resolution(ref); + const selection = spec(ref); + store.recordResolution(ref.roundId, record, selection); + const forged = rehashSelection({ + ...selection, + candidates: [{ optionKey: "forged", p0: 1, b: null }], + }); + database() + .prepare("UPDATE selection_specs SET body = ?, spec_digest = ?") + .run(JSON.stringify(forged), forged.specDigest); + if (reopen) { + close(store); + store = open(); + } + expect(() => store.getSelectionSpec(ref.roundId)).toThrow( + "selection candidate universe mismatch", + ); + }); +} + +test("3995958855: no baseline is guessed for undeclared policy revisions", () => { + const store = open(); + const ref = { ...snapshot(store), policyRevision: 2 }; + store.openRound(ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + const record = parseResolutionRecord({ + ...resolution(ref), + policyRevision: 2, + }); + const selection = rehashSelection({ + ...spec(ref, record), + policyRevision: 2, + }); + expect(() => store.recordResolution(ref.roundId, record, selection)).toThrow( + "unsupported selection policy declaration", + ); + store.recordResolution( + ref.roundId, + { ...record, status: "held", holdReason: "no policy declaration" }, + null, + ); + expect(store.getRound(ref.roundId)?.status).toBe("held"); +}); + test("missing parent is private; directory and database symlinks are rejected", () => { const parent = join(fixture.dir, "private"); open(join(parent, "judgment.sqlite")); @@ -916,3 +1318,101 @@ test("missing parent is private; directory and database symlinks are rejected", symlinkSync(file, path); expect(() => open()).toThrow(/unsafe regular file/); }); + +test("ledger audit rejects a resolved round whose selection was deleted", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + for (const module of MODULE_KINDS) + store.putAssessment(assessment(ref, module)); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + database().exec("DELETE FROM selection_specs"); + expect(() => store.getSelectionSpec(ref.roundId)).toThrow( + "selection spec metadata mismatch", + ); +}); + +test("ledger audit rejects orphaned assessments before unrelated reads", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + store.putAssessment(assessment(ref, "clotho")); + const db = database(); + db.exec("PRAGMA foreign_keys = OFF; DELETE FROM rounds"); + expect(() => store.getIntention("missing")).toThrow( + "judgment foreign key mismatch", + ); +}); + +for (const change of [ + { policyId: "other-policy" }, + { policyRevision: 2 }, + { situation: "autonomous" as const }, +]) { + test(`ledger audit binds rehashed resolution ${Object.keys(change)[0]} to its snapshot`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + store.recordResolution(ref.roundId, resolution(ref, "held"), null); + const changed = { ...resolution(ref, "held"), ...change }; + database() + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), judgmentDigest(changed)); + expect(() => store.getResolution(ref.roundId)).toThrow( + "resolution snapshot mismatch", + ); + }); +} + +test("ledger audit retains the historical objective referenced by a round", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + const next = store.putObjectiveProfile(profile("clotho", 2)); + store.activateObjectiveProfile(ref.agentId, ref.scopeId, next); + database().exec( + "DELETE FROM objective_profiles WHERE module_kind = 'clotho' AND revision = 1", + ); + expect(() => store.getRound(ref.roundId)).toThrow( + "snapshot objective profile mismatch", + ); +}); + +test("ledger audit binds rehashed assessment evidence to its frozen snapshot", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + store.putAssessment(assessment(ref, "clotho")); + const changed = assessment(ref, "clotho", "b".repeat(64)); + database() + .prepare( + "UPDATE assessments SET body = ?, digest = ?, snapshot_digest = ?, input_digest = ?", + ) + .run( + JSON.stringify(changed), + judgmentDigest(changed), + changed.snapshotDigest, + changed.inputDigest, + ); + expect(() => store.getRound(ref.roundId)).toThrow( + "assessment snapshot mismatch", + ); +}); + +test("ledger audit binds rehashed assessment objectives to the frozen module", () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + store.putAssessment(assessment(ref, "clotho")); + const changed = { + ...assessment(ref, "clotho"), + objectiveRef: ref.objectiveProfileRefs.atropos, + }; + changed.inputDigest = assessmentInputDigest(changed); + database() + .prepare("UPDATE assessments SET body = ?, digest = ?, input_digest = ?") + .run(JSON.stringify(changed), judgmentDigest(changed), changed.inputDigest); + expect(() => store.getRound(ref.roundId)).toThrow( + "assessment objective mismatch", + ); +}); From d17c33eb42de313757af560c914a6072520ea3db Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:22:48 +0900 Subject: [PATCH 32/47] fix(core): validate previous context projections before reuse Reject malformed prior revisions and instruction references before computing a new projection, preserving valid maximum revisions and normal revision tracking. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/context/read-projection.ts | 4 +++- .../test/context-read-projection.test.ts | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/lina-core/src/context/read-projection.ts b/packages/lina-core/src/context/read-projection.ts index cb5dbc9..d839471 100644 --- a/packages/lina-core/src/context/read-projection.ts +++ b/packages/lina-core/src/context/read-projection.ts @@ -216,7 +216,9 @@ export function buildContextReadProjection(input: { previous: ContextReadProjection | null; projectedAt: string; }): ContextReadProjection { - const { working, instruction, previous, projectedAt } = input; + const { working, instruction, projectedAt } = input; + const previous = + input.previous === null ? null : parseContextReadProjection(input.previous); validateInput(working, projectedAt, instruction); let instructionRef: InstructionRef | null = null; diff --git a/packages/lina-core/test/context-read-projection.test.ts b/packages/lina-core/test/context-read-projection.test.ts index 65ceaae..a4e1b60 100644 --- a/packages/lina-core/test/context-read-projection.test.ts +++ b/packages/lina-core/test/context-read-projection.test.ts @@ -127,6 +127,30 @@ describe("ContextReadProjection", () => { ).toThrow(/instructionRevision/); }); + it.each([-1, 0])( + "rejects malformed previous instruction revision %s before comparing instructions", + (instructionRevision) => { + const input = { + working: store.working(), + instruction: makeInstruction("previous-entry", "same"), + previous: null, + projectedAt: "2026-09-12T00:00:00.000Z", + }; + const previous = { + ...buildContextReadProjection(input), + instructionRevision, + }; + for (const text of ["same", "changed"]) + expect(() => + buildContextReadProjection({ + ...input, + instruction: { ...input.instruction, text }, + previous, + }), + ).toThrow(/instructionRevision/); + }, + ); + it("increments instructionRevision when text changes for the same entry", () => { const instruction1 = makeInstruction("i1", "hello"); const projectedAt = "2026-09-10T00:00:00.000Z"; From 21cd1aa92612b39d9b3f3f542891bf17311e1f3b Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:24:04 +0900 Subject: [PATCH 33/47] fix(core): identify protected commitments in judgment evidence Add optional attributed breach IDs with strict scope to commitment opposition; absent attribution remains unproven. Reject self-referential intention relations while preserving independent targets. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../src/agents/judgment-validation.ts | 27 ++++++++++-- packages/lina-core/src/agents/judgment.ts | 2 + .../lina-core/test/judgment-contract.test.ts | 44 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 781e705..66a1ecf 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -346,6 +346,10 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { } export function parseOptionAssessment(value: unknown): OptionAssessment { + const hasBreaches = + typeof value === "object" && + value !== null && + Object.hasOwn(value, "breachedIntentionIds"); const row = fields( value, [ @@ -357,6 +361,7 @@ export function parseOptionAssessment(value: unknown): OptionAssessment { "loss", "uncertainty", "evidenceRefs", + ...(hasBreaches ? ["breachedIntentionIds"] : []), ], "option assessment", ); @@ -373,7 +378,7 @@ export function parseOptionAssessment(value: unknown): OptionAssessment { throw Error("severity requires oppose stance"); if ((stance === "unavailable") !== (unavailableReason !== null)) throw Error("unavailable stance requires reason"); - return { + const result: OptionAssessment = { optionKey: boundedId(row["optionKey"], "option key"), stance, severity, @@ -383,6 +388,15 @@ export function parseOptionAssessment(value: unknown): OptionAssessment { uncertainty: boundedText(row["uncertainty"], "uncertainty"), evidenceRefs: strings(row["evidenceRefs"], "evidence refs"), }; + if (hasBreaches) { + if (severity !== "commitment_breach") + throw Error("breach attribution requires commitment opposition"); + result.breachedIntentionIds = strings( + row["breachedIntentionIds"], + "breached intention ids", + ); + } + return result; } export function parseAssessment(value: unknown): Assessment { @@ -828,6 +842,7 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { "intention record", true, ); + const intentionId = boundedId(row["intentionId"], "intention id"); const acceptance = fields( row["acceptance"], ["sourceRef", "acceptedBy", "policyRevision", "acceptedAt"], @@ -865,7 +880,7 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { if (previous !== status) throw Error("intention history status mismatch"); return { schemaVersion: 1, - intentionId: boundedId(row["intentionId"], "intention id"), + intentionId, agentId: boundedId(row["agentId"], "agent id"), scopeId: boundedId(row["scopeId"], "scope id"), revision: recordRevision, @@ -904,8 +919,14 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { ["intentionId", "relation"], "related intention", ); + const relatedId = boundedId( + item["intentionId"], + "related intention id", + ); + if (relatedId === intentionId) + throw Error("self-referential intention relation"); return { - intentionId: boundedId(item["intentionId"], "related intention id"), + intentionId: relatedId, relation: enumeration( item["relation"], INTENTION_RELATIONS, diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts index e7561f5..9a06646 100644 --- a/packages/lina-core/src/agents/judgment.ts +++ b/packages/lina-core/src/agents/judgment.ts @@ -91,6 +91,8 @@ export type OptionAssessment = { loss: string; uncertainty: string; evidenceRefs: string[]; + /** Absent or empty means an unattributed breach and cannot justify a waiver. */ + breachedIntentionIds?: string[]; }; export type JsonValue = | null diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 395a5a1..77357e2 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -560,6 +560,34 @@ test("option stance requires exactly the corresponding severity or unavailable r expect(() => parseOptionAssessment({ ...option, ...change })).toThrow(); }); +test("commitment opposition can identify the protected intentions explicitly", () => { + const attributed = { + ...option, + stance: "oppose", + severity: "commitment_breach", + breachedIntentionIds: ["intention-a", "intention-b"], + }; + expect(JSON.stringify(parseOptionAssessment(attributed))).toBe( + JSON.stringify(attributed), + ); + for (const breachedIntentionIds of [ + [""], + ["a", "a"], + ["x".repeat(161)], + "not-an-array", + ]) + expect(() => + parseOptionAssessment({ ...attributed, breachedIntentionIds }), + ).toThrow(); + expect(() => + parseOptionAssessment({ + ...attributed, + stance: "prefer", + severity: null, + }), + ).toThrow(); +}); + test("assessment sets require one of each module in declared order and matching snapshot", () => { const set = assessmentSet(); expect(parseAssessmentSet(set).assessments.map((a) => a.moduleKind)).toEqual([ @@ -665,6 +693,22 @@ test("intention acceptance requires user authority only for user commitments", ( } }); +for (const relation of ["conflicts", "supersedes", "depends"] as const) { + test(`intention rejects self-${relation} while retaining an independent target`, () => { + const independent = { + ...intention, + relatedIntentions: [{ intentionId: "intention-2", relation }], + }; + expect(parseIntentionRecord(independent)).toEqual(independent); + expect(() => + parseIntentionRecord({ + ...intention, + relatedIntentions: [{ intentionId: intention.intentionId, relation }], + }), + ).toThrow("self-referential intention relation"); + }); +} + function adopt(record = intention): IntentionRecord { return transitionIntention(record, { to: "adopted", From 9c565b33d5fd4e6db434ead36436141f38a603eb Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:25:19 +0900 Subject: [PATCH 34/47] fix(core): ground commitment exemptions and judgment inputs Only a breach attributed to the exact changed intention can waive protection. Unattributed or unrelated breaches retain the veto. Parse incoming snapshots and exclude unavailable stances from conflict diagnostics. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-policy.ts | 14 +- .../test/judgment-policy-review.test.ts | 195 ++++++++++++++++++ .../lina-core/test/judgment-policy.test.ts | 13 +- 3 files changed, 214 insertions(+), 8 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index b704a70..c52e87e 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -18,6 +18,7 @@ import { import { judgmentDigest, parseAssessmentSet, + parseJudgmentSnapshotRef, parseResolutionRecord, parseSelectionSpec, snapshotDigest, @@ -82,7 +83,8 @@ export function resolvePersonalRound(input: { eligibility: HostEligibility; bias: Record; }): { resolution: ResolutionRecord; spec: SelectionSpec | null } { - const { policy, snapshot } = input; + const { policy } = input; + const snapshot = parseJudgmentSnapshotRef(input.snapshot); const options = input.options .map(parseCanonicalOption) .sort((a, b) => @@ -194,7 +196,10 @@ export function resolvePersonalRound(input: { const { clotho, lachesis, atropos } = opinions; if (clotho && lachesis && atropos) { const complete = { clotho, lachesis, atropos }; - if (new Set(MODULE_KINDS.map((m) => complete[m].stance)).size > 1) + const availableStances = MODULE_KINDS.map( + (m) => complete[m].stance, + ).filter((stance) => stance !== "unavailable"); + if (new Set(availableStances).size > 1) record.conflicts.push({ optionKey: option.optionKey, stances: { @@ -211,10 +216,13 @@ export function resolvePersonalRound(input: { // 3-4: Only the named module/severity can exclude at each stage. const remaining = eligible.filter(({ option, opinions }) => { const precondition = option.preconditions; + const breached = opinions.atropos.breachedIntentionIds; const changesAcceptance = (precondition.kind === "intention.suspend" || precondition.kind === "intention.cancel") && - precondition.acceptanceSourceRef.trim() !== ""; + precondition.acceptanceSourceRef.trim() !== "" && + breached?.length === 1 && + breached[0] === precondition.intentionId; if ( opinions.atropos.stance === "oppose" && opinions.atropos.severity === "commitment_breach" && diff --git a/packages/lina-core/test/judgment-policy-review.test.ts b/packages/lina-core/test/judgment-policy-review.test.ts index 4d5a7e2..9fd2a7f 100644 --- a/packages/lina-core/test/judgment-policy-review.test.ts +++ b/packages/lina-core/test/judgment-policy-review.test.ts @@ -6,6 +6,7 @@ import { MODULE_KINDS, PERSONAL_POLICY_V1, parseAssessmentSet, + parseJudgmentSnapshotRef, rankMass, resolvePersonalRound, sampleSelection, @@ -114,6 +115,83 @@ function fixture(): Parameters[0] { }; } +function commitmentChange(breachedIntentionIds?: string[]) { + const input = fixture(); + const actor = { + agentId: input.snapshot.agentId, + scopeId: input.snapshot.scopeId, + }; + const change = buildCanonicalOption({ + kind: "intention.cancel", + actor, + targetId: "changed-intention", + args: {}, + preconditions: { + kind: "intention.cancel", + intentionId: "changed-intention", + acceptanceSourceRef: "accepted-request", + authorityRef: "host-authority", + userConfirmationRef: null, + }, + }); + input.options = [change]; + input.eligibility = [ + { optionKey: change.optionKey, eligible: true, reason: null }, + ]; + input.set = parseAssessmentSet({ + ...input.set, + assessments: input.set.assessments.map((assessment) => ({ + ...assessment, + proposedOptionKeys: [], + recommendedOptionKeys: [], + objectiveAssessments: [ + { + optionKey: change.optionKey, + stance: assessment.moduleKind === "atropos" ? "oppose" : "prefer", + severity: + assessment.moduleKind === "atropos" ? "commitment_breach" : null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + ...(assessment.moduleKind === "atropos" && + breachedIntentionIds !== undefined + ? { breachedIntentionIds } + : {}), + }, + ], + })), + }); + return input; +} + +test("an unattributed breach cannot waive protection for an intention change", () => { + const result = resolvePersonalRound(commitmentChange()); + expect(result.resolution.status).toBe("deferred"); + expect(result.spec).toBeNull(); + expect(result.resolution.excluded[0]?.stage).toBe("commitment_protection"); +}); + +for (const ids of [ + [], + ["different-promise"], + ["changed-intention", "different-promise"], +]) { + test(`an intention change cannot waive protected promises ${JSON.stringify(ids)}`, () => { + const result = resolvePersonalRound(commitmentChange(ids)); + expect(result.resolution.status).toBe("deferred"); + expect(result.spec).toBeNull(); + }); +} + +test("an attributed change of the same accepted intention retains its exemption", () => { + const result = resolvePersonalRound(commitmentChange(["changed-intention"])); + expect(result.resolution.status).toBe("resolved"); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); +}); + for (const field of ["agentId", "scopeId"] as const) { test(`rejects candidate ${field} outside its frozen round`, () => { const input = fixture(); @@ -271,6 +349,102 @@ test("a module omitted from round ordering makes no concession", () => { expect(result.resolution.conceded).toEqual([]); }); +for (const [moduleKind, severity, stage] of [ + ["atropos", "commitment_breach", "commitment_protection"], + ["clotho", "infeasible", "infeasible"], +] as const) { + test(`${moduleKind} unavailable cannot gain a ${severity} veto`, () => { + const input = fixture(); + const assessment = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + ); + const opinion = assessment?.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + opinion.stance = "unavailable"; + opinion.unavailableReason = "missing evidence"; + const available = resolvePersonalRound(input); + expect(available.resolution.status).toBe("resolved"); + expect(available.resolution.excluded).toEqual([]); + expect(available.spec?.candidates).toEqual([ + { optionKey: opinion.optionKey, p0: 1, b: null }, + ]); + + opinion.severity = severity; + expect(() => resolvePersonalRound(input)).toThrow( + "severity requires oppose stance", + ); + + opinion.stance = "oppose"; + opinion.unavailableReason = null; + const opposed = resolvePersonalRound(input); + expect(opposed.resolution.status).toBe("deferred"); + expect(opposed.resolution.excluded).toEqual([ + { + optionKey: opinion.optionKey, + stage, + byModule: moduleKind, + reason: expect.any(String), + }, + ]); + expect(opposed.spec).toBeNull(); + }); +} + +for (const stances of [ + ["unavailable", "prefer", "prefer"], + ["unavailable", "unavailable", "prefer"], + ["unavailable", "unavailable", "unavailable"], + ["unavailable", "prefer", "accept"], + ["unavailable", "prefer", "oppose"], + ["prefer", "prefer", "accept"], + ["prefer", "prefer", "oppose"], +] as const) { + test(`${stances.join("/")} records only differing available stances as conflict`, () => { + const input = fixture(); + const option = input.options[0]; + if (!option) throw Error("missing candidate"); + for (const [index, assessment] of input.set.assessments.entries()) { + const opinion = assessment.objectiveAssessments[0]; + const stance = stances[index]; + if (!opinion || !stance) throw Error("missing opinion or stance"); + opinion.stance = stance; + opinion.severity = stance === "oppose" ? "preference" : null; + opinion.unavailableReason = + stance === "unavailable" ? "missing evidence" : null; + } + expect(parseAssessmentSet(input.set)).toEqual(input.set); + const { resolution } = resolvePersonalRound(input); + expect(resolution.abstentions).toHaveLength( + stances.filter((stance) => stance === "unavailable").length, + ); + for (const assessment of input.set.assessments) { + for (const opinion of assessment.objectiveAssessments) { + if (opinion.unavailableReason !== null) + expect(resolution.abstentions).toContainEqual({ + optionKey: opinion.optionKey, + moduleKind: assessment.moduleKind, + reason: opinion.unavailableReason, + }); + } + } + const conflict = stances[2] === "accept" || stances[2] === "oppose"; + expect(resolution.conflicts).toEqual( + conflict + ? [ + { + optionKey: option.optionKey, + stances: { + clotho: stances[0], + lachesis: stances[1], + atropos: stances[2], + }, + }, + ] + : [], + ); + }); +} + test("partial neural lookup failure keeps the entire decision at its baseline distribution", () => { const input = fixture(); const original = input.options[0]; @@ -357,6 +531,27 @@ for (const outcome of ["resolved", "held", "deferred"] as const) { } else expect(result.spec).toBeNull(); }); + for (const [change, error] of [ + [{ schemaVersion: 2 }, "Unsupported judgment snapshot ref schema version"], + [{ workingRevision: -1 }, "invalid working revision"], + [{ clockId: "" }, "invalid clock id"], + [{ extra: true }, "unknown judgment snapshot ref field extra"], + ] as const) { + test(`rejects bound malformed snapshot ${Object.keys(change)[0]} before ${outcome}`, () => { + const input = round(); + Object.assign(input.snapshot, change); + const digest = snapshotDigest(input.snapshot); + input.set.snapshotDigest = digest; + for (const assessment of input.set.assessments) { + assessment.snapshotDigest = digest; + assessment.inputDigest = assessmentInputDigest(assessment); + } + expect(parseAssessmentSet(input.set)).toEqual(input.set); + expect(() => parseJudgmentSnapshotRef(input.snapshot)).toThrow(error); + expect(() => resolvePersonalRound(input)).toThrow(error); + }); + } + for (const change of [{ policyId: "foreign-policy" }, { revision: 2 }]) { test(`rejects mismatched policy ${Object.keys(change)[0]} before ${outcome}`, () => { const input = round(); diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts index d1ce1cf..c8c72c2 100644 --- a/packages/lina-core/test/judgment-policy.test.ts +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -485,11 +485,14 @@ test("(5) only original-acceptance suspend/cancel escape commitment protection", "intention.resume", ].map((kind) => option(kind, kind as PersonalOptionKind)); const result = resolve( - fixture("user_request", options, (m) => - m === "atropos" - ? opinion("oppose", "commitment_breach") - : opinion("accept"), - ), + fixture("user_request", options, (m) => { + if (m === "atropos") + return { + ...opinion("oppose", "commitment_breach"), + breachedIntentionIds: ["intention"], + }; + return opinion("accept"); + }), ); for (const o of options) { const exempt = From 66f9c84df7d672c4c8a6f1cf4780d20b4227bb86 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:32:50 +0900 Subject: [PATCH 35/47] fix(core): replay judgment policy from frozen candidate inputs Persist immutable CandidateSet records with canonical options, host eligibility and verified historical intention references. Require this evidence for executable resolutions and replay complete policy outcomes instead of trusting exclusions or partially reconstructing ranks. Incomplete evidence remains non-executable held data. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/lina-core/src/agents/index.ts | 2 + .../src/agents/judgment-candidates.ts | 192 +++++ .../lina-core/src/agents/judgment-schema.ts | 1 + .../lina-core/src/agents/judgment-store.ts | 350 +++++--- .../test/judgment-candidates.test.ts | 785 ++++++++++++++++++ .../lina-core/test/judgment-round.test.ts | 41 +- .../test/judgment-store-digest-review.test.ts | 39 +- .../lina-core/test/judgment-store.test.ts | 231 ++++-- 8 files changed, 1478 insertions(+), 163 deletions(-) create mode 100644 packages/lina-core/src/agents/judgment-candidates.ts create mode 100644 packages/lina-core/test/judgment-candidates.test.ts diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index e6b40eb..4b111f8 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -94,3 +94,5 @@ export { JudgmentStore } from "./judgment-store.ts"; // tests can pin the BehaviorJobInput fingerprint through the public barrel). export { behaviorFingerprint } from "./behavior-validation.ts"; + +export * from "./judgment-candidates.ts"; diff --git a/packages/lina-core/src/agents/judgment-candidates.ts b/packages/lina-core/src/agents/judgment-candidates.ts new file mode 100644 index 0000000..2f9ec39 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-candidates.ts @@ -0,0 +1,192 @@ +import { INTENTION_STATUSES, type IntentionStatus } from "./judgment.ts"; +import { + type CanonicalOption, + parseCanonicalOption, +} from "./judgment-catalog.ts"; +import type { HostEligibility } from "./judgment-policy.ts"; +import { judgmentDigest } from "./judgment-validation.ts"; +import { boundedId, boundedText } from "./validation.ts"; + +/** Closed Host inputs, independent of assessment arrival order and session lifetime. */ +export type CandidateSet = { + schemaVersion: 1; + roundId: string; + snapshotDigest: string; + options: CanonicalOption[]; + eligibility: HostEligibility; + intentionRefs: Array<{ + intentionId: string; + revision: number; + status: IntentionStatus; + digest: string; + }>; + candidateDigest: string; +}; + +function fields( + value: unknown, + keys: string[], + label: string, +): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + const row = value as Record; + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); + return row; +} +function list(value: unknown, parse: (value: unknown) => T): T[] { + if (!Array.isArray(value) || value.length > 256) + throw Error("invalid candidate list"); + return Array.from(value, parse); +} +function unique(items: T[], key: (item: T) => string): T[] { + if (new Set(items.map(key)).size !== items.length) + throw Error("duplicate candidate key"); + return items; +} +function sorted(items: T[], key: (item: T) => string): T[] { + return unique(items, key).sort((a, b) => + key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0, + ); +} + +function candidateBody(value: unknown): Omit { + const { + schemaVersion, + roundId, + snapshotDigest, + options: rawOptions, + eligibility: rawEligibility, + intentionRefs: rawRefs, + } = fields( + value, + [ + "schemaVersion", + "roundId", + "snapshotDigest", + "options", + "eligibility", + "intentionRefs", + ], + "candidate set", + ); + if (schemaVersion !== 1) + throw Error("Unsupported candidate set schema version"); + const options = sorted( + list(rawOptions, parseCanonicalOption), + (o) => o.optionKey, + ); + const actor = options[0]?.actor; + if ( + options.some( + (o) => + o.actor.agentId !== actor?.agentId || + o.actor.scopeId !== actor?.scopeId, + ) + ) + throw Error("candidate actor mismatch"); + const keys = new Set(options.map((o) => o.optionKey)); + // Eligibility is an ordered Host list, not a derived/defaulted set. Missing + // rows remain missing and the policy treats them as ineligible. + const eligibility = unique( + list(rawEligibility, (value) => { + const { + optionKey: rawKey, + eligible, + reason, + } = fields( + value, + ["optionKey", "eligible", "reason"], + "candidate eligibility", + ); + const optionKey = boundedId(rawKey, "option key"); + if (!keys.has(optionKey)) throw Error("unknown eligibility option key"); + if (typeof eligible !== "boolean") + throw Error("invalid candidate eligibility"); + return { + optionKey, + eligible, + reason: + reason === null ? null : boundedText(reason, "eligibility reason"), + }; + }), + (r) => r.optionKey, + ); + const intentionRefs = sorted( + list(rawRefs, (value) => { + const { + intentionId, + revision, + status: rawStatus, + digest, + } = fields( + value, + ["intentionId", "revision", "status", "digest"], + "candidate intention ref", + ); + const status = INTENTION_STATUSES.find((s) => s === rawStatus); + if ( + typeof revision !== "number" || + !Number.isSafeInteger(revision) || + revision < 0 || + status === undefined + ) + throw Error("invalid candidate intention ref"); + return { + intentionId: boundedId(intentionId, "intention id"), + revision, + status, + digest: boundedId(digest, "intention digest"), + }; + }), + (r) => r.intentionId, + ); + return { + schemaVersion: 1, + roundId: boundedId(roundId, "round id"), + snapshotDigest: boundedId(snapshotDigest, "snapshot digest"), + options, + eligibility, + intentionRefs, + }; +} + +export function parseCandidateSet(value: unknown): CandidateSet { + const row = fields( + value, + [ + "schemaVersion", + "roundId", + "snapshotDigest", + "options", + "eligibility", + "intentionRefs", + "candidateDigest", + ], + "candidate set", + ); + const { candidateDigest, ...rest } = row; + const result = candidateBody(rest); + if (candidateDigest !== judgmentDigest(result)) + throw Error("candidate set digest mismatch"); + return { ...result, candidateDigest }; +} + +export function buildCandidateSet(input: { + roundId: string; + snapshotDigest: string; + options: CanonicalOption[]; + eligibility: HostEligibility; + intentionRefs: CandidateSet["intentionRefs"]; +}): CandidateSet { + const result = candidateBody({ schemaVersion: 1, ...input }); + return { ...result, candidateDigest: judgmentDigest(result) }; +} diff --git a/packages/lina-core/src/agents/judgment-schema.ts b/packages/lina-core/src/agents/judgment-schema.ts index e29b4e9..ec6f910 100644 --- a/packages/lina-core/src/agents/judgment-schema.ts +++ b/packages/lina-core/src/agents/judgment-schema.ts @@ -7,6 +7,7 @@ CREATE TABLE judgment_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; CREATE TABLE objective_profiles (objective_id TEXT NOT NULL, revision INTEGER NOT NULL, module_kind TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(objective_id, revision)) STRICT; CREATE TABLE objective_profile_active (agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, module_kind TEXT NOT NULL, objective_id TEXT NOT NULL, revision INTEGER NOT NULL, activation_revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(agent_id, scope_id, module_kind), FOREIGN KEY(objective_id, revision) REFERENCES objective_profiles(objective_id, revision)) STRICT; CREATE TABLE rounds (round_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, scope_id TEXT NOT NULL, situation TEXT NOT NULL, sequence INTEGER NOT NULL, snapshot TEXT NOT NULL, snapshot_digest TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('open','resolved','deferred','held')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, UNIQUE(agent_id, scope_id, sequence)) STRICT; +CREATE TABLE candidate_sets (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), snapshot_digest TEXT NOT NULL, candidate_digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; CREATE TABLE assessments (round_id TEXT NOT NULL REFERENCES rounds(round_id), module_kind TEXT NOT NULL, snapshot_digest TEXT NOT NULL, input_digest TEXT NOT NULL, digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(round_id, module_kind)) STRICT; CREATE TABLE resolution_records (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; CREATE TABLE selection_specs (round_id TEXT PRIMARY KEY REFERENCES rounds(round_id), spec_digest TEXT NOT NULL, body TEXT NOT NULL, created_at INTEGER NOT NULL) STRICT; diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index f114e91..aba6353 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -16,10 +16,12 @@ import { type RoundStatus, type SelectionSpec, } from "./judgment.ts"; -import { PERSONAL_POLICY_V1, rankMass } from "./judgment-policy.ts"; +import { type CandidateSet, parseCandidateSet } from "./judgment-candidates.ts"; +import { PERSONAL_POLICY_V1, resolvePersonalRound } from "./judgment-policy.ts"; import { initializeJudgmentSchema } from "./judgment-schema.ts"; import { canonicalJson, + INTENTION_TRANSITIONS, intentionDigest, judgmentDigest, parseAssessment, @@ -47,10 +49,35 @@ function revision(value: number, minimum = 0): number { function validateCandidateBinding( resolution: ResolutionRecord, - selection: SelectionSpec, - set: AssessmentSet, + selection: SelectionSpec | null, + set: AssessmentSet | null, snapshot: JudgmentSnapshotRef, + candidates: CandidateSet | null, ): void { + // Without complete evidence only a non-executable failure receipt is trusted. + // Its descriptive fields are not policy-replayed; deferred is not a fallback. + if (!candidates || !set) { + if (resolution.status !== "held" || selection !== null) + throw Error(!set ? "incomplete assessment set" : "missing candidate set"); + return; + } + const replayed = resolvePersonalRound({ + policy: PERSONAL_POLICY_V1, + snapshot, + options: candidates.options, + eligibility: candidates.eligibility, + set, + bias: Object.fromEntries( + selection?.candidates.map((c) => [c.optionKey, c.b]) ?? [], + ), + }); + if (body(resolution) !== body(replayed.resolution)) + throw Error("resolution policy replay mismatch"); + if (selection === null || replayed.spec === null) { + if (selection !== replayed.spec) + throw Error("selection policy replay mismatch"); + return; + } if (selection.assessmentSetDigest !== judgmentDigest(set)) throw Error("selection assessment set mismatch"); if (selection.resolutionDigest !== judgmentDigest(resolution)) @@ -65,98 +92,109 @@ function validateCandidateBinding( body(selection.objectiveProfileRefs) !== body(snapshot.objectiveProfileRefs) ) throw Error("selection objective refs mismatch"); - const ranked = new Set(resolution.ranking.map((r) => r.optionKey)); - const universe = new Set([ - ...ranked, - ...resolution.excluded.map((r) => r.optionKey), - ]); - if ( - universe.size !== ranked.size + resolution.excluded.length || - selection.candidates.length !== universe.size || - selection.candidates.some((c) => !universe.has(c.optionKey)) || - [ - ...Object.values(resolution.recommendations).flat(), - ...resolution.conflicts.map((r) => r.optionKey), - ...resolution.abstentions.map((r) => r.optionKey), - ...resolution.conceded.map((r) => r.optionKey), - ...set.assessments.flatMap((a) => [ - ...a.proposedOptionKeys, - ...a.recommendedOptionKeys, - ...a.objectiveAssessments.map((o) => o.optionKey), - ]), - ].some((key) => !universe.has(key)) - ) - throw Error("selection candidate universe mismatch"); - // Only this policy has a declared baseline in F1. Do not guess a ratio for - // another catalog/revision; held/deferred records do not need a baseline. - const policy = PERSONAL_POLICY_V1; - if ( - selection.policyId !== policy.policyId || - selection.policyRevision !== policy.revision - ) - throw Error("unsupported selection policy declaration"); + if (body(selection) !== body(replayed.spec)) + throw Error("selection policy replay mismatch"); +} + +function validateCandidateEvidence( + set: CandidateSet, + snapshot: JudgmentSnapshotRef, + lookup: (id: string) => IntentionRecord | null, + assessments: Assessment[], + current: boolean, +): void { if ( - body(resolution.order) !== body(policy.orders[selection.situation]) || - selection.lambda !== policy.lambda[selection.situation] + set.roundId !== snapshot.roundId || + set.snapshotDigest !== snapshotDigest(snapshot) ) - throw Error("selection policy mismatch"); - const opinions = new Map( - set.assessments.map((a) => [ - a.moduleKind, - new Map(a.objectiveAssessments.map((o) => [o.optionKey, o])), - ]), - ); - for (const assessment of set.assessments) { + throw Error("candidate snapshot mismatch"); + const referenced = new Map(); + for (const ref of set.intentionRefs) { + const record = lookup(ref.intentionId); if ( - body(resolution.recommendations[assessment.moduleKind]) !== - body(assessment.recommendedOptionKeys) + !record || + record.agentId !== snapshot.agentId || + record.scopeId !== snapshot.scopeId ) - throw Error("selection assessment recommendations mismatch"); + throw Error("candidate intention scope or reference mismatch"); if ( - [...ranked].some((key) => !opinions.get(assessment.moduleKind)?.has(key)) + record.revision < ref.revision || + (current && record.revision !== ref.revision) ) - throw Error("selection missing ranked assessment"); + throw Error("stale candidate intention revision"); + // This owner changes only status/revision/history. Preserve every original + // field while reconstructing the frozen revision from validated history. + const historical = parseIntentionRecord({ + ...record, + revision: ref.revision, + status: ref.status, + history: record.history.slice(0, ref.revision), + }); + if (intentionDigest(historical) !== ref.digest) + throw Error("candidate intention digest mismatch"); + referenced.set(ref.intentionId, historical); } - // Unavailable modules are dropped for the whole ranking, as in the policy. - const order = policy.orders[selection.situation].filter((m) => - [...ranked].every( - (key) => opinions.get(m)?.get(key)?.stance !== "unavailable", - ), + const eligible = new Set( + set.eligibility.filter((r) => r.eligible).map((r) => r.optionKey), ); - if (order.length === 0) throw Error("selection unavailable ranking"); - const compare = (a: string, b: string): number => { - for (const module of order) { - const left = opinions.get(module)?.get(a); - const right = opinions.get(module)?.get(b); - if (!left || !right) throw Error("selection missing ranked assessment"); - const difference = - policy.stanceOrder.indexOf(left.stance) - - policy.stanceOrder.indexOf(right.stance); - if (difference !== 0) return difference; + for (const option of set.options) { + if ( + option.actor.agentId !== snapshot.agentId || + option.actor.scopeId !== snapshot.scopeId + ) + throw Error("candidate actor snapshot mismatch"); + const precondition = option.preconditions; + if (!eligible.has(option.optionKey) || !("intentionId" in precondition)) + continue; + const record = referenced.get(precondition.intentionId); + if (!record) throw Error("missing candidate intention ref"); + if ( + "acceptanceSourceRef" in precondition && + precondition.acceptanceSourceRef !== record.acceptance.sourceRef + ) + throw Error("candidate original acceptance mismatch"); + if (precondition.kind === "task.start") continue; + if (option.targetId !== record.intentionId) + throw Error("candidate intention target mismatch"); + const to = { + "intention.activate": "active", + "intention.resume": "active", + "intention.suspend": "suspended", + "intention.cancel": "cancelled", + "intention.complete": "completed", + }[precondition.kind] as IntentionStatus; + if ( + !INTENTION_TRANSITIONS[record.status].includes(to) || + (precondition.kind === "intention.activate" && + record.status !== "adopted") || + (precondition.kind === "intention.resume" && + record.status !== "suspended") + ) + throw Error("illegal candidate intention transition"); + } + const keys = new Set(set.options.map((o) => o.optionKey)); + for (const assessment of assessments) { + if ( + [ + ...assessment.proposedOptionKeys, + ...assessment.recommendedOptionKeys, + ...assessment.objectiveAssessments.map((o) => o.optionKey), + ].some((key) => !keys.has(key)) + ) + throw Error("unknown assessment option key"); + if (assessment.moduleKind !== "atropos") continue; + for (const opinion of assessment.objectiveAssessments) { + for (const id of opinion.breachedIntentionIds ?? []) { + const record = referenced.get(id); + if ( + record?.kind !== "user_commitment" || + record.acceptance.acceptedBy !== "user" || + !["adopted", "active", "suspended"].includes(record.status) + ) + throw Error("missing protected commitment evidence"); + } } - return 0; - }; - let rank = 0; - let previous: string | undefined; - for (const item of resolution.ranking) { - const difference = - previous === undefined ? -1 : compare(previous, item.optionKey); - if (difference > 0) throw Error("selection ranking mismatch"); - if (difference < 0) rank += 1; - if (item.rank !== rank) throw Error("selection ranking mismatch"); - previous = item.optionKey; } - const masses = rankMass( - resolution.ranking.map((r) => r.rank), - policy.ratio, - ); - const baseline = new Map( - resolution.ranking.map((r, i) => [r.optionKey, masses[i]]), - ); - if ( - selection.candidates.some((c) => c.p0 !== (baseline.get(c.optionKey) ?? 0)) - ) - throw Error("selection baseline mismatch"); } /** The Host owns the single writer; this ledger is independent of session lifetimes. */ @@ -371,6 +409,53 @@ export class JudgmentStore { }, false); } + /** Freeze the exact Host universe and provenance once, while its round is open. */ + closeCandidateSet(set: CandidateSet): void { + this.assertOpen(); + const parsed = parseCandidateSet(set); + this.transaction(() => { + const round = this.requireOpenRound(parsed.roundId); + if (this.candidateSet(parsed.roundId)) + throw Error("candidate set already closed"); + validateCandidateEvidence( + parsed, + round.snapshot, + (id) => this.getIntention(id), + this.storedAssessments(parsed.roundId), + true, + ); + this.db + .prepare( + "INSERT INTO candidate_sets(round_id, snapshot_digest, candidate_digest, body, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .run( + parsed.roundId, + parsed.snapshotDigest, + parsed.candidateDigest, + body(parsed), + this.now(), + ); + }); + } + + candidateSet(roundId: string): CandidateSet | null { + return this.transaction(() => { + const row = this.db + .prepare("SELECT body FROM candidate_sets WHERE round_id = ?") + .get(boundedId(roundId, "round id")); + if (!row) return null; + const { body: json } = row; + return parseCandidateSet(JSON.parse(String(json))); + }, false); + } + + private storedAssessments(roundId: string): Assessment[] { + return this.db + .prepare("SELECT body FROM assessments WHERE round_id = ?") + .all(roundId) + .map(({ body: json }) => parseAssessment(JSON.parse(String(json)))); + } + putAssessment(assessment: Assessment): void { this.assertOpen(); const parsed = parseAssessment(assessment); @@ -388,6 +473,15 @@ export class JudgmentStore { parsed.objectiveRef.digest !== objective.digest ) throw Error("assessment objective mismatch"); + const candidates = this.candidateSet(parsed.snapshotId); + if (candidates) + validateCandidateEvidence( + candidates, + round.snapshot, + (id) => this.getIntention(id), + [parsed], + false, + ); if ( this.db .prepare( @@ -475,10 +569,27 @@ export class JudgmentStore { selection.snapshotDigest !== round.snapshotDigest)) ) throw Error("selection spec mismatch"); - if (selection) { - const set = this.assessmentSet(id); - validateCandidateBinding(parsed, selection, set, round.snapshot); - } + const candidates = this.candidateSet(id); + const assessments = this.storedAssessments(id); + if (candidates) + validateCandidateEvidence( + candidates, + round.snapshot, + (id) => this.getIntention(id), + assessments, + false, + ); + const set = + assessments.length === MODULE_KINDS.length + ? this.assessmentSet(id) + : null; + validateCandidateBinding( + parsed, + selection, + set, + round.snapshot, + candidates, + ); const now = this.now(); this.db .prepare( @@ -761,6 +872,15 @@ export class JudgmentStore { "snapshot", "snapshot_digest", ); + const candidates = rows( + "candidate_sets", + "candidate set", + parseCandidateSet, + (p) => ({ round_id: p.roundId, snapshot_digest: p.snapshotDigest }), + (p) => p.candidateDigest, + "body", + "candidate_digest", + ); const assessments = rows( "assessments", "assessment", @@ -857,15 +977,6 @@ export class JudgmentStore { selection.snapshotDigest !== snapshotDigest(snapshot) ) throw Error("selection spec metadata mismatch"); - const set = parseAssessmentSet({ - schemaVersion: 1, - roundId: selection.roundId, - snapshotDigest: selection.snapshotDigest, - assessments: MODULE_KINDS.map((module) => - sets.get(selection.roundId)?.find((a) => a.moduleKind === module), - ), - }); - validateCandidateBinding(record, selection, set, snapshot); } const intentions = rows( "intention_records", @@ -913,6 +1024,49 @@ export class JudgmentStore { ) throw Error("intention transition metadata mismatch"); } + const intentionsById = new Map( + intentions.map(({ value }) => [value.intentionId, value]), + ); + const candidatesByRound = new Map( + candidates.map(({ value }) => [value.roundId, value]), + ); + for (const { value } of candidates) { + const snapshot = snapshots.get(value.roundId); + if (!snapshot) throw Error("judgment foreign key mismatch"); + validateCandidateEvidence( + value, + snapshot, + (id) => intentionsById.get(id) ?? null, + sets.get(value.roundId) ?? [], + false, + ); + } + const selectionsByRound = new Map( + selections.map(({ value }) => [value.roundId, value]), + ); + for (const { value: record } of resolutions) { + const snapshot = snapshots.get(record.roundId); + if (!snapshot) throw Error("resolution snapshot mismatch"); + const assessments = sets.get(record.roundId) ?? []; + const set = + assessments.length === MODULE_KINDS.length + ? parseAssessmentSet({ + schemaVersion: 1, + roundId: record.roundId, + snapshotDigest: snapshotDigest(snapshot), + assessments: MODULE_KINDS.map((module) => + assessments.find((a) => a.moduleKind === module), + ), + }) + : null; + validateCandidateBinding( + record, + selectionsByRound.get(record.roundId) ?? null, + set, + snapshot, + candidatesByRound.get(record.roundId) ?? null, + ); + } if (this.db.prepare("PRAGMA foreign_key_check").all().length > 0) throw Error("judgment foreign key mismatch"); } diff --git a/packages/lina-core/test/judgment-candidates.test.ts b/packages/lina-core/test/judgment-candidates.test.ts new file mode 100644 index 0000000..18ae5d7 --- /dev/null +++ b/packages/lina-core/test/judgment-candidates.test.ts @@ -0,0 +1,785 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { + assessmentInputDigest, + buildCandidateSet, + buildCanonicalOption, + type CandidateSet, + type CanonicalOption, + type IntentionRecord, + intentionDigest, + type JudgmentSnapshotRef, + JudgmentStore, + judgmentDigest, + MODULE_KINDS, + PERSONAL_POLICY_V1, + parseAssessment, + parseCandidateSet, + parseIntentionRecord, + parseResolutionRecord, + parseSelectionSpec, + resolvePersonalRound, + snapshotDigest, +} from "../src/agents/index.ts"; +import { Fixture } from "./fixture.ts"; + +let fixture: Fixture; +let store: JudgmentStore; +let path: string; +let snapshot: JudgmentSnapshotRef; +const actor = { agentId: "agent-1", scopeId: "scope-1" }; +const at = "2026-09-12T00:00:00.000Z"; +function option(reason = "one"): CanonicalOption { + return buildCanonicalOption({ + kind: "noop", + actor, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason }, + }); +} +function candidate( + options = [option()], + intentionRefs: CandidateSet["intentionRefs"] = [], +): CandidateSet { + return buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options, + eligibility: options.map((o) => ({ + optionKey: o.optionKey, + eligible: true, + reason: null, + })), + intentionRefs, + }); +} +function intention(id = "intention-1"): IntentionRecord { + return parseIntentionRecord({ + schemaVersion: 1, + intentionId: id, + ...actor, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "Keep commitment", + acceptance: { + sourceRef: "request-1", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); +} +function transition( + id: string, + to: IntentionRecord["status"], + revision: number, +) { + return store.transitionIntention( + id, + { to, reason: "transition", evidenceRef: "request-1", at }, + revision, + ); +} +function intentionRef( + record: IntentionRecord, +): CandidateSet["intentionRefs"][number] { + return { + intentionId: record.intentionId, + revision: record.revision, + status: record.status, + digest: intentionDigest(record), + }; +} +function assess(options = [option()], breachIds?: string[]) { + for (const moduleKind of MODULE_KINDS) { + const input = { + snapshotDigest: snapshotDigest(snapshot), + objectiveRef: snapshot.objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + store.putAssessment( + parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + ...input, + inputDigest: assessmentInputDigest(input), + completeText: "Assessment", + evidenceRefs: [], + proposedOptionKeys: [], + recommendedOptionKeys: [], + objectiveAssessments: options.map((o) => ({ + optionKey: o.optionKey, + stance: breachIds && moduleKind === "atropos" ? "oppose" : "accept", + severity: + breachIds && moduleKind === "atropos" ? "commitment_breach" : null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "uncertainty", + evidenceRefs: [], + ...(breachIds && moduleKind === "atropos" + ? { breachedIntentionIds: breachIds } + : {}), + })), + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }), + ); + } +} +function resolve(set: CandidateSet) { + return resolvePersonalRound({ + policy: PERSONAL_POLICY_V1, + snapshot, + options: set.options, + eligibility: set.eligibility, + set: store.assessmentSet(snapshot.roundId), + bias: {}, + }); +} +function reopen() { + store.close(); + store = new JudgmentStore(path); +} +function db() { + return fixture.keep(new DatabaseSync(path)); +} +function rehash(set: CandidateSet) { + return parseCandidateSet({ + ...set, + candidateDigest: judgmentDigest({ ...set, candidateDigest: undefined }), + }); +} +function held() { + return parseResolutionRecord({ + schemaVersion: 1, + roundId: snapshot.roundId, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: [], lachesis: [], atropos: [] }, + conflicts: [], + excluded: [], + abstentions: [], + ranking: [], + conceded: [], + status: "held", + holdReason: "missing evidence", + }); +} +beforeEach(() => { + fixture = new Fixture(); + path = join(fixture.dir, "judgment.sqlite"); + store = new JudgmentStore(path, { now: () => 1234 }); + for (const moduleKind of MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: moduleKind, + moduleKind, + revision: 1, + objective: "Compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + store.activateObjectiveProfile(actor.agentId, actor.scopeId, ref); + } + const refs = store.activeObjectiveProfiles(actor.agentId, actor.scopeId); + if (!refs) throw Error("missing fixture profiles"); + snapshot = { + schemaVersion: 1, + roundId: "round-1", + ...actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock", + sequence: 1, + bindingGeneration: 0, + }; + store.openRound(snapshot); +}); +afterEach(() => { + store.close(); + fixture.close(); +}); + +test("candidate builder sorts canonical options and refs but preserves exact Host eligibility order and missing rows", () => { + const a = option("a"), + b = option("b"); + const original = candidate([a, b]); + const reversed = buildCandidateSet({ + roundId: original.roundId, + snapshotDigest: original.snapshotDigest, + options: [b, a], + eligibility: [...original.eligibility].reverse(), + intentionRefs: [], + }); + expect(reversed.options).toEqual(original.options); + expect( + parseCandidateSet({ + ...original, + options: [...original.options].reverse(), + }), + ).toEqual(original); + const refs = candidate( + [a], + [intentionRef(intention("b")), intentionRef(intention("a"))], + ); + expect(refs.intentionRefs.map((r) => r.intentionId)).toEqual(["a", "b"]); + expect(reversed.eligibility).toEqual([...original.eligibility].reverse()); + expect(reversed.candidateDigest).not.toBe(original.candidateDigest); + expect(parseCandidateSet(JSON.parse(JSON.stringify(original)))).toEqual( + original, + ); + const missing = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options: [a, b], + eligibility: [], + intentionRefs: [], + }); + expect(missing.eligibility).toEqual([]); + store.closeCandidateSet(missing); + assess([a, b]); + const result = resolve(missing); + expect(result.resolution.status).toBe("deferred"); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(missing); + expect(store.getResolution(snapshot.roundId)).toEqual(result.resolution); +}); + +test("candidate parser rejects unknown, duplicate, malformed and foreign input without normalizing Host semantics", () => { + const set = candidate(); + for (const changed of [ + { ...set, schemaVersion: 2 }, + { ...set, extra: true }, + { ...set, candidateDigest: "forged" }, + { ...set, options: [option(), option()] }, + { ...set, options: [{ ...option(), optionKey: "forged" }] }, + { ...set, eligibility: [...set.eligibility, ...set.eligibility] }, + { + ...set, + eligibility: [{ optionKey: "foreign", eligible: true, reason: null }], + }, + { + ...set, + eligibility: [ + { optionKey: option().optionKey, eligible: "true", reason: null }, + ], + }, + { + ...set, + eligibility: [{ optionKey: option().optionKey, eligible: false }], + }, + { + ...set, + intentionRefs: [intentionRef(intention()), intentionRef(intention())], + }, + { ...set, intentionRefs: [{ ...intentionRef(intention()), revision: -1 }] }, + { ...set, intentionRefs: [{ ...intentionRef(intention()), extra: true }] }, + ]) + expect(() => parseCandidateSet(changed)).toThrow(); + const foreign = buildCanonicalOption({ + ...option("foreign"), + actor: { ...actor, scopeId: "foreign" }, + }); + expect(() => candidate([option(), foreign])).toThrow( + "candidate actor mismatch", + ); +}); + +test("closure requires an open matching frozen round and is immutable and detached", () => { + expect(store.candidateSet(snapshot.roundId)).toBeNull(); + for (const changed of [ + rehash({ ...candidate(), roundId: "missing" }), + rehash({ ...candidate(), snapshotDigest: "wrong" }), + candidate([ + buildCanonicalOption({ + ...option(), + actor: { ...actor, agentId: "foreign" }, + }), + ]), + ]) + expect(() => store.closeCandidateSet(changed)).toThrow(); + const set = candidate(); + store.closeCandidateSet(set); + expect(() => store.closeCandidateSet(set)).toThrow( + "candidate set already closed", + ); + set.eligibility.length = 0; + expect(store.candidateSet(snapshot.roundId)).toEqual(candidate()); + store.recordResolution(snapshot.roundId, held(), null); + expect(() => store.closeCandidateSet(candidate())).toThrow( + "judgment round is not open", + ); + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(candidate()); +}); + +for (const complete of [false, true]) + test(`only held/no-spec can record missing candidate evidence (assessments complete=${complete})`, () => { + if (complete) assess(); + expect(() => + store.recordResolution( + snapshot.roundId, + { ...held(), status: "deferred" }, + null, + ), + ).toThrow(); + store.recordResolution(snapshot.roundId, held(), null); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(held()); + }); + +test("complete inputs replay held and deferred, rejecting descriptive mutations atomically", () => { + const set = candidate(); + store.closeCandidateSet(set); + assess([]); + const result = resolve(set); + expect(result.resolution.status).toBe("held"); + expect(() => + store.recordResolution( + snapshot.roundId, + { ...result.resolution, status: "deferred" }, + null, + ), + ).toThrow("resolution policy replay mismatch"); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); + store.recordResolution(snapshot.roundId, result.resolution, null); + db() + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(held()), judgmentDigest(held())); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); +}); + +for (const stage of [ + "infeasible", + "commitment_protection", + "host_eligibility", +] as const) { + for (const persisted of [false, true]) + test(`closed all-accept inputs reject fully rehashed ${stage} exclusion (persisted=${persisted})`, () => { + const set = candidate([option("a"), option("b")]); + store.closeCandidateSet(set); + assess(set.options); + const result = resolve(set); + const spec = result.spec; + if (!spec) throw Error("missing spec"); + const [first, second] = set.options; + if (!first || !second) throw Error("missing options"); + const forged = parseResolutionRecord({ + ...result.resolution, + ranking: [{ optionKey: first.optionKey, rank: 1 }], + excluded: [ + { + optionKey: second.optionKey, + stage, + byModule: + stage === "infeasible" + ? "clotho" + : stage === "commitment_protection" + ? "atropos" + : null, + reason: "forged", + }, + ], + }); + const changed = { + ...spec, + resolutionDigest: judgmentDigest(forged), + candidates: spec.candidates.map((c) => ({ + ...c, + p0: c.optionKey === first.optionKey ? 1 : 0, + })), + eligibleDigest: judgmentDigest([first.optionKey]), + }; + const forgedSpec = parseSelectionSpec({ + ...changed, + specDigest: judgmentDigest({ ...changed, specDigest: undefined }), + }); + if (persisted) { + store.recordResolution(snapshot.roundId, result.resolution, spec); + const sql = db(); + sql + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(forged), judgmentDigest(forged)); + sql + .prepare("UPDATE selection_specs SET body = ?, spec_digest = ?") + .run(JSON.stringify(forgedSpec), forgedSpec.specDigest); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); + reopen(); + expect(() => store.getSelectionSpec(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); + } else { + expect(() => + store.recordResolution(snapshot.roundId, forged, forgedSpec), + ).toThrow("resolution policy replay mismatch"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); + store.recordResolution(snapshot.roundId, result.resolution, spec); + } + }); +} + +for (const column of [ + "round_id", + "snapshot_digest", + "candidate_digest", + "body", + "delete", +] as const) + test(`candidate audit detects ${column} tampering on external commit and reopen`, () => { + const set = candidate(); + store.closeCandidateSet(set); + assess(); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + const sql = db(); + sql.exec("PRAGMA foreign_keys = OFF"); + if (column === "delete") sql.exec("DELETE FROM candidate_sets"); + else + sql + .prepare(`UPDATE candidate_sets SET ${column} = ?`) + .run( + column === "body" + ? JSON.stringify({ ...set, eligibility: [] }) + : "forged", + ); + expect(() => store.getRound(snapshot.roundId)).toThrow(); + reopen(); + expect(() => store.candidateSet(snapshot.roundId)).toThrow(); + }); + +function suspend(id = "intention-1", acceptanceSourceRef = "request-1") { + return buildCanonicalOption({ + kind: "intention.suspend", + actor, + targetId: id, + args: {}, + preconditions: { + kind: "intention.suspend", + intentionId: id, + acceptanceSourceRef, + reason: "pause", + }, + }); +} + +test("eligible intention targets require matching current scoped references, acceptance and legal transition", () => { + store.putIntention(intention()); + const adopted = transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + store.putIntention({ ...intention("foreign"), scopeId: "foreign" }); + for (const set of [ + candidate([suspend()]), + candidate([suspend()], [intentionRef(adopted)]), + candidate([suspend()], [{ ...intentionRef(active), digest: "wrong" }]), + candidate([suspend()], [{ ...intentionRef(active), status: "suspended" }]), + candidate( + [suspend("missing")], + [{ ...intentionRef(active), intentionId: "missing" }], + ), + candidate( + [suspend("foreign")], + [intentionRef({ ...intention("foreign"), scopeId: "foreign" })], + ), + candidate([suspend("intention-1", "fabricated")], [intentionRef(active)]), + candidate( + [ + buildCanonicalOption({ + kind: "intention.resume", + actor, + targetId: active.intentionId, + args: {}, + preconditions: { + kind: "intention.resume", + intentionId: active.intentionId, + acceptanceSourceRef: "request-1", + reason: "resume", + }, + }), + ], + [intentionRef(active)], + ), + ]) { + expect(() => store.closeCandidateSet(set)).toThrow(); + expect(store.candidateSet(snapshot.roundId)).toBeNull(); + } + store.closeCandidateSet(candidate([suspend()], [intentionRef(active)])); +}); + +for (const tamper of ["none", "metadata", "acceptance", "delete"] as const) + test(`frozen intention revision survives valid later transitions but rejects ${tamper} rewrite`, () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + const set = candidate([suspend()], [intentionRef(active)]); + store.closeCandidateSet(set); + assess(set.options); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + transition("intention-1", "suspended", 2); + transition("intention-1", "active", 3); + const current = transition("intention-1", "completed", 4); + const sql = db(); + if (tamper === "none") { + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(set); + expect(store.getResolution(snapshot.roundId)).toEqual(result.resolution); + expect(store.getIntention(current.intentionId)).toEqual(current); + return; + } + if (tamper === "delete") + sql.exec( + "PRAGMA foreign_keys = OFF; DELETE FROM intention_transitions; DELETE FROM intention_records", + ); + else { + const changed = parseIntentionRecord( + tamper === "metadata" + ? { ...current, text: "replacement" } + : { + ...current, + acceptance: { + ...current.acceptance, + acceptedAt: "2026-09-11T00:00:00.000Z", + }, + }, + ); + sql + .prepare("UPDATE intention_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), intentionDigest(changed)); + } + expect(() => store.getResolution(snapshot.roundId)).toThrow( + /candidate intention/, + ); + reopen(); + expect(() => store.candidateSet(snapshot.roundId)).toThrow( + /candidate intention/, + ); + }); + +test("candidate insertion failure rolls back closure and leaves the round open", () => { + const sql = db(); + sql.exec( + "CREATE TRIGGER fail_candidate BEFORE INSERT ON candidate_sets BEGIN SELECT RAISE(ABORT, 'fixture candidate failure'); END", + ); + expect(() => store.closeCandidateSet(candidate())).toThrow( + "fixture candidate failure", + ); + expect(store.candidateSet(snapshot.roundId)).toBeNull(); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); + sql.exec("DROP TRIGGER fail_candidate"); + store.closeCandidateSet(candidate()); + expect(store.candidateSet(snapshot.roundId)).toEqual(candidate()); +}); + +test("rehashed candidate input tampering must agree with the persisted policy outcome", () => { + const set = candidate(); + store.closeCandidateSet(set); + assess(); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + const changed = rehash({ ...set, eligibility: [] }); + db() + .prepare("UPDATE candidate_sets SET body = ?, candidate_digest = ?") + .run(JSON.stringify(changed), changed.candidateDigest); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); + reopen(); + expect(() => store.candidateSet(snapshot.roundId)).toThrow( + "resolution policy replay mismatch", + ); +}); + +test("complete replay binds conflicts, recommendations, abstentions, concessions and ordering", () => { + const set = candidate(); + store.closeCandidateSet(set); + assess(); + const result = resolve(set); + if (!result.spec) throw Error("missing spec"); + const key = option().optionKey; + for (const change of [ + { + conflicts: [ + { + optionKey: key, + stances: { clotho: "prefer", lachesis: "accept", atropos: "accept" }, + }, + ], + }, + { recommendations: { clotho: [key], lachesis: [], atropos: [] } }, + { + abstentions: [ + { optionKey: key, moduleKind: "clotho", reason: "fabricated" }, + ], + }, + { conceded: [{ optionKey: key, moduleKind: "atropos" }] }, + { order: ["clotho", "atropos", "lachesis"] }, + ]) { + const record = parseResolutionRecord({ ...result.resolution, ...change }); + const spec = { ...result.spec, resolutionDigest: judgmentDigest(record) }; + const selection = parseSelectionSpec({ + ...spec, + specDigest: judgmentDigest({ ...spec, specDigest: undefined }), + }); + expect(() => + store.recordResolution(snapshot.roundId, record, selection), + ).toThrow("resolution policy replay mismatch"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + } + const spec = { + ...result.spec, + candidates: result.spec.candidates.map((c) => ({ ...c, b: 0.75 })), + }; + const selection = parseSelectionSpec({ + ...spec, + specDigest: judgmentDigest({ ...spec, specDigest: undefined }), + }); + store.recordResolution(snapshot.roundId, result.resolution, selection); + reopen(); + expect(store.getSelectionSpec(snapshot.roundId)).toEqual(selection); +}); + +test("historical evidence rejects rewritten original acceptance even with a consistent current history ledger", () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + store.closeCandidateSet(candidate([suspend()], [intentionRef(active)])); + const suspended = transition("intention-1", "suspended", 2); + const changed = parseIntentionRecord({ + ...suspended, + acceptance: { ...suspended.acceptance, sourceRef: "replacement" }, + history: suspended.history.map((t) => ({ + ...t, + evidenceRef: "replacement", + })), + }); + const sql = db(); + sql + .prepare("UPDATE intention_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), intentionDigest(changed)); + sql.exec("UPDATE intention_transitions SET evidence_ref = 'replacement'"); + expect(() => store.candidateSet(snapshot.roundId)).toThrow( + "candidate intention digest mismatch", + ); + reopen(); + expect(() => store.getIntention("intention-1")).toThrow( + "candidate intention digest mismatch", + ); +}); + +test("eligible resume references the suspended intention and original acceptance when present", () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const suspended = transition("intention-1", "suspended", 1); + const resume = buildCanonicalOption({ + kind: "intention.resume", + actor, + targetId: suspended.intentionId, + args: {}, + preconditions: { + kind: "intention.resume", + intentionId: suspended.intentionId, + acceptanceSourceRef: "request-1", + reason: "resume", + }, + }); + const set = candidate([resume], [intentionRef(suspended)]); + store.closeCandidateSet(set); + assess(set.options); + const result = resolve(set); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + transition(suspended.intentionId, "active", 2); + reopen(); + expect(store.candidateSet(snapshot.roundId)).toEqual(set); +}); + +test("Atropos attributed breach evidence is required whether assessments arrive before or after closure", () => { + assess([option()], ["missing"]); + expect(() => store.closeCandidateSet(candidate())).toThrow( + "missing protected commitment evidence", + ); + expect(store.candidateSet(snapshot.roundId)).toBeNull(); +}); + +for (const kind of ["user_commitment", "autonomous_goal"] as const) + for (const status of ["proposed", "active"] as const) + test(`attributed protected evidence requires accepted live user commitment (${kind}/${status})`, () => { + const proposed = { ...intention(), kind }; + store.putIntention(proposed); + let record: IntentionRecord = proposed; + if (status === "active") { + transition(record.intentionId, "adopted", 0); + record = transition(record.intentionId, "active", 1); + } + const set = candidate([option()], [intentionRef(record)]); + store.closeCandidateSet(set); + if (kind !== "user_commitment" || status !== "active") + expect(() => assess(set.options, [record.intentionId])).toThrow( + "missing protected commitment evidence", + ); + else { + assess(set.options, [record.intentionId]); + const result = resolve(set); + expect(result.resolution.status).toBe("deferred"); + store.recordResolution( + snapshot.roundId, + result.resolution, + result.spec, + ); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual( + result.resolution, + ); + } + }); + +test("targeted waiver with verified protected intention is replayed and remains historical", () => { + store.putIntention(intention()); + transition("intention-1", "adopted", 0); + const active = transition("intention-1", "active", 1); + const set = candidate([suspend()], [intentionRef(active)]); + assess(set.options, [active.intentionId]); + store.closeCandidateSet(set); + const result = resolve(set); + expect(result.resolution.status).toBe("resolved"); + store.recordResolution(snapshot.roundId, result.resolution, result.spec); + transition(active.intentionId, "suspended", 2); + reopen(); + expect(store.getSelectionSpec(snapshot.roundId)).toEqual(result.spec); +}); diff --git a/packages/lina-core/test/judgment-round.test.ts b/packages/lina-core/test/judgment-round.test.ts index df1ef90..a61df11 100644 --- a/packages/lina-core/test/judgment-round.test.ts +++ b/packages/lina-core/test/judgment-round.test.ts @@ -5,9 +5,11 @@ import { join } from "node:path"; import { type Assessment, assessmentInputDigest, + buildCandidateSet, buildCanonicalOption, type CanonicalOption, type IntentionRecord, + intentionDigest, type JudgmentSnapshotRef, JudgmentStore, judgmentDigest, @@ -150,7 +152,7 @@ function canonicalOptions() { kind: "task.start", authorityRef: "authority-1", taskText: "Start fixture task", - intentionId: "intention-1", + intentionId: "intention-start", }, }); const inquire = buildCanonicalOption({ @@ -328,16 +330,45 @@ function playRound(store: JudgmentStore, situation: Situation) { ); for (const assessment of assessments) store.putAssessment(assessment); const set = store.assessmentSet(snapshot.roundId); - const resolved = resolvePersonalRound({ - policy: PERSONAL_POLICY_V1, - snapshot, + store.putIntention({ + ...proposedIntention(), + intentionId: "intention-start", + }); + const startIntention = store.transitionIntention( + "intention-start", + { + to: "adopted", + reason: "existing task intention", + evidenceRef: "source-1", + at: PROJECTED_AT, + }, + 0, + ); + const closed = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), options: candidates, - set, eligibility: candidates.map((option) => ({ optionKey: option.optionKey, eligible: true, reason: null, })), + intentionRefs: [ + { + intentionId: startIntention.intentionId, + revision: startIntention.revision, + status: startIntention.status, + digest: intentionDigest(startIntention), + }, + ], + }); + store.closeCandidateSet(closed); + const resolved = resolvePersonalRound({ + policy: PERSONAL_POLICY_V1, + snapshot, + options: candidates, + set, + eligibility: closed.eligibility, bias: {}, }); store.recordResolution(snapshot.roundId, resolved.resolution, resolved.spec); diff --git a/packages/lina-core/test/judgment-store-digest-review.test.ts b/packages/lina-core/test/judgment-store-digest-review.test.ts index 47a223e..192d80e 100644 --- a/packages/lina-core/test/judgment-store-digest-review.test.ts +++ b/packages/lina-core/test/judgment-store-digest-review.test.ts @@ -6,6 +6,8 @@ import { DatabaseSync } from "node:sqlite"; import { type Assessment, assessmentInputDigest, + buildCandidateSet, + buildCanonicalOption, type IntentionRecord, type IntentionTransition, type JudgmentSnapshotRef, @@ -93,6 +95,26 @@ function snapshot( bindingGeneration: 0, }); } +const optionA = buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent-1", scopeId: "scope-1" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "fixture" }, +}); +const A = optionA.optionKey; +function closeCandidates(store: JudgmentStore, ref: JudgmentSnapshotRef) { + store.closeCandidateSet( + buildCandidateSet({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + options: [optionA], + eligibility: [{ optionKey: A, eligible: true, reason: null }], + intentionRefs: [], + }), + ); +} + function assessment( ref: JudgmentSnapshotRef, moduleKind: ModuleKind, @@ -111,10 +133,10 @@ function assessment( inputDigest: assessmentInputDigest(input), completeText: "Fixture assessment", evidenceRefs: [], - proposedOptionKeys: ["a"], + proposedOptionKeys: [A], objectiveAssessments: [ { - optionKey: "a", + optionKey: A, stance: "prefer", severity: null, unavailableReason: null, @@ -124,7 +146,7 @@ function assessment( evidenceRefs: [], }, ], - recommendedOptionKeys: ["a"], + recommendedOptionKeys: [A], detail: { kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ moduleKind @@ -145,11 +167,11 @@ function resolution( policyRevision: 1, situation: ref.situation, order: ["atropos", "clotho", "lachesis"], - recommendations: { clotho: ["a"], lachesis: ["a"], atropos: ["a"] }, + recommendations: { clotho: [A], lachesis: [A], atropos: [A] }, conflicts: [], excluded: [], abstentions: [], - ranking: [{ optionKey: "a", rank: 1 }], + ranking: [{ optionKey: A, rank: 1 }], conceded: [], status, holdReason: status === "resolved" ? null : "no eligible candidate", @@ -177,8 +199,8 @@ function spec( policyRevision: record.policyRevision, situation: ref.situation, lambda: 0, - candidates: [{ optionKey: "a", p0: 1, b: null }], - eligibleDigest: judgmentDigest(["a"]), + candidates: [{ optionKey: A, p0: 1, b: null }], + eligibleDigest: judgmentDigest([A]), }; return parseSelectionSpec({ ...body, specDigest: judgmentDigest(body) }); } @@ -221,6 +243,7 @@ function transition( function seed() { const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const module of MODULE_KINDS) store.putAssessment(assessment(ref, module)); store.putIntention(intention()); @@ -347,7 +370,7 @@ for (const tamper of ["body", "digest"] as const) { store.recordResolution(ref.roundId, record, selection); const changedRecord = parseResolutionRecord({ ...record, - ranking: [{ optionKey: "a", rank: 2 }], + ranking: [{ optionKey: A, rank: 2 }], }); // Keep the embedded selection digest valid: only its persisted anchor is stale. const changedSelection = rehashSpec({ ...selection, lambda: 1 }); diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index 18a33c3..de247a7 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -6,6 +6,9 @@ import { DatabaseSync } from "node:sqlite"; import { type Assessment, assessmentInputDigest, + buildCandidateSet, + buildCanonicalOption, + type CanonicalOption, type IntentionRecord, type IntentionTransition, JUDGMENT_SCHEMA_VERSION, @@ -107,6 +110,43 @@ function snapshot( bindingGeneration: 0, }); } +const fixtureOptions = ["a", "b", "excluded"] + .map((reason) => + buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent-1", scopeId: "scope-1" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason }, + }), + ) + .sort((a, b) => (a.optionKey < b.optionKey ? -1 : 1)); +const [optionA, optionB, optionExcluded] = fixtureOptions; +if (!optionA || !optionB || !optionExcluded) + throw Error("missing fixture options"); +const A = optionA.optionKey, + B = optionB.optionKey, + EXCLUDED = optionExcluded.optionKey; +function closeCandidates( + store: JudgmentStore, + ref: JudgmentSnapshotRef, + options: CanonicalOption[] = fixtureOptions.slice(0, 1), +) { + store.closeCandidateSet( + buildCandidateSet({ + roundId: ref.roundId, + snapshotDigest: snapshotDigest(ref), + options, + eligibility: options.map((o) => ({ + optionKey: o.optionKey, + eligible: o.optionKey !== EXCLUDED, + reason: o.optionKey === EXCLUDED ? "not eligible" : null, + })), + intentionRefs: [], + }), + ); +} + function assessment( ref: JudgmentSnapshotRef, moduleKind: ModuleKind, @@ -125,10 +165,10 @@ function assessment( inputDigest: assessmentInputDigest(input), completeText: "Fixture assessment", evidenceRefs: [], - proposedOptionKeys: ["a"], + proposedOptionKeys: [A], objectiveAssessments: [ { - optionKey: "a", + optionKey: A, stance: "prefer", severity: null, unavailableReason: null, @@ -138,7 +178,7 @@ function assessment( evidenceRefs: [], }, ], - recommendedOptionKeys: ["a"], + recommendedOptionKeys: [A], detail: { kind: { clotho: "forecasts", lachesis: "values", atropos: "continuity" }[ moduleKind @@ -159,11 +199,11 @@ function resolution( policyRevision: 1, situation: ref.situation, order: ["atropos", "clotho", "lachesis"], - recommendations: { clotho: ["a"], lachesis: ["a"], atropos: ["a"] }, + recommendations: { clotho: [A], lachesis: [A], atropos: [A] }, conflicts: [], excluded: [], abstentions: [], - ranking: [{ optionKey: "a", rank: 1 }], + ranking: [{ optionKey: A, rank: 1 }], conceded: [], status, holdReason: status === "resolved" ? null : "no eligible candidate", @@ -191,8 +231,8 @@ function spec( policyRevision: record.policyRevision, situation: ref.situation, lambda: 0, - candidates: [{ optionKey: "a", p0: 1, b: null }], - eligibleDigest: judgmentDigest(["a"]), + candidates: [{ optionKey: A, p0: 1, b: null }], + eligibleDigest: judgmentDigest([A]), }; return parseSelectionSpec({ ...body, specDigest: judgmentDigest(body) }); } @@ -242,6 +282,61 @@ function canonical(value: unknown): unknown { return value; } +for (const stage of [ + "infeasible", + "commitment_protection", + "host_eligibility", +] as const) { + test(`3996172412: recomputed forged ${stage} exclusion is rejected by the existing store API`, () => { + const store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB]); + for (const module of MODULE_KINDS) { + const original = assessment(ref, module); + store.putAssessment( + parseAssessment({ + ...original, + objectiveAssessments: [A, B].map((optionKey) => ({ + ...original.objectiveAssessments[0], + optionKey, + stance: "accept", + })), + }), + ); + } + const record = parseResolutionRecord({ + ...resolution(ref), + excluded: [ + { + optionKey: B, + stage, + byModule: + stage === "infeasible" + ? "clotho" + : stage === "commitment_protection" + ? "atropos" + : null, + reason: "forged exclusion", + }, + ], + }); + const selection = rehashSelection({ + ...spec(ref, record), + assessmentSetDigest: judgmentDigest(store.assessmentSet(ref.roundId)), + candidates: [ + { optionKey: A, p0: 1, b: null }, + { optionKey: B, p0: 0, b: null }, + ], + }); + expect(() => + store.recordResolution(ref.roundId, record, selection), + ).toThrow(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + }); +} + test("canonical JSON bytes are shared with judgment digests", () => { const value = { z: [ @@ -266,7 +361,7 @@ test("canonical JSON bytes are shared with judgment digests", () => { }); }); -test("fresh schema has exactly nine STRICT tables, metadata, WAL and unchanged DDL on reopen", () => { +test("fresh schema has exactly ten STRICT tables, metadata, WAL and unchanged DDL on reopen", () => { let store = open(); const db = database(); const original = ddl(db); @@ -277,6 +372,7 @@ test("fresh schema has exactly nine STRICT tables, metadata, WAL and unchanged D }); expect(original.map(({ name }) => name)).toEqual([ "assessments", + "candidate_sets", "intention_records", "intention_transitions", "judgment_meta", @@ -314,6 +410,7 @@ for (const change of [ for (const change of [ "ALTER TABLE rounds ADD COLUMN extra TEXT", + "DROP TABLE candidate_sets", "UPDATE judgment_meta SET value='other' WHERE key='store'", "UPDATE judgment_meta SET value='2' WHERE key='schema_version'", "DELETE FROM judgment_meta WHERE key='store'", @@ -415,6 +512,7 @@ for (const tamper of ["snapshot", "snapshot_digest"] as const) { let store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); close(store); const db = database(); db.prepare(`UPDATE rounds SET ${tamper} = ? WHERE round_id = ?`).run( @@ -453,6 +551,7 @@ test("3995117504: snapshot digest is checked after parser normalization", () => { kind: "request", id: "b", revision: 1 }, ]; store.openRound(ref); + closeCandidates(store, ref); database() .prepare("UPDATE rounds SET snapshot = ? WHERE round_id = ?") .run( @@ -509,6 +608,7 @@ for (const change of [ const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); const original = assessment(ref, "clotho"); const changed = { ...original, @@ -538,6 +638,7 @@ for (const status of ["resolved", "held", "deferred"] as const) { const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); const record = { ...resolution(ref, status), ...change }; expect(() => @@ -558,6 +659,7 @@ test("resolved round requires all three persisted assessments", () => { const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); store.putAssessment(assessment(ref, "clotho")); expect(() => store.recordResolution(ref.roundId, resolution(ref), spec(ref)), @@ -617,6 +719,7 @@ for (const { label, change, error } of selectionMismatches) { const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); const selection = spec(ref); const changed = { ...selection, ...change(selection) }; @@ -641,6 +744,7 @@ test("assessment and selection use frozen refs after objective activation change const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); const next = store.putObjectiveProfile(profile("clotho", 2)); store.activateObjectiveProfile(ref.agentId, ref.scopeId, next); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); @@ -657,6 +761,7 @@ test("resolution and selection persist atomically and survive reopen with canoni let store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); const record = resolution(ref); const selection = spec(ref); @@ -681,6 +786,7 @@ test("resolution and selection persist atomically and survive reopen with canoni ["objective_profiles", "body"], ["rounds", "snapshot"], ["assessments", "body"], + ["candidate_sets", "body"], ["resolution_records", "body"], ["selection_specs", "body"], ["intention_records", "body"], @@ -701,6 +807,7 @@ test("resolution rejects mismatched selection presence, round id and snapshot wi const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); const other = { ...ref, roundId: "other" }; const db = database(); for (const [record, selection] of [ @@ -724,8 +831,8 @@ test("resolution rejects mismatched selection presence, round id and snapshot wi expect(() => store.recordResolution(ref.roundId, resolution(other), spec(ref)), ).toThrow(); - store.recordResolution(ref.roundId, resolution(ref, "deferred"), null); - expect(store.getRound(ref.roundId)?.status).toBe("deferred"); + store.recordResolution(ref.roundId, resolution(ref, "held"), null); + expect(store.getRound(ref.roundId)?.status).toBe("held"); expect(store.getSelectionSpec(ref.roundId)).toBeNull(); expect(store.getResolution("missing")).toBeNull(); }); @@ -734,6 +841,7 @@ test("SQLite failure during resolution rolls back the already inserted resolutio const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); const db = database(); db.exec( @@ -825,6 +933,7 @@ test("external records are reparsed and malformed persisted bodies are rejected const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const action of [ () => store.putObjectiveProfile({ @@ -894,6 +1003,8 @@ test("every public method rejects use after close", () => { () => store.activeObjectiveProfiles("agent-1", "scope-1"), () => store.openRound(ref), () => store.getRound(ref.roundId), + () => store.candidateSet(ref.roundId), + () => closeCandidates(store, ref), () => store.putAssessment(assessment(ref, "clotho")), () => store.assessmentSet(ref.roundId), () => store.recordResolution(ref.roundId, resolution(ref), spec(ref)), @@ -933,6 +1044,7 @@ for (const reopen of [false, true]) { let store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); store.recordResolution(ref.roundId, resolution(ref), spec(ref)); store.putIntention(intention()); @@ -1009,35 +1121,36 @@ test("3995958855: valid hashes cannot substitute a candidate", () => { const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); const record = resolution(ref); const original = spec(ref); for (const candidates of [ - [{ optionKey: "forged", p0: 1, b: null }], + [{ optionKey: "zz-forged", p0: 1, b: null }], [ - { optionKey: "a", p0: 1, b: null }, - { optionKey: "forged", p0: 0, b: null }, + { optionKey: A, p0: 1, b: null }, + { optionKey: "zz-forged", p0: 0, b: null }, ], ]) { const forged = rehashSelection({ ...original, candidates }); expect(() => store.recordResolution(ref.roundId, record, forged)).toThrow( - "selection candidate universe mismatch", + "selection policy replay mismatch", ); expect(store.getResolution(ref.roundId)).toBeNull(); expect(store.getRound(ref.roundId)?.status).toBe("open"); } const forgedRecord = parseResolutionRecord({ ...record, - ranking: [{ optionKey: "forged", rank: 1 }], + ranking: [{ optionKey: "zz-forged", rank: 1 }], }); const forged = rehashSelection({ ...original, resolutionDigest: judgmentDigest(forgedRecord), - candidates: [{ optionKey: "forged", p0: 1, b: null }], + candidates: [{ optionKey: "zz-forged", p0: 1, b: null }], }); expect(() => store.recordResolution(ref.roundId, forgedRecord, forged), - ).toThrow("selection candidate universe mismatch"); + ).toThrow("resolution policy replay mismatch"); store.recordResolution(ref.roundId, record, original); }); @@ -1045,6 +1158,7 @@ test("3995958855: baseline uses declared ratio and retains unassessed host exclu let store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB, optionExcluded]); for (const m of MODULE_KINDS) { const original = assessment(ref, m); store.putAssessment( @@ -1054,7 +1168,7 @@ test("3995958855: baseline uses declared ratio and retains unassessed host exclu ...original.objectiveAssessments, { ...original.objectiveAssessments[0], - optionKey: "b", + optionKey: B, stance: "accept", }, ], @@ -1064,12 +1178,12 @@ test("3995958855: baseline uses declared ratio and retains unassessed host exclu const record = parseResolutionRecord({ ...resolution(ref), ranking: [ - { optionKey: "a", rank: 1 }, - { optionKey: "b", rank: 2 }, + { optionKey: A, rank: 1 }, + { optionKey: B, rank: 2 }, ], excluded: [ { - optionKey: "excluded", + optionKey: EXCLUDED, stage: "host_eligibility", byModule: null, reason: "not eligible", @@ -1077,9 +1191,9 @@ test("3995958855: baseline uses declared ratio and retains unassessed host exclu ], }); const candidates = [ - { optionKey: "a", p0: 2 / 3, b: null }, - { optionKey: "b", p0: 1 / 3, b: null }, - { optionKey: "excluded", p0: 0, b: null }, + { optionKey: A, p0: 2 / 3, b: null }, + { optionKey: B, p0: 1 / 3, b: null }, + { optionKey: EXCLUDED, p0: 0, b: null }, ]; const selection = rehashSelection({ ...spec(ref, record), @@ -1090,21 +1204,21 @@ test("3995958855: baseline uses declared ratio and retains unassessed host exclu { candidates: candidates.map((c) => ({ ...c, - p0: c.optionKey === "excluded" ? 0 : 0.5, + p0: c.optionKey === EXCLUDED ? 0 : 0.5, })), }, { candidates: candidates.map((c) => ({ ...c, - p0: c.optionKey === "excluded" ? 0.1 : 0.45, + p0: c.optionKey === EXCLUDED ? 0.1 : 0.45, })), }, - { candidates: candidates.filter((c) => c.optionKey !== "excluded") }, + { candidates: candidates.filter((c) => c.optionKey !== EXCLUDED) }, { lambda: 1 }, ]) { const invalid = rehashSelection({ ...selection, ...altered }); expect(() => store.recordResolution(ref.roundId, record, invalid)).toThrow( - /selection (baseline|candidate universe|policy) mismatch/, + "selection policy replay mismatch", ); expect(store.getResolution(ref.roundId)).toBeNull(); } @@ -1180,6 +1294,7 @@ test("3995958855: rehashed ranking must reflect persisted stances", () => { const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB]); for (const module of MODULE_KINDS) { const original = assessment(ref, module); store.putAssessment( @@ -1189,7 +1304,7 @@ test("3995958855: rehashed ranking must reflect persisted stances", () => { ...original.objectiveAssessments, { ...original.objectiveAssessments[0], - optionKey: "b", + optionKey: B, stance: "accept", }, ], @@ -1198,16 +1313,16 @@ test("3995958855: rehashed ranking must reflect persisted stances", () => { } for (const ranking of [ [ - { optionKey: "b", rank: 1 }, - { optionKey: "a", rank: 2 }, + { optionKey: B, rank: 1 }, + { optionKey: A, rank: 2 }, ], [ - { optionKey: "a", rank: 1 }, - { optionKey: "b", rank: 1 }, + { optionKey: A, rank: 1 }, + { optionKey: B, rank: 1 }, ], [ - { optionKey: "a", rank: 2 }, - { optionKey: "b", rank: 3 }, + { optionKey: A, rank: 2 }, + { optionKey: B, rank: 3 }, ], ]) { const record = parseResolutionRecord({ ...resolution(ref), ranking }); @@ -1215,13 +1330,13 @@ test("3995958855: rehashed ranking must reflect persisted stances", () => { ...spec(ref, record), assessmentSetDigest: judgmentDigest(store.assessmentSet(ref.roundId)), candidates: [ - { optionKey: "a", p0: 0.5, b: null }, - { optionKey: "b", p0: 0.5, b: null }, + { optionKey: A, p0: 0.5, b: null }, + { optionKey: B, p0: 0.5, b: null }, ], }); expect(() => store.recordResolution(ref.roundId, record, selection), - ).toThrow("selection ranking mismatch"); + ).toThrow("resolution policy replay mismatch"); expect(store.getRound(ref.roundId)?.status).toBe("open"); } }); @@ -1230,24 +1345,25 @@ test("3995958855: missing ranked assessments reject without inventing excluded c const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref, [optionA, optionB]); for (const module of MODULE_KINDS) store.putAssessment(assessment(ref, module)); const record = parseResolutionRecord({ ...resolution(ref), ranking: [ - { optionKey: "a", rank: 1 }, - { optionKey: "b", rank: 2 }, + { optionKey: A, rank: 1 }, + { optionKey: B, rank: 2 }, ], }); const selection = rehashSelection({ ...spec(ref, record), candidates: [ - { optionKey: "a", p0: 2 / 3, b: null }, - { optionKey: "b", p0: 1 / 3, b: null }, + { optionKey: A, p0: 2 / 3, b: null }, + { optionKey: B, p0: 1 / 3, b: null }, ], }); expect(() => store.recordResolution(ref.roundId, record, selection)).toThrow( - "selection missing ranked assessment", + "resolution policy replay mismatch", ); expect(store.getSelectionSpec(ref.roundId)).toBeNull(); }); @@ -1257,6 +1373,7 @@ for (const reopen of [false, true]) { let store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const module of MODULE_KINDS) store.putAssessment(assessment(ref, module)); const record = resolution(ref); @@ -1264,7 +1381,7 @@ for (const reopen of [false, true]) { store.recordResolution(ref.roundId, record, selection); const forged = rehashSelection({ ...selection, - candidates: [{ optionKey: "forged", p0: 1, b: null }], + candidates: [{ optionKey: "zz-forged", p0: 1, b: null }], }); database() .prepare("UPDATE selection_specs SET body = ?, spec_digest = ?") @@ -1274,7 +1391,7 @@ for (const reopen of [false, true]) { store = open(); } expect(() => store.getSelectionSpec(ref.roundId)).toThrow( - "selection candidate universe mismatch", + "selection policy replay mismatch", ); }); } @@ -1283,6 +1400,7 @@ test("3995958855: no baseline is guessed for undeclared policy revisions", () => const store = open(); const ref = { ...snapshot(store), policyRevision: 2 }; store.openRound(ref); + closeCandidates(store, ref); for (const module of MODULE_KINDS) store.putAssessment(assessment(ref, module)); const record = parseResolutionRecord({ @@ -1294,14 +1412,17 @@ test("3995958855: no baseline is guessed for undeclared policy revisions", () => policyRevision: 2, }); expect(() => store.recordResolution(ref.roundId, record, selection)).toThrow( - "unsupported selection policy declaration", + "policy snapshot mismatch", ); - store.recordResolution( - ref.roundId, - { ...record, status: "held", holdReason: "no policy declaration" }, - null, - ); - expect(store.getRound(ref.roundId)?.status).toBe("held"); + expect(() => + store.recordResolution( + ref.roundId, + { ...record, status: "held", holdReason: "no policy declaration" }, + null, + ), + ).toThrow("policy snapshot mismatch"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); }); test("missing parent is private; directory and database symlinks are rejected", () => { @@ -1323,6 +1444,7 @@ test("ledger audit rejects a resolved round whose selection was deleted", () => const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); for (const module of MODULE_KINDS) store.putAssessment(assessment(ref, module)); store.recordResolution(ref.roundId, resolution(ref), spec(ref)); @@ -1336,6 +1458,7 @@ test("ledger audit rejects orphaned assessments before unrelated reads", () => { const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); store.putAssessment(assessment(ref, "clotho")); const db = database(); db.exec("PRAGMA foreign_keys = OFF; DELETE FROM rounds"); @@ -1353,6 +1476,7 @@ for (const change of [ const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); store.recordResolution(ref.roundId, resolution(ref, "held"), null); const changed = { ...resolution(ref, "held"), ...change }; database() @@ -1368,6 +1492,7 @@ test("ledger audit retains the historical objective referenced by a round", () = const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); const next = store.putObjectiveProfile(profile("clotho", 2)); store.activateObjectiveProfile(ref.agentId, ref.scopeId, next); database().exec( @@ -1382,6 +1507,7 @@ test("ledger audit binds rehashed assessment evidence to its frozen snapshot", ( const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); store.putAssessment(assessment(ref, "clotho")); const changed = assessment(ref, "clotho", "b".repeat(64)); database() @@ -1403,6 +1529,7 @@ test("ledger audit binds rehashed assessment objectives to the frozen module", ( const store = open(); const ref = snapshot(store); store.openRound(ref); + closeCandidates(store, ref); store.putAssessment(assessment(ref, "clotho")); const changed = { ...assessment(ref, "clotho"), From dfadcead44101abe68b8cb1bc254290beabe70b3 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:31:29 +0900 Subject: [PATCH 36/47] test(runtime): finalize SQLite state before binding restart Settle closed SQLite handles before the fixture reopens and audits persisted LIFE storage. Force collection at the private-copy audit boundary to reproduce the 503 regression while preserving real HTTP, CAS, persistence and session-isolation assertions. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../test/life-binding-routes-fixture.test.ts | 3 +++ .../test/life-binding-routes.test.ts | 22 +++++++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/lina-runtime/test/life-binding-routes-fixture.test.ts b/packages/lina-runtime/test/life-binding-routes-fixture.test.ts index 786400b..6322054 100644 --- a/packages/lina-runtime/test/life-binding-routes-fixture.test.ts +++ b/packages/lina-runtime/test/life-binding-routes-fixture.test.ts @@ -57,6 +57,8 @@ export async function bindingFixture() { }, async reopen() { await server.stop(); + // Model process-exit finalization before the replacement's file audit. + Bun.gc(true); fleet = open(); server = await startFleetServer(fleet, 0, process.cwd(), "lina", { lazy: true, @@ -64,6 +66,7 @@ export async function bindingFixture() { }, async close() { await server.stop(); + Bun.gc(true); rmSync(root, { recursive: true, force: true }); }, }; diff --git a/packages/lina-runtime/test/life-binding-routes.test.ts b/packages/lina-runtime/test/life-binding-routes.test.ts index 698da8f..6346c29 100644 --- a/packages/lina-runtime/test/life-binding-routes.test.ts +++ b/packages/lina-runtime/test/life-binding-routes.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; +import { WorldStore } from "../../lina-core/src/world/store.ts"; import { activateSocialPack } from "../../lina-core/test/life-social-store-fixture.ts"; import { authorPack } from "./life-authoring-fixture.ts"; import { @@ -24,7 +25,24 @@ test("protected binding HTTP persists explicit V2, checks CAS and reopens withou (await putBinding(f.url, { expectedRevision: 0, selection })).status, ).toBe(409); await f.reopen(); - expect(await (await fetch(f.url)).json()).toEqual(expected); + // Collect at the private-copy audit boundary, not at a lucky heap threshold. + const close = WorldStore.prototype.close; + const collect = spyOn(WorldStore.prototype, "close").mockImplementation( + function (this: WorldStore) { + close.call(this); + Bun.gc(true); + }, + ); + try { + const response = await fetch(f.url); + expect({ status: response.status, body: await response.json() }).toEqual({ + status: 200, + body: expected, + }); + expect(collect).toHaveBeenCalled(); + } finally { + collect.mockRestore(); + } const growthOnly = { ...selection, conversationRecipientId: null }; expect( await ( From 46b4b77734f37163197070e358287c62f6bd1fc7 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:00:01 +0900 Subject: [PATCH 37/47] fix(core): require canonical intention timestamps Reject noncanonical, local, and impossible intention dates without rewriting digest-bearing evidence. Preserve exact UTC timestamps across acceptance, deadlines, and transitions. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../src/agents/judgment-validation.ts | 4 ++- .../lina-core/test/judgment-contract.test.ts | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 66a1ecf..36661a9 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -89,7 +89,9 @@ function nullableText(value: unknown, label: string): string | null { function timestamp(value: unknown, label: string): string { const text = boundedText(value, label); - if (!Number.isFinite(Date.parse(text))) throw Error(`invalid ${label}`); + const time = Date.parse(text); + if (!Number.isFinite(time) || new Date(time).toISOString() !== text) + throw Error(`invalid ${label}`); return text; } diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 77357e2..34c10ae 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -926,6 +926,42 @@ test("intention transitions require outcome, original acceptance and nonempty re ).toThrow(); }); +for (const value of [ + "September 10, 2026", + "2026-09-10T00:00:00", + "2026-02-30T00:00:00.000Z", + "2026-09-10T01:00:00.000+01:00", + "2026-09-10T00:00:00Z", + " 2026-09-10T00:00:00.000Z ", +]) { + test(`3995355457 canonical intention timestamps reject ${value}`, () => { + expect(() => + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, acceptedAt: value }, + }), + ).toThrow(); + expect(() => + parseIntentionRecord({ ...intention, deadline: value }), + ).toThrow(); + expect(() => + parseIntentionTransition({ ...transition, at: value }), + ).toThrow(); + }); +} +test("3995355457 canonical UTC leap day is retained exactly", () => { + const value = "2024-02-29T00:00:00.000Z"; + expect(parseIntentionRecord({ ...intention, deadline: value }).deadline).toBe( + value, + ); + expect( + parseIntentionRecord({ + ...intention, + acceptance: { ...intention.acceptance, acceptedAt: value }, + }).acceptance.acceptedAt, + ).toBe(value); + expect(parseIntentionTransition({ ...transition, at: value }).at).toBe(value); +}); test("intention history revision, continuity, edges, final status and timestamps are validated", () => { const active = activate(); for (const change of [ From 7eac3fe9037f11136dd00dd5b9ef87b7b9d5b945 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:26:59 +0900 Subject: [PATCH 38/47] fix(core): verify arbitration evidence and currentness Require owner-verified commitment attribution and Host eligibility for factual exclusions. Declare exact assessment unavailability, replay frozen evidence, and reject stale objective/intention snapshots or backward scope sequences. Verified the isolated staged tree: 506 judgment tests pass and the public core barrel builds. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-evidence.ts | 118 +++++++++ .../lina-core/src/agents/judgment-policy.ts | 50 ++-- .../lina-core/src/agents/judgment-store.ts | 151 ++++-------- .../src/agents/judgment-validation.ts | 13 +- packages/lina-core/src/agents/judgment.ts | 10 +- .../test/judgment-candidates.test.ts | 16 +- .../lina-core/test/judgment-contract.test.ts | 31 ++- .../judgment-policy-commitment-fixture.ts | 82 +++++++ .../test/judgment-policy-evidence-fixture.ts | 226 ++++++++++++++++++ ...ment-policy-evidence-replay-review.test.ts | 78 ++++++ .../judgment-policy-evidence-review.test.ts | 222 +++++++++++++++++ .../test/judgment-policy-review.test.ts | 62 ++--- .../lina-core/test/judgment-policy.test.ts | 100 +++++--- .../lina-core/test/judgment-round.test.ts | 65 +++-- .../test/judgment-store-digest-review.test.ts | 37 ++- .../lina-core/test/judgment-store.test.ts | 201 +++++++++++++++- 16 files changed, 1252 insertions(+), 210 deletions(-) create mode 100644 packages/lina-core/src/agents/judgment-evidence.ts create mode 100644 packages/lina-core/test/judgment-policy-commitment-fixture.ts create mode 100644 packages/lina-core/test/judgment-policy-evidence-fixture.ts create mode 100644 packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts create mode 100644 packages/lina-core/test/judgment-policy-evidence-review.test.ts diff --git a/packages/lina-core/src/agents/judgment-evidence.ts b/packages/lina-core/src/agents/judgment-evidence.ts new file mode 100644 index 0000000..df34516 --- /dev/null +++ b/packages/lina-core/src/agents/judgment-evidence.ts @@ -0,0 +1,118 @@ +import type { + Assessment, + IntentionRecord, + JudgmentSnapshotRef, +} from "./judgment.ts"; +import type { CandidateSet } from "./judgment-candidates.ts"; +import { + INTENTION_TRANSITIONS, + intentionDigest, + parseIntentionRecord, + snapshotDigest, +} from "./judgment-validation.ts"; + +/** Owner lookup is trusted Host code, never a model-provided evidence claim. + * The same frozen evidence rules apply to closure, arbitration and store replay. + */ +export function validateCandidateEvidence( + set: CandidateSet, + snapshot: JudgmentSnapshotRef, + lookup: (id: string) => IntentionRecord | null, + assessments: Assessment[], + current: boolean, +): void { + if ( + set.roundId !== snapshot.roundId || + set.snapshotDigest !== snapshotDigest(snapshot) + ) + throw Error("candidate snapshot mismatch"); + const referenced = new Map(); + for (const ref of set.intentionRefs) { + const record = lookup(ref.intentionId); + if ( + !record || + record.agentId !== snapshot.agentId || + record.scopeId !== snapshot.scopeId + ) + throw Error("candidate intention scope or reference mismatch"); + if ( + record.revision < ref.revision || + (current && record.revision !== ref.revision) + ) + throw Error("stale candidate intention revision"); + // This owner changes only status/revision/history. Preserve every original + // field while reconstructing the frozen revision from validated history. + const historical = parseIntentionRecord({ + ...record, + revision: ref.revision, + status: ref.status, + history: record.history.slice(0, ref.revision), + }); + if (intentionDigest(historical) !== ref.digest) + throw Error("candidate intention digest mismatch"); + referenced.set(ref.intentionId, historical); + } + const eligible = new Set( + set.eligibility.filter((r) => r.eligible).map((r) => r.optionKey), + ); + for (const option of set.options) { + if ( + option.actor.agentId !== snapshot.agentId || + option.actor.scopeId !== snapshot.scopeId + ) + throw Error("candidate actor snapshot mismatch"); + const precondition = option.preconditions; + if (!eligible.has(option.optionKey) || !("intentionId" in precondition)) + continue; + const record = referenced.get(precondition.intentionId); + if (!record) throw Error("missing candidate intention ref"); + if ( + "acceptanceSourceRef" in precondition && + precondition.acceptanceSourceRef !== record.acceptance.sourceRef + ) + throw Error("candidate original acceptance mismatch"); + if (precondition.kind === "task.start") continue; + if (option.targetId !== record.intentionId) + throw Error("candidate intention target mismatch"); + const to = ( + { + "intention.activate": "active", + "intention.resume": "active", + "intention.suspend": "suspended", + "intention.cancel": "cancelled", + "intention.complete": "completed", + } as const + )[precondition.kind]; + if ( + !INTENTION_TRANSITIONS[record.status].includes(to) || + (precondition.kind === "intention.activate" && + record.status !== "adopted") || + (precondition.kind === "intention.resume" && + record.status !== "suspended") + ) + throw Error("illegal candidate intention transition"); + } + const keys = new Set(set.options.map((o) => o.optionKey)); + for (const assessment of assessments) { + if ( + [ + ...assessment.proposedOptionKeys, + ...assessment.recommendedOptionKeys, + ...assessment.objectiveAssessments.map((o) => o.optionKey), + ].some((key) => !keys.has(key)) + ) + throw Error("unknown assessment option key"); + if (assessment.moduleKind !== "atropos") continue; + for (const opinion of assessment.objectiveAssessments) { + for (const id of opinion.breachedIntentionIds ?? []) { + const record = referenced.get(id); + if ( + record?.kind !== "user_commitment" || + record.acceptance.acceptedBy !== "user" || + !["adopted", "active", "suspended"].includes(record.status) + ) + throw Error("missing protected commitment evidence"); + } + } + } +} diff --git a/packages/lina-core/src/agents/judgment-policy.ts b/packages/lina-core/src/agents/judgment-policy.ts index c52e87e..b565001 100644 --- a/packages/lina-core/src/agents/judgment-policy.ts +++ b/packages/lina-core/src/agents/judgment-policy.ts @@ -1,6 +1,9 @@ import { isDeepStrictEqual } from "node:util"; import { + ASSESSMENT_UNAVAILABLE_REASONS, type AssessmentSet, + type AssessmentUnavailableReason, + type IntentionRecord, type JudgmentSnapshotRef, MODULE_KINDS, type ModuleKind, @@ -11,10 +14,12 @@ import { type Situation, type Stance, } from "./judgment.ts"; +import { type CandidateSet, parseCandidateSet } from "./judgment-candidates.ts"; import { type CanonicalOption, parseCanonicalOption, } from "./judgment-catalog.ts"; +import { validateCandidateEvidence } from "./judgment-evidence.ts"; import { judgmentDigest, parseAssessmentSet, @@ -31,6 +36,7 @@ export type ArbitrationPolicy = Readonly<{ orders: Readonly>; lambda: Readonly>; stanceOrder: readonly Stance[]; + unavailableReasons: readonly AssessmentUnavailableReason[]; }>; export const PERSONAL_POLICY_V1: ArbitrationPolicy = Object.freeze({ policyId: "personal.v1", @@ -43,6 +49,7 @@ export const PERSONAL_POLICY_V1: ArbitrationPolicy = Object.freeze({ }), lambda: Object.freeze({ user_request: 0, autonomous: 1, transition: 1 }), stanceOrder: Object.freeze(["prefer", "accept", "oppose"] as const), + unavailableReasons: Object.freeze([...ASSESSMENT_UNAVAILABLE_REASONS]), }); export type HostEligibility = Array<{ optionKey: OptionKey; @@ -82,6 +89,11 @@ export function resolvePersonalRound(input: { set: AssessmentSet; eligibility: HostEligibility; bias: Record; + /** Only Host code supplies the owner lookup; assessment IDs alone are claims. */ + evidence?: { + candidates: CandidateSet; + lookupIntention: (id: string) => IntentionRecord | null; + }; }): { resolution: ResolutionRecord; spec: SelectionSpec | null } { const { policy } = input; const snapshot = parseJudgmentSnapshotRef(input.snapshot); @@ -132,6 +144,21 @@ export function resolvePersonalRound(input: { ) throw Error("unknown assessment option key"); } + if (input.evidence) { + const candidates = parseCandidateSet(input.evidence.candidates); + if ( + !isDeepStrictEqual(candidates.options, options) || + !isDeepStrictEqual(candidates.eligibility, input.eligibility) + ) + throw Error("candidate evidence input mismatch"); + validateCandidateEvidence( + candidates, + snapshot, + input.evidence.lookupIntention, + set.assessments, + false, + ); + } const order = [...policy.orders[snapshot.situation]]; const record: ResolutionRecord = { schemaVersion: 1, @@ -172,7 +199,9 @@ export function resolvePersonalRound(input: { optionKey: option.optionKey, stage: "host_eligibility", byModule: null, - reason: row?.reason ?? "missing host eligibility", + reason: row + ? (row.reason ?? "host ineligible") + : "missing host eligibility", }); const opinions: Partial> = {}; for (const assessment of set.assessments) { @@ -213,19 +242,22 @@ export function resolvePersonalRound(input: { } if (missing !== null) return finishWithoutSpec("held", missing); - // 3-4: Only the named module/severity can exclude at each stage. + // 3-4: Protected commitments need owner-verified attribution. Factual + // prerequisite failures are Host eligibility; Clotho's label alone is not proof. const remaining = eligible.filter(({ option, opinions }) => { const precondition = option.preconditions; const breached = opinions.atropos.breachedIntentionIds; const changesAcceptance = (precondition.kind === "intention.suspend" || precondition.kind === "intention.cancel") && - precondition.acceptanceSourceRef.trim() !== "" && breached?.length === 1 && breached[0] === precondition.intentionId; if ( opinions.atropos.stance === "oppose" && opinions.atropos.severity === "commitment_breach" && + input.evidence && + breached !== undefined && + breached.length > 0 && !changesAcceptance ) { record.excluded.push({ @@ -236,18 +268,6 @@ export function resolvePersonalRound(input: { }); return false; } - if ( - opinions.clotho.stance === "oppose" && - opinions.clotho.severity === "infeasible" - ) { - record.excluded.push({ - optionKey: option.optionKey, - stage: "infeasible", - byModule: "clotho", - reason: "infeasible precondition", - }); - return false; - } return true; }); // 9: No candidate means deferred, not an empty ordering or uniform fallback. diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index aba6353..ed83692 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -17,11 +17,11 @@ import { type SelectionSpec, } from "./judgment.ts"; import { type CandidateSet, parseCandidateSet } from "./judgment-candidates.ts"; +import { validateCandidateEvidence } from "./judgment-evidence.ts"; import { PERSONAL_POLICY_V1, resolvePersonalRound } from "./judgment-policy.ts"; import { initializeJudgmentSchema } from "./judgment-schema.ts"; import { canonicalJson, - INTENTION_TRANSITIONS, intentionDigest, judgmentDigest, parseAssessment, @@ -53,6 +53,7 @@ function validateCandidateBinding( set: AssessmentSet | null, snapshot: JudgmentSnapshotRef, candidates: CandidateSet | null, + lookupIntention: (id: string) => IntentionRecord | null, ): void { // Without complete evidence only a non-executable failure receipt is trusted. // Its descriptive fields are not policy-replayed; deferred is not a fallback. @@ -67,6 +68,7 @@ function validateCandidateBinding( options: candidates.options, eligibility: candidates.eligibility, set, + evidence: { candidates, lookupIntention }, bias: Object.fromEntries( selection?.candidates.map((c) => [c.optionKey, c.b]) ?? [], ), @@ -96,107 +98,6 @@ function validateCandidateBinding( throw Error("selection policy replay mismatch"); } -function validateCandidateEvidence( - set: CandidateSet, - snapshot: JudgmentSnapshotRef, - lookup: (id: string) => IntentionRecord | null, - assessments: Assessment[], - current: boolean, -): void { - if ( - set.roundId !== snapshot.roundId || - set.snapshotDigest !== snapshotDigest(snapshot) - ) - throw Error("candidate snapshot mismatch"); - const referenced = new Map(); - for (const ref of set.intentionRefs) { - const record = lookup(ref.intentionId); - if ( - !record || - record.agentId !== snapshot.agentId || - record.scopeId !== snapshot.scopeId - ) - throw Error("candidate intention scope or reference mismatch"); - if ( - record.revision < ref.revision || - (current && record.revision !== ref.revision) - ) - throw Error("stale candidate intention revision"); - // This owner changes only status/revision/history. Preserve every original - // field while reconstructing the frozen revision from validated history. - const historical = parseIntentionRecord({ - ...record, - revision: ref.revision, - status: ref.status, - history: record.history.slice(0, ref.revision), - }); - if (intentionDigest(historical) !== ref.digest) - throw Error("candidate intention digest mismatch"); - referenced.set(ref.intentionId, historical); - } - const eligible = new Set( - set.eligibility.filter((r) => r.eligible).map((r) => r.optionKey), - ); - for (const option of set.options) { - if ( - option.actor.agentId !== snapshot.agentId || - option.actor.scopeId !== snapshot.scopeId - ) - throw Error("candidate actor snapshot mismatch"); - const precondition = option.preconditions; - if (!eligible.has(option.optionKey) || !("intentionId" in precondition)) - continue; - const record = referenced.get(precondition.intentionId); - if (!record) throw Error("missing candidate intention ref"); - if ( - "acceptanceSourceRef" in precondition && - precondition.acceptanceSourceRef !== record.acceptance.sourceRef - ) - throw Error("candidate original acceptance mismatch"); - if (precondition.kind === "task.start") continue; - if (option.targetId !== record.intentionId) - throw Error("candidate intention target mismatch"); - const to = { - "intention.activate": "active", - "intention.resume": "active", - "intention.suspend": "suspended", - "intention.cancel": "cancelled", - "intention.complete": "completed", - }[precondition.kind] as IntentionStatus; - if ( - !INTENTION_TRANSITIONS[record.status].includes(to) || - (precondition.kind === "intention.activate" && - record.status !== "adopted") || - (precondition.kind === "intention.resume" && - record.status !== "suspended") - ) - throw Error("illegal candidate intention transition"); - } - const keys = new Set(set.options.map((o) => o.optionKey)); - for (const assessment of assessments) { - if ( - [ - ...assessment.proposedOptionKeys, - ...assessment.recommendedOptionKeys, - ...assessment.objectiveAssessments.map((o) => o.optionKey), - ].some((key) => !keys.has(key)) - ) - throw Error("unknown assessment option key"); - if (assessment.moduleKind !== "atropos") continue; - for (const opinion of assessment.objectiveAssessments) { - for (const id of opinion.breachedIntentionIds ?? []) { - const record = referenced.get(id); - if ( - record?.kind !== "user_commitment" || - record.acceptance.acceptedBy !== "user" || - !["adopted", "active", "suspended"].includes(record.status) - ) - throw Error("missing protected commitment evidence"); - } - } - } -} - /** The Host owns the single writer; this ledger is independent of session lifetimes. */ export class JudgmentStore { private readonly db: DatabaseSync; @@ -365,6 +266,21 @@ export class JudgmentStore { ); if (body(active) !== body(parsed.objectiveProfileRefs)) throw Error("stale objective profile refs"); + if ( + parsed.intentionRevision !== + this.intentionRevision(parsed.agentId, parsed.scopeId) + ) + throw Error("stale intention revision"); + const highWater = this.db + .prepare( + "SELECT MAX(sequence) AS sequence FROM rounds WHERE agent_id = ? AND scope_id = ?", + ) + .get(parsed.agentId, parsed.scopeId); + if ( + highWater?.["sequence"] !== null && + parsed.sequence <= Number(highWater?.["sequence"]) + ) + throw Error("stale round sequence"); const digest = snapshotDigest(parsed); const now = this.now(); this.db @@ -555,6 +471,20 @@ export class JudgmentStore { const selection = spec === null ? null : parseSelectionSpec(spec); this.transaction(() => { const round = this.requireOpenRound(id); + if ( + body( + this.activeObjectiveProfiles( + round.snapshot.agentId, + round.snapshot.scopeId, + ), + ) !== body(round.snapshot.objectiveProfileRefs) + ) + throw Error("stale objective profile refs"); + if ( + round.snapshot.intentionRevision !== + this.intentionRevision(round.snapshot.agentId, round.snapshot.scopeId) + ) + throw Error("stale intention revision"); if (parsed.roundId !== id) throw Error("resolution round mismatch"); if ( parsed.situation !== round.snapshot.situation || @@ -589,6 +519,7 @@ export class JudgmentStore { set, round.snapshot, candidates, + (id) => this.getIntention(id), ); const now = this.now(); this.db @@ -642,6 +573,19 @@ export class JudgmentStore { }, false); } + /** Scoped create/+1-transition mutation counter; no delete or scope-move API. + * Keep this invariant if future mutation APIs are introduced. */ + intentionRevision(agentId: string, scopeId: string): number { + return this.transaction(() => { + const row = this.db + .prepare( + "SELECT COALESCE(SUM(1 + revision), 0) AS revision FROM intention_records WHERE agent_id = ? AND scope_id = ?", + ) + .get(boundedId(agentId, "agent id"), boundedId(scopeId, "scope id")); + return revision(Number(row?.["revision"])); + }, false); + } + putIntention(record: IntentionRecord): void { this.assertOpen(); const parsed = parseIntentionRecord(record); @@ -654,6 +598,7 @@ export class JudgmentStore { this.transaction(() => { if (this.getIntention(parsed.intentionId)) throw Error("duplicate intention"); + revision(this.intentionRevision(parsed.agentId, parsed.scopeId) + 1); const now = this.now(); this.db .prepare( @@ -695,6 +640,7 @@ export class JudgmentStore { const updated = parseIntentionRecord( transitionIntention(record, transition), ); + revision(this.intentionRevision(record.agentId, record.scopeId) + 1); this.db .prepare( "UPDATE intention_records SET revision = ?, status = ?, digest = ?, body = ?, updated_at = ? WHERE intention_id = ?", @@ -1065,6 +1011,7 @@ export class JudgmentStore { set, snapshot, candidatesByRound.get(record.roundId) ?? null, + (id) => intentionsById.get(id) ?? null, ); } if (this.db.prepare("PRAGMA foreign_key_check").all().length > 0) diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 36661a9..89a0ff5 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { validId } from "../context/validation.ts"; import { ACCEPTED_BY, + ASSESSMENT_UNAVAILABLE_REASONS, type Assessment, type AssessmentSet, EXCLUSION_STAGES, @@ -372,10 +373,14 @@ export function parseOptionAssessment(value: unknown): OptionAssessment { row["severity"] === null ? null : enumeration(row["severity"], SEVERITIES, "severity"); - const unavailableReason = nullableText( - row["unavailableReason"], - "unavailable reason", - ); + const unavailableReason = + row["unavailableReason"] === null + ? null + : enumeration( + row["unavailableReason"], + ASSESSMENT_UNAVAILABLE_REASONS, + "unavailable reason", + ); if ((stance === "oppose") !== (severity !== null)) throw Error("severity requires oppose stance"); if ((stance === "unavailable") !== (unavailableReason !== null)) diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts index 9a06646..c749d9d 100644 --- a/packages/lina-core/src/agents/judgment.ts +++ b/packages/lina-core/src/agents/judgment.ts @@ -2,6 +2,12 @@ export const MODULE_KINDS = ["clotho", "lachesis", "atropos"] as const; export type ModuleKind = (typeof MODULE_KINDS)[number]; export const STANCES = ["prefer", "accept", "oppose", "unavailable"] as const; export type Stance = (typeof STANCES)[number]; +/** Initial personal.v1 vocabulary; detailed diagnosis belongs in evidence/text fields. */ +export const ASSESSMENT_UNAVAILABLE_REASONS = [ + "insufficient_evidence", +] as const; +export type AssessmentUnavailableReason = + (typeof ASSESSMENT_UNAVAILABLE_REASONS)[number]; export const SEVERITIES = [ "commitment_breach", "infeasible", @@ -86,12 +92,12 @@ export type OptionAssessment = { optionKey: OptionKey; stance: Stance; severity: Severity | null; - unavailableReason: string | null; + unavailableReason: AssessmentUnavailableReason | null; gain: string; loss: string; uncertainty: string; evidenceRefs: string[]; - /** Absent or empty means an unattributed breach and cannot justify a waiver. */ + /** Absent/empty attribution cannot justify a hard veto or a targeted waiver. */ breachedIntentionIds?: string[]; }; export type JsonValue = diff --git a/packages/lina-core/test/judgment-candidates.test.ts b/packages/lina-core/test/judgment-candidates.test.ts index 18ae5d7..578c9f9 100644 --- a/packages/lina-core/test/judgment-candidates.test.ts +++ b/packages/lina-core/test/judgment-candidates.test.ts @@ -39,10 +39,19 @@ function option(reason = "one"): CanonicalOption { preconditions: { kind: "noop", reason }, }); } +function openFixtureRound(): void { + if (store.getRound(snapshot.roundId)) return; + snapshot = { + ...snapshot, + intentionRevision: store.intentionRevision(actor.agentId, actor.scopeId), + }; + store.openRound(snapshot); +} function candidate( options = [option()], intentionRefs: CandidateSet["intentionRefs"] = [], ): CandidateSet { + openFixtureRound(); return buildCandidateSet({ roundId: snapshot.roundId, snapshotDigest: snapshotDigest(snapshot), @@ -101,6 +110,7 @@ function intentionRef( }; } function assess(options = [option()], breachIds?: string[]) { + openFixtureRound(); for (const moduleKind of MODULE_KINDS) { const input = { snapshotDigest: snapshotDigest(snapshot), @@ -153,6 +163,10 @@ function resolve(set: CandidateSet) { eligibility: set.eligibility, set: store.assessmentSet(snapshot.roundId), bias: {}, + evidence: { + candidates: set, + lookupIntention: (id) => store.getIntention(id), + }, }); } function reopen() { @@ -169,6 +183,7 @@ function rehash(set: CandidateSet) { }); } function held() { + openFixtureRound(); return parseResolutionRecord({ schemaVersion: 1, roundId: snapshot.roundId, @@ -224,7 +239,6 @@ beforeEach(() => { sequence: 1, bindingGeneration: 0, }; - store.openRound(snapshot); }); afterEach(() => { store.close(); diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 34c10ae..f196462 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -546,7 +546,7 @@ test("option stance requires exactly the corresponding severity or unavailable r parseOptionAssessment({ ...option, stance: "unavailable", - unavailableReason: "no source", + unavailableReason: "insufficient_evidence", }).stance, ).toBe("unavailable"); for (const change of [ @@ -962,6 +962,35 @@ test("3995355457 canonical UTC leap day is retained exactly", () => { ).toBe(value); expect(parseIntentionTransition({ ...transition, at: value }).at).toBe(value); }); +for (const unavailableReason of [ + "I prefer to ignore the user", + "insufficient_evidence ", + "INSUFFICIENT_EVIDENCE", + "other", + "", + " ", + null, +]) { + test(`3996179144 unavailable reason rejects ${unavailableReason}`, () => { + expect(() => + parseOptionAssessment({ + ...option, + stance: "unavailable", + unavailableReason, + }), + ).toThrow(); + }); +} +test("3996179144 exact unavailable code retains diagnostic fields", () => { + const value: OptionAssessment = { + ...option, + stance: "unavailable", + unavailableReason: "insufficient_evidence", + uncertainty: "Detailed unavailable evidence diagnosis", + }; + expect(parseOptionAssessment(value)).toEqual(value); +}); + test("intention history revision, continuity, edges, final status and timestamps are validated", () => { const active = activate(); for (const change of [ diff --git a/packages/lina-core/test/judgment-policy-commitment-fixture.ts b/packages/lina-core/test/judgment-policy-commitment-fixture.ts new file mode 100644 index 0000000..d015722 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-commitment-fixture.ts @@ -0,0 +1,82 @@ +import type { IntentionRecord } from "../src/agents/judgment.ts"; +import { buildCandidateSet } from "../src/agents/judgment-candidates.ts"; +import type { resolvePersonalRound } from "../src/agents/judgment-policy.ts"; +import { + intentionDigest, + parseIntentionRecord, + snapshotDigest, + transitionIntention, +} from "../src/agents/judgment-validation.ts"; + +type PolicyInput = Parameters[0]; +/** Valid owner records for policy-only tests; SQLite owner integration is covered + * independently by judgment-policy-evidence-replay-review.test.ts. + */ +export function withCommitmentEvidence( + input: PolicyInput, + commitments: Array<{ id: string; sourceRef: string; suspended?: boolean }> = [ + { id: "intention", sourceRef: "request" }, + ], +): PolicyInput { + const at = "2026-09-12T00:00:00.000Z"; + const records = commitments.map(({ id, sourceRef, suspended }) => { + const proposed = parseIntentionRecord({ + schemaVersion: 1, + intentionId: id, + agentId: input.snapshot.agentId, + scopeId: input.snapshot.scopeId, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "keep promise", + acceptance: { + sourceRef, + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); + const adopted = transitionIntention(proposed, { + to: "adopted", + reason: "accept", + evidenceRef: sourceRef, + at, + }); + return suspended + ? transitionIntention(adopted, { + to: "suspended", + reason: "pause", + evidenceRef: sourceRef, + at, + }) + : adopted; + }); + const owner = new Map( + records.map((record) => [record.intentionId, record]), + ); + return { + ...input, + evidence: { + candidates: buildCandidateSet({ + roundId: input.snapshot.roundId, + snapshotDigest: snapshotDigest(input.snapshot), + options: input.options, + eligibility: input.eligibility, + intentionRefs: records.map((record) => ({ + intentionId: record.intentionId, + revision: record.revision, + status: record.status, + digest: intentionDigest(record), + })), + }), + lookupIntention: (id) => owner.get(id) ?? null, + }, + }; +} diff --git a/packages/lina-core/test/judgment-policy-evidence-fixture.ts b/packages/lina-core/test/judgment-policy-evidence-fixture.ts new file mode 100644 index 0000000..2758589 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-evidence-fixture.ts @@ -0,0 +1,226 @@ +import { join } from "node:path"; +import { type IntentionRecord, MODULE_KINDS } from "../src/agents/judgment.ts"; +import { + buildCandidateSet, + type CandidateSet, +} from "../src/agents/judgment-candidates.ts"; +import { + buildCanonicalOption, + type CanonicalOption, +} from "../src/agents/judgment-catalog.ts"; +import { + PERSONAL_POLICY_V1, + type resolvePersonalRound, +} from "../src/agents/judgment-policy.ts"; +import { JudgmentStore } from "../src/agents/judgment-store.ts"; +import { + assessmentInputDigest, + intentionDigest, + parseAssessmentSet, + snapshotDigest, +} from "../src/agents/judgment-validation.ts"; +import { Fixture } from "./fixture.ts"; + +const actor = { agentId: "policy-agent", scopeId: "policy-scope" }; +const at = "2026-09-12T00:00:00.000Z"; +export function policyOption( + kind: "noop" | "intention.suspend" | "intention.cancel" = "noop", + acceptanceSourceRef = "accepted-request", +): CanonicalOption { + const common = { + actor, + targetId: kind === "noop" ? null : "promise", + args: {}, + }; + switch (kind) { + case "noop": + return buildCanonicalOption({ + ...common, + kind, + preconditions: { kind, reason: "wait" }, + }); + case "intention.suspend": + return buildCanonicalOption({ + ...common, + kind, + preconditions: { + kind, + intentionId: "promise", + acceptanceSourceRef, + reason: "pause", + }, + }); + case "intention.cancel": + return buildCanonicalOption({ + ...common, + kind, + preconditions: { + kind, + intentionId: "promise", + acceptanceSourceRef, + authorityRef: "authority", + userConfirmationRef: "confirmation", + }, + }); + } +} +export function policyEvidenceFixture(option = policyOption()) { + const fixture = new Fixture(); + const path = join(fixture.dir, "policy.sqlite"); + const store = fixture.keep(new JudgmentStore(path, { now: () => 1234 })); + for (const moduleKind of MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: moduleKind, + moduleKind, + revision: 1, + objective: "compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + store.activateObjectiveProfile(actor.agentId, actor.scopeId, ref); + } + const objectiveProfileRefs = store.activeObjectiveProfiles( + actor.agentId, + actor.scopeId, + ); + if (!objectiveProfileRefs) throw Error("missing profiles"); + const snapshot = { + schemaVersion: 1 as const, + roundId: "policy-evidence-round", + ...actor, + sourceRefs: [], + workingRevision: 0, + instructionRevision: 0, + policyId: PERSONAL_POLICY_V1.policyId, + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: 0, + objectiveProfileRefs, + observationRef: null, + frozenNeuralRef: null, + situation: "autonomous" as const, + clockId: "clock", + sequence: 1, + bindingGeneration: 0, + }; + const proposed: IntentionRecord = { + schemaVersion: 1, + intentionId: "promise", + ...actor, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "keep promise", + acceptance: { + sourceRef: "accepted-request", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: at, + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }; + store.putIntention(proposed); + const adopted = store.transitionIntention( + "promise", + { to: "adopted", reason: "accept", evidenceRef: "accepted-request", at }, + 0, + ); + snapshot.intentionRevision = store.intentionRevision( + actor.agentId, + actor.scopeId, + ); + store.openRound(snapshot); + const candidates = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + options: [option], + eligibility: [ + { optionKey: option.optionKey, eligible: true, reason: null }, + ], + intentionRefs: [ + { + intentionId: adopted.intentionId, + revision: adopted.revision, + status: adopted.status, + digest: intentionDigest(adopted), + }, + ], + }); + const set = parseAssessmentSet({ + schemaVersion: 1, + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + assessments: MODULE_KINDS.map((moduleKind) => { + const ref = { + snapshotDigest: snapshotDigest(snapshot), + objectiveRef: objectiveProfileRefs[moduleKind], + mechanismRevision: 1, + }; + return { + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + ...ref, + inputDigest: assessmentInputDigest(ref), + completeText: "assessment", + evidenceRefs: [], + proposedOptionKeys: [], + recommendedOptionKeys: [], + objectiveAssessments: [ + { + optionKey: option.optionKey, + stance: "accept", + severity: null, + unavailableReason: null, + gain: "gain", + loss: "loss", + uncertainty: "unknown", + evidenceRefs: [], + }, + ], + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }; + }), + }); + const input: Parameters[0] & { + evidence: { + candidates: CandidateSet; + lookupIntention: (id: string) => IntentionRecord | null; + }; + } = { + policy: PERSONAL_POLICY_V1, + snapshot, + options: candidates.options, + eligibility: candidates.eligibility, + set, + bias: {}, + evidence: { candidates, lookupIntention: (id) => store.getIntention(id) }, + }; + function oppose(moduleKind: "atropos" | "clotho", ids?: string[]) { + const opinion = input.set.assessments.find( + (a) => a.moduleKind === moduleKind, + )?.objectiveAssessments[0]; + if (!opinion) throw Error("missing opinion"); + opinion.stance = "oppose"; + opinion.severity = + moduleKind === "atropos" ? "commitment_breach" : "infeasible"; + if (ids !== undefined) opinion.breachedIntentionIds = ids; + } + return { fixture, store, path, input, oppose, adopted, option }; +} diff --git a/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts b/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts new file mode 100644 index 0000000..5eec8b3 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts @@ -0,0 +1,78 @@ +import { afterEach, expect, test } from "bun:test"; +import { resolvePersonalRound } from "../src/agents/judgment-policy.ts"; +import { JudgmentStore } from "../src/agents/judgment-store.ts"; +import { + policyEvidenceFixture, + policyOption, +} from "./judgment-policy-evidence-fixture.ts"; + +const fixtures: ReturnType[] = []; +afterEach(() => { + for (const { fixture } of fixtures.splice(0)) fixture.close(); +}); + +for (const kind of ["noop", "intention.suspend", "intention.cancel"] as const) + test(`owner-verified ${kind} arbitration records and replays after later intention transitions`, () => { + const context = policyEvidenceFixture(policyOption(kind)); + fixtures.push(context); + const { input, oppose, store, path, fixture } = context; + oppose("atropos", ["promise"]); + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(input); + const at = "2026-09-12T00:00:00.000Z"; + + store.recordResolution( + input.snapshot.roundId, + result.resolution, + result.spec, + ); + store.transitionIntention( + "promise", + { to: "active", reason: "start", evidenceRef: "accepted-request", at }, + 1, + ); + store.transitionIntention( + "promise", + { to: "completed", reason: "finished", evidenceRef: "outcome", at }, + 2, + ); + const reopened = fixture.keep(new JudgmentStore(path)); + + expect(reopened.getResolution(input.snapshot.roundId)).toEqual( + result.resolution, + ); + expect(reopened.getSelectionSpec(input.snapshot.roundId)).toEqual( + result.spec, + ); + expect(result.resolution.status).toBe( + kind === "noop" ? "deferred" : "resolved", + ); + }); + +for (const moduleKind of ["atropos", "clotho"] as const) + test(`unattributed ${moduleKind} opposition persists without a veto`, () => { + const context = policyEvidenceFixture(); + fixtures.push(context); + const { input, oppose, store, fixture, path } = context; + oppose(moduleKind); + store.closeCandidateSet(input.evidence.candidates); + for (const assessment of input.set.assessments) + store.putAssessment(assessment); + const result = resolvePersonalRound(input); + + store.recordResolution( + input.snapshot.roundId, + result.resolution, + result.spec, + ); + const reopened = fixture.keep(new JudgmentStore(path)); + + expect( + reopened.getSelectionSpec(input.snapshot.roundId)?.candidates[0]?.p0, + ).toBe(1); + expect(reopened.getResolution(input.snapshot.roundId)?.excluded).toEqual( + [], + ); + }); diff --git a/packages/lina-core/test/judgment-policy-evidence-review.test.ts b/packages/lina-core/test/judgment-policy-evidence-review.test.ts new file mode 100644 index 0000000..5d74cb4 --- /dev/null +++ b/packages/lina-core/test/judgment-policy-evidence-review.test.ts @@ -0,0 +1,222 @@ +import { afterEach, expect, test } from "bun:test"; +import { buildCandidateSet } from "../src/agents/judgment-candidates.ts"; +import { buildCanonicalOption } from "../src/agents/judgment-catalog.ts"; +import { + PERSONAL_POLICY_V1, + resolvePersonalRound, +} from "../src/agents/judgment-policy.ts"; +import { intentionDigest } from "../src/agents/judgment-validation.ts"; +import { + policyEvidenceFixture, + policyOption, +} from "./judgment-policy-evidence-fixture.ts"; + +const fixtures: ReturnType[] = []; +function setup(option = policyOption()) { + const fixture = policyEvidenceFixture(option); + fixtures.push(fixture); + return fixture; +} +afterEach(() => { + for (const { fixture } of fixtures.splice(0)) fixture.close(); +}); + +test("personal.v1 declares its closed unavailable reason vocabulary", () => { + expect(PERSONAL_POLICY_V1).toMatchObject({ + unavailableReasons: ["insufficient_evidence"], + }); +}); + +// Exclusion reasons are parsed receipt fields: missing evidence and an explicit +// negative Host decision must not become the same recorded state. +test("explicit Host false/null is distinct from an absent eligibility row", () => { + const { input } = setup(); + const { evidence: _evidence, ...direct } = input; + direct.eligibility = direct.eligibility.map((row) => ({ + ...row, + eligible: false, + })); + const explicit = resolvePersonalRound(direct); + const missing = resolvePersonalRound({ ...direct, eligibility: [] }); + expect(explicit.resolution.status).toBe("deferred"); + expect(explicit.resolution.excluded[0]?.stage).toBe("host_eligibility"); + expect(explicit.resolution.excluded[0]?.reason).not.toBe( + missing.resolution.excluded[0]?.reason, + ); +}); + +for (const kind of ["noop", "intention.suspend", "intention.cancel"] as const) + for (const ids of [undefined, []]) + test(`Atropos unattributed ${kind} opposition cannot hard-veto (${JSON.stringify(ids)})`, () => { + const { input, oppose } = setup(policyOption(kind)); + oppose("atropos", ids); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); + }); + +for (const ids of [["invented"], ["promise"]]) + test(`direct resolver cannot trust unverified attribution ${JSON.stringify(ids)}`, () => { + const { input, oppose } = setup(); + oppose("atropos", ids); + const { evidence: _evidence, ...direct } = input; + const result = resolvePersonalRound(direct); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); + }); + +test("Host-eligible Clotho infeasible opinion alone remains ranked opposition", () => { + const { input, oppose } = setup(); + oppose("clotho"); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); +}); + +test("factual prerequisite failure is represented by Host eligibility", () => { + const { input, oppose } = setup(); + oppose("clotho"); + const { evidence: _evidence, ...direct } = input; + direct.eligibility = direct.eligibility.map((row) => ({ + ...row, + eligible: false, + reason: "prerequisite receipt: unavailable resource", + })); + const result = resolvePersonalRound(direct); + expect(result.resolution.excluded[0]?.stage).toBe("host_eligibility"); + expect(result.spec).toBeNull(); +}); + +test("owner-verified accepted commitment still hard-vetoes an ordinary candidate", () => { + const { input, oppose } = setup(); + oppose("atropos", ["promise"]); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded[0]?.stage).toBe("commitment_protection"); + expect(result.spec).toBeNull(); +}); + +for (const kind of ["intention.suspend", "intention.cancel"] as const) + test(`verified same-target ${kind} retains its original-acceptance waiver`, () => { + const { input, oppose } = setup(policyOption(kind)); + oppose("atropos", ["promise"]); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded).toEqual([]); + expect(result.spec?.candidates[0]?.p0).toBe(1); + }); + +for (const forgery of [ + "id", + "digest", + "scope", + "lookup", + "candidate-universe", + "snapshot", + "kind", + "status", +] as const) + test(`direct resolver rejects ${forgery} evidence instead of manufacturing a veto`, () => { + const { input, oppose, adopted } = setup(); + oppose("atropos", [forgery === "id" ? "invented" : "promise"]); + const { candidateDigest: _digest, ...candidates } = + input.evidence.candidates; + if (forgery === "digest") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + intentionRefs: candidates.intentionRefs.map((ref) => ({ + ...ref, + digest: "invented", + })), + }); + if (forgery === "scope") + input.evidence.lookupIntention = () => ({ + ...adopted, + scopeId: "foreign", + }); + if (forgery === "lookup") input.evidence.lookupIntention = () => null; + if (forgery === "candidate-universe") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + eligibility: [], + }); + if (forgery === "snapshot") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + snapshotDigest: "invented", + }); + if (forgery === "kind") + input.evidence.lookupIntention = () => ({ + ...adopted, + kind: "autonomous_goal", + }); + if (forgery === "status") + input.evidence.candidates = buildCandidateSet({ + ...candidates, + intentionRefs: candidates.intentionRefs.map((ref) => ({ + ...ref, + status: "proposed", + })), + }); + expect(() => resolvePersonalRound(input)).toThrow(); + }); + +for (const ids of [["other"], ["promise", "other"]]) + test(`targeted waiver cannot release another verified promise ${JSON.stringify(ids)}`, () => { + const { input, oppose, store, adopted } = setup( + policyOption("intention.cancel"), + ); + store.putIntention({ + ...adopted, + intentionId: "other", + status: "proposed", + revision: 0, + history: [], + }); + const other = store.transitionIntention( + "other", + { + to: "adopted", + reason: "accept", + evidenceRef: "accepted-request", + at: "2026-09-12T00:00:00.000Z", + }, + 0, + ); + const { candidateDigest: _digest, ...candidates } = + input.evidence.candidates; + input.evidence.candidates = buildCandidateSet({ + ...candidates, + intentionRefs: [ + ...candidates.intentionRefs, + { + intentionId: other.intentionId, + revision: other.revision, + status: other.status, + digest: intentionDigest(other), + }, + ], + }); + oppose("atropos", ids); + const result = resolvePersonalRound(input); + expect(result.resolution.excluded[0]?.stage).toBe("commitment_protection"); + expect(result.spec).toBeNull(); + }); + +test("a nonempty invented acceptance reference cannot obtain a waiver", () => { + const { input, oppose } = setup(policyOption("intention.cancel", "invented")); + oppose("atropos", ["promise"]); + expect(() => resolvePersonalRound(input)).toThrow( + "candidate original acceptance mismatch", + ); +}); + +test("a waiver cannot name a different canonical target", () => { + const option = buildCanonicalOption({ + ...policyOption("intention.cancel"), + targetId: "different", + }); + const { input, oppose } = setup(option); + oppose("atropos", ["promise"]); + expect(() => resolvePersonalRound(input)).toThrow( + "candidate intention target mismatch", + ); +}); diff --git a/packages/lina-core/test/judgment-policy-review.test.ts b/packages/lina-core/test/judgment-policy-review.test.ts index 9fd2a7f..9a95368 100644 --- a/packages/lina-core/test/judgment-policy-review.test.ts +++ b/packages/lina-core/test/judgment-policy-review.test.ts @@ -12,6 +12,7 @@ import { sampleSelection, snapshotDigest, } from "../src/agents/index.ts"; +import { withCommitmentEvidence } from "./judgment-policy-commitment-fixture.ts"; test("rank mass remains normalized when absolute geometric weights overflow or underflow", () => { expect(rankMass([1076], 0.5)).toEqual([1]); @@ -163,18 +164,21 @@ function commitmentChange(breachedIntentionIds?: string[]) { ], })), }); - return input; + return withCommitmentEvidence(input, [ + { id: "changed-intention", sourceRef: "accepted-request" }, + { id: "different-promise", sourceRef: "other-request" }, + ]); } -test("an unattributed breach cannot waive protection for an intention change", () => { - const result = resolvePersonalRound(commitmentChange()); - expect(result.resolution.status).toBe("deferred"); - expect(result.spec).toBeNull(); - expect(result.resolution.excluded[0]?.stage).toBe("commitment_protection"); -}); +for (const ids of [undefined, []]) + test(`an unattributed breach cannot hard-veto an intention change (${JSON.stringify(ids)})`, () => { + const result = resolvePersonalRound(commitmentChange(ids)); + expect(result.resolution.status).toBe("resolved"); + expect(result.spec?.candidates[0]?.p0).toBe(1); + expect(result.resolution.excluded).toEqual([]); + }); for (const ids of [ - [], ["different-promise"], ["changed-intention", "different-promise"], ]) { @@ -231,8 +235,13 @@ for (const removal of ["host", "commitment", "infeasible", "ranked"] as const) { input.eligibility = [ { optionKey: preferred.optionKey, - eligible: removal !== "host", - reason: removal === "host" ? "not authorized" : null, + eligible: removal !== "host" && removal !== "infeasible", + reason: + removal === "host" + ? "not authorized" + : removal === "infeasible" + ? "failed prerequisite receipt" + : null, }, { optionKey: alternative.optionKey, eligible: true, reason: null }, ]; @@ -252,13 +261,16 @@ for (const removal of ["host", "commitment", "infeasible", "ranked"] as const) { if (removal === "commitment" && assessment.moduleKind === "atropos") { opinion.stance = "oppose"; opinion.severity = "commitment_breach"; + opinion.breachedIntentionIds = ["intention"]; } if (removal === "infeasible" && assessment.moduleKind === "clotho") { opinion.stance = "oppose"; opinion.severity = "infeasible"; } } - const { resolution } = resolvePersonalRound(input); + const { resolution } = resolvePersonalRound( + removal === "commitment" ? withCommitmentEvidence(input) : input, + ); expect(resolution.status).toBe("resolved"); if (removal === "ranked") { expect(resolution.conceded).toContainEqual({ @@ -293,6 +305,7 @@ test("personal.v1 revision one rejects altered or malformed declarations", () => { lambda: { ...PERSONAL_POLICY_V1.lambda, user_request: 1 } }, { lambda: { ...PERSONAL_POLICY_V1.lambda, autonomous: 0 } }, { stanceOrder: [] }, + { unavailableReasons: [] }, { stanceOrder: [...PERSONAL_POLICY_V1.stanceOrder].reverse() }, ]) { expect(() => @@ -333,7 +346,7 @@ test("a module omitted from round ordering makes no concession", () => { optionKey: alternative.optionKey, stance: assessment.moduleKind === "clotho" ? "unavailable" : "prefer", unavailableReason: - assessment.moduleKind === "clotho" ? "missing forecast" : null, + assessment.moduleKind === "clotho" ? "insufficient_evidence" : null, }); } const result = resolvePersonalRound(input); @@ -344,14 +357,14 @@ test("a module omitted from round ordering makes no concession", () => { expect(result.resolution.abstentions).toContainEqual({ moduleKind: "clotho", optionKey: alternative.optionKey, - reason: "missing forecast", + reason: "insufficient_evidence", }); expect(result.resolution.conceded).toEqual([]); }); -for (const [moduleKind, severity, stage] of [ - ["atropos", "commitment_breach", "commitment_protection"], - ["clotho", "infeasible", "infeasible"], +for (const [moduleKind, severity] of [ + ["atropos", "commitment_breach"], + ["clotho", "infeasible"], ] as const) { test(`${moduleKind} unavailable cannot gain a ${severity} veto`, () => { const input = fixture(); @@ -361,7 +374,7 @@ for (const [moduleKind, severity, stage] of [ const opinion = assessment?.objectiveAssessments[0]; if (!opinion) throw Error("missing opinion"); opinion.stance = "unavailable"; - opinion.unavailableReason = "missing evidence"; + opinion.unavailableReason = "insufficient_evidence"; const available = resolvePersonalRound(input); expect(available.resolution.status).toBe("resolved"); expect(available.resolution.excluded).toEqual([]); @@ -377,16 +390,11 @@ for (const [moduleKind, severity, stage] of [ opinion.stance = "oppose"; opinion.unavailableReason = null; const opposed = resolvePersonalRound(input); - expect(opposed.resolution.status).toBe("deferred"); - expect(opposed.resolution.excluded).toEqual([ - { - optionKey: opinion.optionKey, - stage, - byModule: moduleKind, - reason: expect.any(String), - }, + expect(opposed.resolution.status).toBe("resolved"); + expect(opposed.resolution.excluded).toEqual([]); + expect(opposed.spec?.candidates).toEqual([ + { optionKey: opinion.optionKey, p0: 1, b: null }, ]); - expect(opposed.spec).toBeNull(); }); } @@ -410,7 +418,7 @@ for (const stances of [ opinion.stance = stance; opinion.severity = stance === "oppose" ? "preference" : null; opinion.unavailableReason = - stance === "unavailable" ? "missing evidence" : null; + stance === "unavailable" ? "insufficient_evidence" : null; } expect(parseAssessmentSet(input.set)).toEqual(input.set); const { resolution } = resolvePersonalRound(input); diff --git a/packages/lina-core/test/judgment-policy.test.ts b/packages/lina-core/test/judgment-policy.test.ts index c8c72c2..7cf4d66 100644 --- a/packages/lina-core/test/judgment-policy.test.ts +++ b/packages/lina-core/test/judgment-policy.test.ts @@ -30,6 +30,7 @@ import { sampleSelection, snapshotDigest, } from "../src/agents/index.ts"; +import { withCommitmentEvidence } from "./judgment-policy-commitment-fixture.ts"; const actor = { agentId: "agent", scopeId: "scope" }; const preconditions: PersonalPrecondition[] = [ @@ -128,7 +129,8 @@ function opinion( return { stance, severity: stance === "oppose" ? (severity ?? "preference") : null, - unavailableReason: stance === "unavailable" ? "missing observation" : null, + unavailableReason: + stance === "unavailable" ? "insufficient_evidence" : null, }; } function fixture( @@ -209,9 +211,12 @@ function fixture( }; } function resolve(input: Parameters[0]) { - const before = structuredClone(input); + const before = structuredClone({ + ...input, + evidence: input.evidence?.candidates, + }); const result = resolvePersonalRound(input); - expect(input).toEqual(before); + expect({ ...input, evidence: input.evidence?.candidates }).toEqual(before); expect(parseResolutionRecord(result.resolution)).toEqual(result.resolution); if (result.spec) { expect(parseSelectionSpec(result.spec)).toEqual(result.spec); @@ -483,16 +488,36 @@ test("(5) only original-acceptance suspend/cancel escape commitment protection", "intention.cancel", "intention.suspend", "intention.resume", - ].map((kind) => option(kind, kind as PersonalOptionKind)); + ].map((kind) => { + const candidate = option("intention", kind as PersonalOptionKind); + return kind === "intention.resume" + ? buildCanonicalOption({ + ...candidate, + targetId: "resume-intention", + preconditions: { + kind: "intention.resume", + intentionId: "resume-intention", + acceptanceSourceRef: "request", + reason: "resume", + }, + }) + : candidate; + }); const result = resolve( - fixture("user_request", options, (m) => { - if (m === "atropos") - return { - ...opinion("oppose", "commitment_breach"), - breachedIntentionIds: ["intention"], - }; - return opinion("accept"); - }), + withCommitmentEvidence( + fixture("user_request", options, (m) => { + if (m === "atropos") + return { + ...opinion("oppose", "commitment_breach"), + breachedIntentionIds: ["intention"], + }; + return opinion("accept"); + }), + [ + { id: "intention", sourceRef: "request" }, + { id: "resume-intention", sourceRef: "request", suspended: true }, + ], + ), ); for (const o of options) { const exempt = @@ -526,7 +551,7 @@ test("(5) only original-acceptance suspend/cancel escape commitment protection", expect(() => resolvePersonalRound(input)).toThrow(); }); -test("(5) preference never excludes, and only clotho drives infeasible exclusion", () => { +test("(5) preference and unverified infeasible opposition never exclude", () => { for (const moduleKind of MODULE_KINDS) { for (const severity of ["preference", "infeasible"] as const) { const result = resolve( @@ -539,15 +564,8 @@ test("(5) preference never excludes, and only clotho drives infeasible exclusion const p0 = requireSpec(result).candidates.find( (c) => c.optionKey === option("a").optionKey, )?.p0; - if (moduleKind === "clotho" && severity === "infeasible") { - expect(p0).toBe(0); - expect(result.resolution.excluded).toContainEqual({ - optionKey: option("a").optionKey, - stage: "infeasible", - byModule: "clotho", - reason: expect.any(String), - }); - } else expect(p0).toBeGreaterThan(0); + expect(p0).toBeGreaterThan(0); + expect(result.resolution.excluded).toEqual([]); } } }); @@ -572,8 +590,15 @@ test("(7) no host-eligible or no remaining candidates defers, never supplies uni expect(result.spec).toBeNull(); } const excluded = resolve( - fixture("transition", undefined, (m) => - m === "clotho" ? opinion("oppose", "infeasible") : opinion("accept"), + withCommitmentEvidence( + fixture("transition", undefined, (m) => + m === "atropos" + ? { + ...opinion("oppose", "commitment_breach"), + breachedIntentionIds: ["intention"], + } + : opinion("accept"), + ), ), ); expect(excluded.resolution.status).toBe("deferred"); @@ -606,21 +631,26 @@ test("(7) eligible completeness precedes protection; ineligible candidates need m === "lachesis" && o.targetId === "a" ? null : m === "atropos" - ? opinion("oppose", "commitment_breach") + ? { + ...opinion("oppose", "commitment_breach"), + breachedIntentionIds: ["intention"], + } : opinion("accept"), ); - const result = resolve(input); + const result = resolve(withCommitmentEvidence(input)); expect(result.resolution.status).toBe("held"); expect(result.resolution.holdReason).toBe( `missing assessment lachesis for ${option("a").optionKey}`, ); expect(result.spec).toBeNull(); - const without = resolve({ - ...input, - eligibility: input.eligibility.filter( - (e) => e.optionKey !== option("a").optionKey, - ), - }); + const without = resolve( + withCommitmentEvidence({ + ...input, + eligibility: input.eligibility.filter( + (e) => e.optionKey !== option("a").optionKey, + ), + }), + ); expect(without.resolution.status).toBe("deferred"); const mismatched = { ...input, @@ -668,7 +698,7 @@ for (const situation of SITUATIONS) { expect(result.resolution.abstentions).toContainEqual({ optionKey: option("c").optionKey, moduleKind: m1, - reason: "missing observation", + reason: "insufficient_evidence", }); expect( resolve({ ...input, options: [...input.options].reverse() }), @@ -803,6 +833,7 @@ test("personal policy is frozen at every level", () => { ...Object.values(PERSONAL_POLICY_V1.orders), PERSONAL_POLICY_V1.lambda, PERSONAL_POLICY_V1.stanceOrder, + PERSONAL_POLICY_V1.unavailableReasons, ]) expect(Object.isFrozen(value)).toBe(true); }); @@ -821,6 +852,8 @@ test("assignment cannot change personal policy arbitration", () => { [PERSONAL_POLICY_V1, "orders", {}], [PERSONAL_POLICY_V1, "lambda", {}], [PERSONAL_POLICY_V1, "stanceOrder", []], + [PERSONAL_POLICY_V1, "unavailableReasons", []], + [PERSONAL_POLICY_V1.unavailableReasons, "0", "invented"], [ PERSONAL_POLICY_V1.orders, "autonomous", @@ -850,5 +883,6 @@ test("policy public type exposes exactly the revision-one declaration", () => { orders, lambda: { user_request: 0, autonomous: 1, transition: 1 }, stanceOrder: ["prefer", "accept", "oppose"], + unavailableReasons: ["insufficient_evidence"], }); }); diff --git a/packages/lina-core/test/judgment-round.test.ts b/packages/lina-core/test/judgment-round.test.ts index a61df11..8911504 100644 --- a/packages/lina-core/test/judgment-round.test.ts +++ b/packages/lina-core/test/judgment-round.test.ts @@ -238,6 +238,9 @@ function assessmentFor( loss: "fixture loss", uncertainty: "fixture uncertainty", evidenceRefs: [], + ...(moduleKind === "atropos" && option.kind === "noop" + ? { breachedIntentionIds: ["protected-promise"] } + : {}), }; }); return parseAssessment({ @@ -319,17 +322,6 @@ function playRound(store: JudgmentStore, situation: Situation) { options.inquire, options.noop, ]; - const snapshot = snapshotRef(situation, profiles.refs, projection); - const opened = store.openRound(snapshot); - expect(opened).toEqual({ - roundId: snapshot.roundId, - snapshotDigest: snapshotDigest(snapshot), - }); - const assessments = (["clotho", "lachesis", "atropos"] as const).map( - (moduleKind) => assessmentFor(snapshot, moduleKind, candidates), - ); - for (const assessment of assessments) store.putAssessment(assessment); - const set = store.assessmentSet(snapshot.roundId); store.putIntention({ ...proposedIntention(), intentionId: "intention-start", @@ -344,16 +336,53 @@ function playRound(store: JudgmentStore, situation: Situation) { }, 0, ); + const promise = proposedIntention(); + store.putIntention({ + ...promise, + intentionId: "protected-promise", + kind: "user_commitment", + acceptance: { ...promise.acceptance, acceptedBy: "user" }, + }); + const protectedIntention = store.transitionIntention( + "protected-promise", + { + to: "adopted", + reason: "accepted user promise", + evidenceRef: "source-1", + at: PROJECTED_AT, + }, + 0, + ); + const snapshot = { + ...snapshotRef(situation, profiles.refs, projection), + intentionRevision: store.intentionRevision(AGENT, SCOPE), + }; + const opened = store.openRound(snapshot); + expect(opened).toEqual({ + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + }); + const assessments = (["clotho", "lachesis", "atropos"] as const).map( + (moduleKind) => assessmentFor(snapshot, moduleKind, candidates), + ); + for (const assessment of assessments) store.putAssessment(assessment); + const set = store.assessmentSet(snapshot.roundId); const closed = buildCandidateSet({ roundId: snapshot.roundId, snapshotDigest: snapshotDigest(snapshot), options: candidates, eligibility: candidates.map((option) => ({ optionKey: option.optionKey, - eligible: true, - reason: null, + eligible: option.kind !== "task.start", + reason: option.kind === "task.start" ? "infeasible precondition" : null, })), intentionRefs: [ + { + intentionId: protectedIntention.intentionId, + revision: protectedIntention.revision, + status: protectedIntention.status, + digest: intentionDigest(protectedIntention), + }, { intentionId: startIntention.intentionId, revision: startIntention.revision, @@ -370,6 +399,10 @@ function playRound(store: JudgmentStore, situation: Situation) { set, eligibility: closed.eligibility, bias: {}, + evidence: { + candidates: closed, + lookupIntention: (id) => store.getIntention(id), + }, }); store.recordResolution(snapshot.roundId, resolved.resolution, resolved.spec); return { @@ -384,7 +417,7 @@ function playRound(store: JudgmentStore, situation: Situation) { }; } -test("autonomous personal.v1 round persists selection, sampling and adopted intention", () => { +test("autonomous personal.v1 round persists SelectionSpec and adopted intention, with injected sampling in memory", () => { const store = open(); const { projection, @@ -474,8 +507,8 @@ test("autonomous personal.v1 round persists selection, sampling and adopted inte expect(resolution.order).toEqual([...AUTONOMOUS_ORDER]); expect(resolution.excluded).toContainEqual({ optionKey: options.start.optionKey, - stage: "infeasible", - byModule: "clotho", + stage: "host_eligibility", + byModule: null, reason: "infeasible precondition", }); expect(resolution.excluded).toContainEqual({ diff --git a/packages/lina-core/test/judgment-store-digest-review.test.ts b/packages/lina-core/test/judgment-store-digest-review.test.ts index 192d80e..30c9022 100644 --- a/packages/lina-core/test/judgment-store-digest-review.test.ts +++ b/packages/lina-core/test/judgment-store-digest-review.test.ts @@ -85,7 +85,7 @@ function snapshot( policyRevision: 1, identityRevision: 1, domainRevisions: { life: 0 }, - intentionRevision: 0, + intentionRevision: store.intentionRevision("agent-1", "scope-1"), objectiveProfileRefs: refs, observationRef: null, frozenNeuralRef: null, @@ -241,12 +241,12 @@ function transition( } function seed() { + store.putIntention(intention()); const ref = snapshot(store); store.openRound(ref); closeCandidates(store, ref); for (const module of MODULE_KINDS) store.putAssessment(assessment(ref, module)); - store.putIntention(intention()); return ref; } function replaceBody(table: string, value: unknown, where = "1 = 1"): void { @@ -463,6 +463,39 @@ for (const tamper of [ } } +for (const field of ["acceptedAt", "deadline", "at"] as const) { + test(`3995355457 persisted noncanonical ${field} rejects intact even with recomputed digests`, () => { + store.putIntention(intention()); + const original = + field === "at" + ? store.transitionIntention("intention-1", transition("adopted"), 0) + : intention(); + const changed = structuredClone(original); + const noncanonical = "2026-02-30T00:00:00.000Z"; + if (field === "deadline") changed.deadline = noncanonical; + else if (field === "acceptedAt") + changed.acceptance.acceptedAt = noncanonical; + else { + const first = changed.history[0]; + if (!first) throw Error("missing transition"); + first.at = noncanonical; + db.prepare("UPDATE intention_transitions SET at = ?").run(noncanonical); + } + const body = JSON.stringify(changed); + db.prepare("UPDATE intention_records SET body = ?, digest = ?").run( + body, + judgmentDigest(changed), + ); + reopen(); + expect(() => store.getIntention("intention-1")).toThrow( + /invalid (accepted at|intention deadline|transition at)/, + ); + expect(db.prepare("SELECT body FROM intention_records").get()).toEqual({ + body, + }); + }); +} + test("3995355426: parser-normalized digests and valid lifecycle survive reopen", () => { const ref = seed(); const record = resolution(ref); diff --git a/packages/lina-core/test/judgment-store.test.ts b/packages/lina-core/test/judgment-store.test.ts index de247a7..44cd522 100644 --- a/packages/lina-core/test/judgment-store.test.ts +++ b/packages/lina-core/test/judgment-store.test.ts @@ -100,7 +100,7 @@ function snapshot( policyRevision: 1, identityRevision: 1, domainRevisions: { life: 0 }, - intentionRevision: 0, + intentionRevision: store.intentionRevision("agent-1", "scope-1"), objectiveProfileRefs: refs, observationRef: null, frozenNeuralRef: null, @@ -740,7 +740,7 @@ for (const { label, change, error } of selectionMismatches) { }); } -test("assessment and selection use frozen refs after objective activation changes", () => { +test("3998853057 assessments retain frozen refs but stale objective resolution rejects atomically", () => { const store = open(); const ref = snapshot(store); store.openRound(ref); @@ -752,20 +752,207 @@ test("assessment and selection use frozen refs after objective activation change expect(selection.assessmentSetDigest).toBe( judgmentDigest(store.assessmentSet(ref.roundId)), ); - store.recordResolution(ref.roundId, resolution(ref), selection); - expect(store.getSelectionSpec(ref.roundId)).toEqual(selection); - expect(store.getRound(ref.roundId)?.status).toBe("resolved"); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), selection), + ).toThrow("stale objective profile refs"); + expect(store.assessmentSet(ref.roundId).assessments).toHaveLength(3); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + expect(store.getRound(ref.roundId)?.status).toBe("open"); +}); + +test("3998853057 resolved action remains historical after activation and intention changes", () => { + let store = open(); + const ref = snapshot(store); + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + ref.objectiveProfileRefs.clotho, + ); + store.activateObjectiveProfile( + "other-agent", + ref.scopeId, + store.putObjectiveProfile(profile("clotho", 2)), + ); + store.activateObjectiveProfile( + ref.agentId, + "other-scope", + store.putObjectiveProfile(profile("clotho", 2)), + ); + store.recordResolution(ref.roundId, resolution(ref), spec(ref)); + store.activateObjectiveProfile( + ref.agentId, + ref.scopeId, + store.putObjectiveProfile(profile("clotho", 2)), + ); + store.putIntention(intention()); + store.transitionIntention("intention-1", transition("adopted"), 0); + close(store); + store = open(); + expect(store.getResolution(ref.roundId)).toEqual(resolution(ref)); + expect(store.getSelectionSpec(ref.roundId)).toEqual(spec(ref)); }); +test("3998853070 scoped mutation sum counts creates/transitions and survives reopen", () => { + let store = open(); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(0); + store.putIntention(intention()); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(1); + store.putIntention(intention("second")); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(2); + store.transitionIntention("intention-1", transition("adopted"), 0); + expect(() => store.putIntention(intention())).toThrow("duplicate intention"); + expect(() => + store.transitionIntention("intention-1", transition("active"), 0), + ).toThrow("stale intention revision"); + expect(() => + store.transitionIntention("intention-1", transition("completed"), 1), + ).toThrow(); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(3); + expect(store.intentionRevision("agent-1", "other")).toBe(0); + expect(store.intentionRevision("other", "scope-1")).toBe(0); + close(store); + store = open(); + expect(store.intentionRevision("agent-1", "scope-1")).toBe(3); +}); + +for (const mutation of ["create", "transition"] as const) { + test(`3998853070 ${mutation} invalidates open and resolution even without candidate intention refs`, () => { + const store = open(); + store.putIntention(intention()); + const ref = { ...snapshot(store), intentionRevision: 1 }; + store.openRound(ref); + closeCandidates(store, ref); + for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); + if (mutation === "create") store.putIntention(intention("second")); + else store.transitionIntention("intention-1", transition("adopted"), 0); + expect(() => + store.openRound({ ...ref, roundId: "stale", sequence: 2 }), + ).toThrow("stale intention revision"); + expect(() => + store.recordResolution(ref.roundId, resolution(ref), spec(ref)), + ).toThrow("stale intention revision"); + expect(store.getRound(ref.roundId)?.status).toBe("open"); + expect(store.getResolution(ref.roundId)).toBeNull(); + expect(store.getSelectionSpec(ref.roundId)).toBeNull(); + const fresh = { + ...ref, + roundId: "fresh", + sequence: 3, + intentionRevision: store.intentionRevision(ref.agentId, ref.scopeId), + }; + store.openRound(fresh); + closeCandidates(store, fresh); + for (const m of MODULE_KINDS) store.putAssessment(assessment(fresh, m)); + store.recordResolution(fresh.roundId, resolution(fresh), spec(fresh)); + expect(store.getRound(fresh.roundId)?.status).toBe("resolved"); + }); +} + +test("3998853070 unsafe scoped counter prevents both mutation paths atomically", () => { + const store = open(); + store.putIntention(intention()); + // The owner counter boundary is the only synthetic value; all mutations use real SQLite. + const counter = spyOn(store, "intentionRevision").mockReturnValue( + Number.MAX_SAFE_INTEGER, + ); + try { + expect(() => store.putIntention(intention("overflow"))).toThrow( + "invalid judgment revision", + ); + expect(() => + store.transitionIntention("intention-1", transition("adopted"), 0), + ).toThrow("invalid judgment revision"); + expect(store.getIntention("overflow")).toBeNull(); + expect(store.getIntention("intention-1")).toEqual(intention()); + expect( + database() + .prepare("SELECT count(*) AS n FROM intention_transitions") + .get(), + ).toEqual({ n: 0 }); + } finally { + counter.mockRestore(); + } + expect(store.intentionRevision("agent-1", "scope-1")).toBe(1); +}); + +test("3998853089 scoped sequence increases across handles and reopen with gaps allowed", () => { + let store = open(); + const ref = snapshot(store, "two", 2); + const second = open(); + store.openRound(ref); + for (const sequence of [1, 2]) + expect(() => + second.openRound({ ...ref, roundId: `rejected-${sequence}`, sequence }), + ).toThrow(); + second.openRound({ ...ref, roundId: "four", sequence: 4 }); + close(store); + store = open(); + expect(() => + store.openRound({ ...ref, roundId: "three", sequence: 3 }), + ).toThrow("stale round sequence"); + store.openRound({ ...ref, roundId: "five", sequence: 5 }); + for (const change of [{ agentId: "other" }, { scopeId: "other" }]) { + const independent = { + ...ref, + ...change, + roundId: JSON.stringify(change), + sequence: 1, + }; + for (const m of MODULE_KINDS) + store.activateObjectiveProfile( + independent.agentId, + independent.scopeId, + ref.objectiveProfileRefs[m], + ); + store.openRound(independent); + } + expect(database().prepare("SELECT count(*) AS n FROM rounds").get()).toEqual({ + n: 5, + }); +}); + +for (const field of ["acceptedAt", "deadline", "at"] as const) { + test(`3995355457 store rejects noncanonical ${field} before mutation`, () => { + const store = open(); + const value = "2026-02-30T00:00:00.000Z"; + if (field === "at") { + store.putIntention(intention()); + expect(() => + store.transitionIntention( + "intention-1", + { ...transition("adopted"), at: value }, + 0, + ), + ).toThrow(); + expect(store.getIntention("intention-1")).toEqual(intention()); + } else { + const record = intention(); + if (field === "deadline") record.deadline = value; + else record.acceptance.acceptedAt = value; + expect(() => store.putIntention(record)).toThrow(); + expect(store.getIntention(record.intentionId)).toBeNull(); + } + expect( + database() + .prepare("SELECT count(*) AS n FROM intention_transitions") + .get(), + ).toEqual({ n: 0 }); + }); +} + test("resolution and selection persist atomically and survive reopen with canonical JSON", () => { let store = open(); + store.putIntention(intention()); const ref = snapshot(store); store.openRound(ref); closeCandidates(store, ref); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); const record = resolution(ref); const selection = spec(ref); - store.putIntention(intention()); store.recordResolution(ref.roundId, record, selection); expect(store.getRound(ref.roundId)?.status).toBe("resolved"); expect(() => @@ -931,6 +1118,7 @@ test("SQLite transition insert failure rolls back updated intention", () => { test("external records are reparsed and malformed persisted bodies are rejected on read", () => { const store = open(); + store.putIntention(intention()); const ref = snapshot(store); store.openRound(ref); closeCandidates(store, ref); @@ -966,7 +1154,6 @@ test("external records are reparsed and malformed persisted bodies are rejected ]) expect(action).toThrow(); const db = database(); - store.putIntention(intention()); for (const m of MODULE_KINDS) store.putAssessment(assessment(ref, m)); store.recordResolution(ref.roundId, resolution(ref), spec(ref)); for (const [table, column, read] of [ From ebd7af9eb6684403a30749ae2a241d36cc455f7c Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:35:18 +0900 Subject: [PATCH 39/47] fix(core): persist candidate-free dialogue judgments Add versioned dialogue synthesis and immutable judgment references without action candidates or selection specifications. Preserve exact action-v1 records and apply mode-aware provenance checks on writes and historical restore. Verified 535 judgment tests, strict root/browser TypeScript, public core build, and real SQLite dialogue restoration with zero candidate and selection rows. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- packages/lina-core/src/agents/index.ts | 1 + .../lina-core/src/agents/judgment-dialogue.ts | 396 ++++++++++++++ .../lina-core/src/agents/judgment-store.ts | 124 +++-- .../test/judgment-dialogue-contract.test.ts | 513 ++++++++++++++++++ ...ment-policy-evidence-replay-review.test.ts | 6 +- .../lina-core/test/judgment-round.test.ts | 4 +- 6 files changed, 1006 insertions(+), 38 deletions(-) create mode 100644 packages/lina-core/src/agents/judgment-dialogue.ts create mode 100644 packages/lina-core/test/judgment-dialogue-contract.test.ts diff --git a/packages/lina-core/src/agents/index.ts b/packages/lina-core/src/agents/index.ts index 4b111f8..36285bd 100644 --- a/packages/lina-core/src/agents/index.ts +++ b/packages/lina-core/src/agents/index.ts @@ -96,3 +96,4 @@ export { JudgmentStore } from "./judgment-store.ts"; export { behaviorFingerprint } from "./behavior-validation.ts"; export * from "./judgment-candidates.ts"; +export * from "./judgment-dialogue.ts"; diff --git a/packages/lina-core/src/agents/judgment-dialogue.ts b/packages/lina-core/src/agents/judgment-dialogue.ts new file mode 100644 index 0000000..9318ece --- /dev/null +++ b/packages/lina-core/src/agents/judgment-dialogue.ts @@ -0,0 +1,396 @@ +import { validId } from "../context/validation.ts"; +import { + type Assessment, + type JudgmentSnapshotRef, + MODULE_KINDS, + type ModuleKind, + type ObjectiveProfileRef, + type ResolutionRecord, + type RoundStatus, + SITUATIONS, + type Situation, +} from "./judgment.ts"; +import { + canonicalJson, + judgmentDigest, + parseAssessment, + parseJudgmentSnapshotRef, + parseObjectiveProfileRef, + parseResolutionRecord, + snapshotDigest, +} from "./judgment-validation.ts"; +import { boundedId, boundedText } from "./validation.ts"; + +type DialogueProvenance = { + roundId: string; + snapshotDigest: string; + objectiveProfileRefs: Record; + assessmentDigests: Record; + policyId: string; + policyRevision: number; + requestId: string; + requestDigest: string; + sourceDigest: string; +}; +/** JSON capability v2, stored in the unchanged v1 ledger DDL. Old readers reject + * this version; legacy action v1 bodies/digests are never rewritten or decorated. + * This is synthesis provenance, not response acceptance or action selection. */ +export type DialogueResolutionRecord = DialogueProvenance & { + schemaVersion: 2; + mode: "dialogue"; + situation: Situation; + recommendations: Record; + alignment: "aligned" | "conflicted" | "incomplete"; + conflicts: Array<{ moduleKind: ModuleKind; reason: string }>; + concessions: Array<{ moduleKind: ModuleKind; reason: string }>; + synthesis: string; + rationale: string; + status: Exclude; + holdReason: string | null; +}; +export type StoredResolutionRecord = + | ResolutionRecord + | DialogueResolutionRecord; +export type DialogueJudgmentRef = Omit< + DialogueProvenance, + "assessmentDigests" +> & { + schemaVersion: 1; + agentId: string; + scopeId: string; + assessmentDigests: Record; + resolutionDigest: string; + refDigest: string; +}; + +function fields( + value: unknown, + keys: readonly string[], + label: string, + version?: number, +): Record { + if ( + value === null || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) + throw Error(`invalid ${label}`); + const row = value as Record; + if (version !== undefined && row["schemaVersion"] !== version) + throw Error(`Unsupported ${label} schema version`); + for (const key of Object.keys(row)) + if (!keys.includes(key)) throw Error(`unknown ${label} field ${key}`); + for (const key of keys) + if (!Object.hasOwn(row, key)) throw Error(`missing ${label} field ${key}`); + return row; +} +function member( + value: unknown, + values: readonly T[], + label: string, +): T { + const found = values.find((item) => item === value); + if (found === undefined) throw Error(`invalid ${label}`); + return found; +} +function digest(value: unknown): string { + const parsed = boundedId(value, "dialogue digest"); + if (!/^[a-f0-9]{64}$/.test(parsed)) throw Error("invalid dialogue digest"); + return parsed; +} +function modules( + value: unknown, + parse: (value: unknown) => T, +): Record { + const row = fields(value, MODULE_KINDS, "dialogue modules"); + return { + clotho: parse(row["clotho"]), + lachesis: parse(row["lachesis"]), + atropos: parse(row["atropos"]), + }; +} +function policyRevision(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + throw Error("invalid dialogue policy revision"); + return value; +} +function reasons(value: unknown): DialogueResolutionRecord["conflicts"] { + if (!Array.isArray(value) || value.length > 256) + throw Error("invalid dialogue reasons"); + return Array.from(value, (item) => { + const row = fields(item, ["moduleKind", "reason"], "dialogue reason"); + return { + moduleKind: member(row["moduleKind"], MODULE_KINDS, "module kind"), + reason: boundedText(row["reason"], "dialogue reason"), + }; + }); +} +const PROVENANCE_KEYS = [ + "roundId", + "snapshotDigest", + "objectiveProfileRefs", + "assessmentDigests", + "policyId", + "policyRevision", + "requestId", + "requestDigest", + "sourceDigest", +] as const; +function provenance(row: Record): DialogueProvenance { + return { + roundId: boundedId(row["roundId"], "round id"), + snapshotDigest: digest(row["snapshotDigest"]), + objectiveProfileRefs: modules( + row["objectiveProfileRefs"], + parseObjectiveProfileRef, + ), + assessmentDigests: modules(row["assessmentDigests"], (value) => + value === null ? null : digest(value), + ), + policyId: boundedId(row["policyId"], "policy id"), + policyRevision: policyRevision(row["policyRevision"]), + requestId: validId(row["requestId"], "request id"), + requestDigest: digest(row["requestDigest"]), + sourceDigest: digest(row["sourceDigest"]), + }; +} +export function parseDialogueResolutionRecord( + value: unknown, +): DialogueResolutionRecord { + const row = fields( + value, + [ + "schemaVersion", + "mode", + ...PROVENANCE_KEYS, + "situation", + "recommendations", + "alignment", + "conflicts", + "concessions", + "synthesis", + "rationale", + "status", + "holdReason", + ], + "dialogue resolution", + 2, + ); + const result: DialogueResolutionRecord = { + schemaVersion: 2, + mode: member(row["mode"], ["dialogue"], "dialogue mode"), + ...provenance(row), + situation: member(row["situation"], SITUATIONS, "situation"), + recommendations: modules(row["recommendations"], (value) => + value === null ? null : boundedText(value, "dialogue recommendation"), + ), + alignment: member( + row["alignment"], + ["aligned", "conflicted", "incomplete"], + "dialogue alignment", + ), + conflicts: reasons(row["conflicts"]), + concessions: reasons(row["concessions"]), + synthesis: boundedText(row["synthesis"], "dialogue synthesis"), + rationale: boundedText(row["rationale"], "dialogue rationale"), + status: member( + row["status"], + ["resolved", "held", "deferred"], + "resolution status", + ), + holdReason: + row["holdReason"] === null + ? null + : boundedText(row["holdReason"], "hold reason"), + }; + if ((result.status !== "resolved") !== (result.holdReason !== null)) + throw Error("unresolved status requires hold reason"); + const incomplete = MODULE_KINDS.some( + (m) => result.assessmentDigests[m] === null, + ); + if ( + incomplete !== (result.alignment === "incomplete") || + (incomplete && result.status !== "held") + ) + throw Error("incomplete dialogue requires held status"); + for (const m of MODULE_KINDS) + if ( + (result.assessmentDigests[m] === null) !== + (result.recommendations[m] === null) + ) + throw Error("dialogue recommendation assessment mismatch"); + if ( + result.alignment === "aligned" && + (result.conflicts.length || result.concessions.length) + ) + throw Error("aligned dialogue has no conflicts or concessions"); + if (result.alignment === "conflicted" && result.conflicts.length === 0) + throw Error("conflicted dialogue requires reasons"); + return result; +} +/** Explicit union parser; parseResolutionRecord remains the exact action-v1 parser. */ +export function parseStoredResolutionRecord( + value: unknown, +): StoredResolutionRecord { + return value !== null && + typeof value === "object" && + "schemaVersion" in value && + value.schemaVersion === 2 + ? parseDialogueResolutionRecord(value) + : parseResolutionRecord(value); +} + +/** Pure historical binding. Current objective/intention checks belong only at + * the owner write boundary, never here or in reference restoration. */ +export function validateDialogueResolution( + record: DialogueResolutionRecord, + snapshot: JudgmentSnapshotRef, + assessments: Assessment[], +): void { + if ( + record.roundId !== snapshot.roundId || + record.snapshotDigest !== snapshotDigest(snapshot) || + record.policyId !== snapshot.policyId || + record.policyRevision !== snapshot.policyRevision || + record.situation !== snapshot.situation || + canonicalJson(record.objectiveProfileRefs) !== + canonicalJson(snapshot.objectiveProfileRefs) + ) + throw Error("dialogue snapshot mismatch"); + if ( + !snapshot.sourceRefs.some( + (source) => source.kind === "request" && source.id === record.requestId, + ) + ) + throw Error("dialogue request source mismatch"); + const seen = new Set(); + for (const assessment of assessments) { + const m = assessment.moduleKind; + if (seen.has(m)) throw Error("duplicate dialogue assessment"); + seen.add(m); + if ( + assessment.proposedOptionKeys.length || + assessment.objectiveAssessments.length || + assessment.recommendedOptionKeys.length + ) + throw Error("dialogue forbids action assessments"); + if ( + assessment.snapshotId !== record.roundId || + assessment.snapshotDigest !== record.snapshotDigest || + canonicalJson(assessment.objectiveRef) !== + canonicalJson(record.objectiveProfileRefs[m]) + ) + throw Error("dialogue assessment snapshot mismatch"); + if (record.assessmentDigests[m] !== judgmentDigest(assessment)) + throw Error("dialogue assessment digest mismatch"); + } + for (const m of MODULE_KINDS) + if (seen.has(m) !== (record.assessmentDigests[m] !== null)) + throw Error("missing dialogue assessment"); +} +export function buildDialogueResolution( + input: Omit< + DialogueResolutionRecord, + | "schemaVersion" + | "mode" + | "roundId" + | "snapshotDigest" + | "objectiveProfileRefs" + | "assessmentDigests" + | "policyId" + | "policyRevision" + | "situation" + > & { snapshot: JudgmentSnapshotRef; assessments: Assessment[] }, +): DialogueResolutionRecord { + const { + snapshot: rawSnapshot, + assessments: rawAssessments, + ...synthesis + } = input; + const snapshot = parseJudgmentSnapshotRef(rawSnapshot); + const assessments = rawAssessments.map(parseAssessment); + const assessmentDigest = (module: ModuleKind) => { + const found = assessments.find((a) => a.moduleKind === module); + return found ? judgmentDigest(found) : null; + }; + const result = parseDialogueResolutionRecord({ + ...synthesis, + schemaVersion: 2, + mode: "dialogue", + roundId: snapshot.roundId, + snapshotDigest: snapshotDigest(snapshot), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + assessmentDigests: { + clotho: assessmentDigest("clotho"), + lachesis: assessmentDigest("lachesis"), + atropos: assessmentDigest("atropos"), + }, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + }); + validateDialogueResolution(result, snapshot, assessments); + return result; +} +export function parseDialogueJudgmentRef(value: unknown): DialogueJudgmentRef { + const row = fields( + value, + [ + "schemaVersion", + ...PROVENANCE_KEYS, + "agentId", + "scopeId", + "resolutionDigest", + "refDigest", + ], + "dialogue judgment ref", + 1, + ); + const result: DialogueJudgmentRef = { + schemaVersion: 1, + ...provenance(row), + agentId: boundedId(row["agentId"], "agent id"), + scopeId: boundedId(row["scopeId"], "scope id"), + assessmentDigests: modules(row["assessmentDigests"], digest), + resolutionDigest: digest(row["resolutionDigest"]), + refDigest: digest(row["refDigest"]), + }; + if (result.refDigest !== judgmentDigest({ ...result, refDigest: undefined })) + throw Error("dialogue ref digest mismatch"); + return result; +} +export function buildDialogueJudgmentRef(input: { + snapshot: JudgmentSnapshotRef; + assessments: Assessment[]; + resolution: DialogueResolutionRecord; +}): DialogueJudgmentRef { + const snapshot = parseJudgmentSnapshotRef(input.snapshot); + const record = parseDialogueResolutionRecord(input.resolution); + validateDialogueResolution( + record, + snapshot, + input.assessments.map(parseAssessment), + ); + if (record.status !== "resolved") + throw Error("dialogue ref requires resolved synthesis"); + const value = { + schemaVersion: 1, + roundId: record.roundId, + snapshotDigest: record.snapshotDigest, + objectiveProfileRefs: record.objectiveProfileRefs, + assessmentDigests: record.assessmentDigests, + policyId: record.policyId, + policyRevision: record.policyRevision, + requestId: record.requestId, + requestDigest: record.requestDigest, + sourceDigest: record.sourceDigest, + agentId: snapshot.agentId, + scopeId: snapshot.scopeId, + resolutionDigest: judgmentDigest(record), + }; + return parseDialogueJudgmentRef({ + ...value, + refDigest: judgmentDigest(value), + }); +} diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index ed83692..9f574c1 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -17,6 +17,15 @@ import { type SelectionSpec, } from "./judgment.ts"; import { type CandidateSet, parseCandidateSet } from "./judgment-candidates.ts"; +import { + buildDialogueJudgmentRef, + type DialogueJudgmentRef, + type DialogueResolutionRecord, + parseDialogueJudgmentRef, + parseStoredResolutionRecord, + type StoredResolutionRecord, + validateDialogueResolution, +} from "./judgment-dialogue.ts"; import { validateCandidateEvidence } from "./judgment-evidence.ts"; import { PERSONAL_POLICY_V1, resolvePersonalRound } from "./judgment-policy.ts"; import { initializeJudgmentSchema } from "./judgment-schema.ts"; @@ -31,7 +40,6 @@ import { parseJudgmentSnapshotRef, parseObjectiveProfile, parseObjectiveProfileRef, - parseResolutionRecord, parseSelectionSpec, snapshotDigest, transitionIntention, @@ -47,14 +55,21 @@ function revision(value: number, minimum = 0): number { return value; } -function validateCandidateBinding( - resolution: ResolutionRecord, +function validateResolutionBinding( + resolution: StoredResolutionRecord, selection: SelectionSpec | null, set: AssessmentSet | null, snapshot: JudgmentSnapshotRef, candidates: CandidateSet | null, lookupIntention: (id: string) => IntentionRecord | null, + assessments: Assessment[], ): void { + if (resolution.schemaVersion === 2) { + if (selection !== null || candidates !== null) + throw Error("dialogue forbids action candidates and selection"); + validateDialogueResolution(resolution, snapshot, assessments); + return; + } // Without complete evidence only a non-executable failure receipt is trusted. // Its descriptive fields are not policy-replayed; deferred is not a fallback. if (!candidates || !set) { @@ -462,12 +477,12 @@ export class JudgmentStore { recordResolution( roundId: string, - resolution: ResolutionRecord, + resolution: StoredResolutionRecord, spec: SelectionSpec | null, ): void { this.assertOpen(); const id = boundedId(roundId, "round id"); - const parsed = parseResolutionRecord(resolution); + const parsed = parseStoredResolutionRecord(resolution); const selection = spec === null ? null : parseSelectionSpec(spec); this.transaction(() => { const round = this.requireOpenRound(id); @@ -492,8 +507,10 @@ export class JudgmentStore { parsed.policyRevision !== round.snapshot.policyRevision ) throw Error("resolution snapshot mismatch"); + const needsSelection = + parsed.schemaVersion === 1 && parsed.status === "resolved"; if ( - (parsed.status === "resolved") !== (selection !== null) || + needsSelection !== (selection !== null) || (selection && (selection.roundId !== id || selection.snapshotDigest !== round.snapshotDigest)) @@ -513,13 +530,14 @@ export class JudgmentStore { assessments.length === MODULE_KINDS.length ? this.assessmentSet(id) : null; - validateCandidateBinding( + validateResolutionBinding( parsed, selection, set, round.snapshot, candidates, (id) => this.getIntention(id), + assessments, ); const now = this.now(); this.db @@ -541,7 +559,16 @@ export class JudgmentStore { }); } - getResolution(roundId: string): ResolutionRecord | null { + getResolution(roundId: string): StoredResolutionRecord | null; + getResolution(roundId: string, mode: "action"): ResolutionRecord | null; + getResolution( + roundId: string, + mode: "dialogue", + ): DialogueResolutionRecord | null; + getResolution( + roundId: string, + mode?: "action" | "dialogue", + ): StoredResolutionRecord | null { return this.transaction(() => { const row = this.db .prepare( @@ -550,13 +577,43 @@ export class JudgmentStore { .get(boundedId(roundId, "round id")); if (!row) return null; const { body: json, digest } = row; - const parsed = parseResolutionRecord(JSON.parse(String(json))); + const parsed = parseStoredResolutionRecord(JSON.parse(String(json))); if (judgmentDigest(parsed) !== digest) throw Error("resolution digest mismatch"); + if (mode !== undefined && mode !== "action" && mode !== "dialogue") + throw Error("invalid resolution mode"); + if ( + mode !== undefined && + mode !== (parsed.schemaVersion === 1 ? "action" : "dialogue") + ) + return null; return parsed; }, false); } + /** Reconstructible immutable reference, not a response-acceptance receipt. */ + dialogueJudgmentRef(roundId: string): DialogueJudgmentRef | null { + return this.transaction(() => { + const resolution = this.getResolution(roundId, "dialogue"); + if (resolution?.status !== "resolved") return null; + const round = this.getRound(roundId); + if (!round) throw Error("dialogue round missing"); + return buildDialogueJudgmentRef({ + snapshot: round.snapshot, + assessments: this.storedAssessments(roundId), + resolution, + }); + }, false); + } + + validateDialogueJudgmentRef(value: DialogueJudgmentRef): void { + const ref = parseDialogueJudgmentRef(value); + this.transaction(() => { + if (body(ref) !== body(this.dialogueJudgmentRef(ref.roundId))) + throw Error("dialogue judgment ref mismatch"); + }, false); + } + getSelectionSpec(roundId: string): SelectionSpec | null { return this.transaction(() => { const row = this.db @@ -696,18 +753,15 @@ export class JudgmentStore { const scope = boundedId(scopeId, "scope id"); if (status !== undefined && !INTENTION_STATUSES.includes(status)) throw Error("invalid intention status"); + const statement = this.db.prepare( + status === undefined + ? "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id" + : "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", + ); const rows = status === undefined - ? this.db - .prepare( - "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? ORDER BY intention_id", - ) - .all(agent, scope) - : this.db - .prepare( - "SELECT body, digest FROM intention_records WHERE agent_id = ? AND scope_id = ? AND status = ? ORDER BY intention_id", - ) - .all(agent, scope, status); + ? statement.all(agent, scope) + : statement.all(agent, scope, status); return rows.map(({ body: json, digest }) => { const parsed = parseIntentionRecord(JSON.parse(String(json))); if (intentionDigest(parsed) !== digest) @@ -841,7 +895,7 @@ export class JudgmentStore { const resolutions = rows( "resolution_records", "resolution", - parseResolutionRecord, + parseStoredResolutionRecord, (p) => ({ round_id: p.roundId }), ); const statuses = new Map( @@ -879,7 +933,10 @@ export class JudgmentStore { selections.map(({ value }) => value.roundId), ); for (const { value } of resolutions) { - if ((value.status === "resolved") !== selectedRounds.has(value.roundId)) + if ( + (value.schemaVersion === 1 && value.status === "resolved") !== + selectedRounds.has(value.roundId) + ) throw Error("selection spec metadata mismatch"); } const snapshots = new Map( @@ -994,24 +1051,25 @@ export class JudgmentStore { const snapshot = snapshots.get(record.roundId); if (!snapshot) throw Error("resolution snapshot mismatch"); const assessments = sets.get(record.roundId) ?? []; - const set = - assessments.length === MODULE_KINDS.length - ? parseAssessmentSet({ - schemaVersion: 1, - roundId: record.roundId, - snapshotDigest: snapshotDigest(snapshot), - assessments: MODULE_KINDS.map((module) => - assessments.find((a) => a.moduleKind === module), - ), - }) - : null; - validateCandidateBinding( + let set: AssessmentSet | null = null; + if (assessments.length === MODULE_KINDS.length) { + set = parseAssessmentSet({ + schemaVersion: 1, + roundId: record.roundId, + snapshotDigest: snapshotDigest(snapshot), + assessments: MODULE_KINDS.map((module) => + assessments.find((a) => a.moduleKind === module), + ), + }); + } + validateResolutionBinding( record, selectionsByRound.get(record.roundId) ?? null, set, snapshot, candidatesByRound.get(record.roundId) ?? null, (id) => intentionsById.get(id) ?? null, + assessments, ); } if (this.db.prepare("PRAGMA foreign_key_check").all().length > 0) diff --git a/packages/lina-core/test/judgment-dialogue-contract.test.ts b/packages/lina-core/test/judgment-dialogue-contract.test.ts new file mode 100644 index 0000000..21b7999 --- /dev/null +++ b/packages/lina-core/test/judgment-dialogue-contract.test.ts @@ -0,0 +1,513 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import * as api from "../src/agents/index.ts"; +import { Fixture } from "./fixture.ts"; + +let fixture: Fixture; +let path: string; +let store: api.JudgmentStore; +let snapshot: api.JudgmentSnapshotRef; +let assessments: api.Assessment[]; +beforeEach(() => { + fixture = new Fixture(); + path = join(fixture.dir, "dialogue.sqlite"); + store = new api.JudgmentStore(path); + for (const moduleKind of api.MODULE_KINDS) { + const ref = store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: moduleKind, + moduleKind, + revision: 1, + objective: "Compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + store.activateObjectiveProfile("agent", "scope", ref); + } + const refs = store.activeObjectiveProfiles("agent", "scope"); + if (!refs) throw Error("missing profiles"); + snapshot = { + schemaVersion: 1, + roundId: "dialogue", + agentId: "agent", + scopeId: "scope", + sourceRefs: [{ kind: "request", id: "request", revision: 0 }], + workingRevision: 0, + instructionRevision: 0, + policyId: "personal.v1", + policyRevision: 1, + identityRevision: 0, + domainRevisions: {}, + intentionRevision: store.intentionRevision("agent", "scope"), + objectiveProfileRefs: refs, + observationRef: null, + frozenNeuralRef: null, + situation: "user_request", + clockId: "clock", + sequence: 1, + bindingGeneration: 0, + }; + assessments = api.MODULE_KINDS.map((moduleKind) => { + const input = { + snapshotDigest: api.snapshotDigest(snapshot), + objectiveRef: refs[moduleKind], + mechanismRevision: 1, + }; + return api.parseAssessment({ + schemaVersion: 1, + moduleKind, + snapshotId: snapshot.roundId, + ...input, + inputDigest: api.assessmentInputDigest(input), + completeText: `Initial ${moduleKind} judgment`, + evidenceRefs: ["source"], + proposedOptionKeys: [], + objectiveAssessments: [], + recommendedOptionKeys: [], + detail: { + kind: { + clotho: "forecasts", + lachesis: "values", + atropos: "continuity", + }[moduleKind], + body: {}, + }, + diagnostics: {}, + }); + }); +}); +afterEach(() => { + store.close(); + fixture.close(); +}); +function record() { + return { + schemaVersion: 2 as const, + mode: "dialogue" as const, + roundId: snapshot.roundId, + snapshotDigest: api.snapshotDigest(snapshot), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + assessmentDigests: { + clotho: api.judgmentDigest(assessments[0]), + lachesis: api.judgmentDigest(assessments[1]), + atropos: api.judgmentDigest(assessments[2]), + }, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + requestId: "request", + requestDigest: api.judgmentDigest({ request: "original" }), + sourceDigest: api.judgmentDigest({ source: "original" }), + recommendations: { + clotho: "Explain likely outcomes", + lachesis: "Respect the user's priorities", + atropos: "Retain the original commitment", + }, + alignment: "aligned" as const, + conflicts: [], + concessions: [], + synthesis: "A textual response synthesis", + rationale: "Reasons for this synthesis", + status: "resolved" as const, + holdReason: null, + }; +} +function seed(count = 3) { + store.openRound(snapshot); + for (const a of assessments.slice(0, count)) store.putAssessment(a); +} +function reopen() { + store.close(); + store = new api.JudgmentStore(path); +} +function db() { + return fixture.keep(new DatabaseSync(path)); +} + +test("3996179135 action v1 bytes and digest remain unchanged alongside dialogue v2", () => { + const actionSnapshot = { + ...snapshot, + roundId: "historic-action", + sequence: 0, + }; + const action = api.parseResolutionRecord({ + schemaVersion: 1, + roundId: actionSnapshot.roundId, + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + order: ["atropos", "clotho", "lachesis"], + recommendations: { clotho: [], lachesis: [], atropos: [] }, + conflicts: [], + excluded: [], + abstentions: [ + { + optionKey: "historical", + moduleKind: "clotho", + reason: "historic diagnostic text", + }, + ], + ranking: [], + conceded: [], + status: "held", + holdReason: "historical missing input", + }); + store.openRound(actionSnapshot); + store.recordResolution(actionSnapshot.roundId, action, null); + const sql = db(); + const original = sql + .prepare("SELECT body,digest FROM resolution_records WHERE round_id = ?") + .get(actionSnapshot.roundId); + seed(); + store.recordResolution(snapshot.roundId, record(), null); + reopen(); + expect(api.parseStoredResolutionRecord(action)).toEqual(action); + expect(store.getResolution(actionSnapshot.roundId, "action")).toEqual(action); + expect(store.getResolution(actionSnapshot.roundId, "dialogue")).toBeNull(); + expect(store.dialogueJudgmentRef(actionSnapshot.roundId)).toBeNull(); + expect(store.getResolution(snapshot.roundId, "action")).toBeNull(); + expect( + sql + .prepare("SELECT body,digest FROM resolution_records WHERE round_id = ?") + .get(actionSnapshot.roundId), + ).toEqual(original); + expect(original?.["digest"]).toBe(api.judgmentDigest(action)); +}); + +test("3996179135 dialogue v2 golden round-trip retains prose and immutable provenance", () => { + const original = record(); + const parsed = api.parseDialogueResolutionRecord( + JSON.parse(JSON.stringify(original)), + ); + expect(parsed).toEqual(original); + expect(api.judgmentDigest(parsed)).toBe(api.judgmentDigest(original)); + expect(api.parseStoredResolutionRecord(original)).toEqual(original); + const { + schemaVersion: _version, + mode: _mode, + roundId: _round, + snapshotDigest: _digest, + objectiveProfileRefs: _refs, + assessmentDigests: _assessments, + policyId: _policy, + policyRevision: _revision, + situation: _situation, + ...input + } = original; + expect( + api.buildDialogueResolution({ ...input, snapshot, assessments }), + ).toEqual(original); +}); +for (const change of [ + { schemaVersion: 1 }, + { schemaVersion: 3 }, + { mode: "action" }, + { extra: true }, + { ranking: [] }, + { candidates: [] }, + { assessmentSetDigest: "fake" }, + { selectionSpec: null }, + { assessmentDigests: { clotho: "a", lachesis: "b" } }, + { assessmentDigests: { clotho: null, lachesis: null, atropos: null } }, + { recommendations: { clotho: "text", lachesis: "text" } }, + { rationale: " " }, + { synthesis: "" }, + { alignment: "conflicted" }, +]) + test(`3996179135 strict dialogue rejects ${JSON.stringify(change)}`, () => { + expect(() => + api.parseDialogueResolutionRecord({ ...record(), ...change }), + ).toThrow(); + }); + +test("3996179135 resolved candidate-free dialogue persists with zero selection/candidate rows and reconstructible ref", () => { + seed(); + const original = record(); + store.recordResolution(snapshot.roundId, original, null); + const ref = store.dialogueJudgmentRef(snapshot.roundId); + expect(ref).not.toBeNull(); + if (!ref) throw Error("missing dialogue ref"); + expect(api.parseDialogueJudgmentRef(JSON.parse(JSON.stringify(ref)))).toEqual( + ref, + ); + expect(ref.resolutionDigest).toBe(api.judgmentDigest(original)); + expect(ref.assessmentDigests).toEqual(original.assessmentDigests); + expect(ref.requestDigest).toBe(original.requestDigest); + expect(ref.sourceDigest).toBe(original.sourceDigest); + expect(() => store.validateDialogueJudgmentRef(ref)).not.toThrow(); + for (const changed of [ + { ...ref, schemaVersion: 2 }, + { ...ref, extra: true }, + { + ...ref, + assessmentDigests: { + clotho: ref.assessmentDigests.clotho, + lachesis: ref.assessmentDigests.lachesis, + }, + }, + { ...ref, refDigest: api.judgmentDigest("wrong") }, + ]) + expect(() => api.parseDialogueJudgmentRef(changed)).toThrow(); + for (const field of [ + "requestDigest", + "sourceDigest", + "snapshotDigest", + "resolutionDigest", + ] as const) { + const changed = { ...ref, [field]: api.judgmentDigest("tampered") }; + const forged = { + ...changed, + refDigest: api.judgmentDigest({ ...changed, refDigest: undefined }), + }; + expect(() => store.validateDialogueJudgmentRef(forged)).toThrow(); + } + const sql = db(); + for (const table of ["candidate_sets", "selection_specs"]) + expect(sql.prepare(`SELECT count(*) AS n FROM ${table}`).get()).toEqual({ + n: 0, + }); + expect(sql.prepare("SELECT count(*) AS n FROM assessments").get()).toEqual({ + n: 3, + }); + const first = assessments[0]; + if (!first) throw Error("missing fixture assessment"); + expect(() => store.putAssessment(first)).toThrow("not open"); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(original); + expect(store.getResolution(snapshot.roundId, "dialogue")).toEqual(original); + expect(store.dialogueJudgmentRef(snapshot.roundId)).toEqual(ref); + expect(store.assessmentSet(snapshot.roundId).assessments).toEqual( + assessments, + ); + expect(store.getSelectionSpec(snapshot.roundId)).toBeNull(); +}); + +test("3996179135 dialogue forbids SelectionSpec and action candidate closure atomically", () => { + seed(); + const value = record(); + const specBody = { + schemaVersion: 1, + roundId: snapshot.roundId, + snapshotDigest: api.snapshotDigest(snapshot), + assessmentSetDigest: api.judgmentDigest( + store.assessmentSet(snapshot.roundId), + ), + objectiveProfileRefs: snapshot.objectiveProfileRefs, + resolutionDigest: api.judgmentDigest(value), + policyId: snapshot.policyId, + policyRevision: snapshot.policyRevision, + situation: snapshot.situation, + lambda: 0, + candidates: [{ optionKey: "fake", p0: 1, b: null }], + eligibleDigest: api.judgmentDigest(["fake"]), + }; + const spec = api.parseSelectionSpec({ + ...specBody, + specDigest: api.judgmentDigest(specBody), + }); + expect(() => store.recordResolution(snapshot.roundId, value, spec)).toThrow(); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + const option = api.buildCanonicalOption({ + kind: "noop", + actor: { agentId: "agent", scopeId: "scope" }, + targetId: null, + args: {}, + preconditions: { kind: "noop", reason: "wait" }, + }); + store.closeCandidateSet( + api.buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: api.snapshotDigest(snapshot), + options: [option], + eligibility: [ + { optionKey: option.optionKey, eligible: true, reason: null }, + ], + intentionRefs: [], + }), + ); + expect(() => store.recordResolution(snapshot.roundId, value, null)).toThrow(); + expect(store.getRound(snapshot.roundId)?.status).toBe("open"); +}); +for (const tamper of [ + "missing", + "assessmentDigest", + "objective", + "snapshot", + "request", +] as const) { + test(`3996179135 dialogue rejects ${tamper} on write and rehashed historical restore`, () => { + seed(); + const original = record(); + const changed = structuredClone(original); + if (tamper === "assessmentDigest") + changed.assessmentDigests.clotho = api.judgmentDigest("changed"); + if (tamper === "objective") + changed.objectiveProfileRefs.clotho.digest = + api.judgmentDigest("changed"); + if (tamper === "snapshot") + changed.snapshotDigest = api.judgmentDigest("changed"); + if (tamper === "request") changed.requestId = "other-request"; + if (tamper === "missing") + db().exec("DELETE FROM assessments WHERE module_kind = 'atropos'"); + expect(() => + store.recordResolution(snapshot.roundId, changed, null), + ).toThrow(); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + if (tamper === "missing") { + const third = assessments[2]; + if (!third) throw Error("missing fixture assessment"); + store.putAssessment(third); + } + store.recordResolution(snapshot.roundId, original, null); + if (tamper === "missing") + db().exec("DELETE FROM assessments WHERE module_kind = 'atropos'"); + else + db() + .prepare("UPDATE resolution_records SET body = ?, digest = ?") + .run(JSON.stringify(changed), api.judgmentDigest(changed)); + expect(() => store.getResolution(snapshot.roundId)).toThrow(); + reopen(); + expect(() => store.dialogueJudgmentRef(snapshot.roundId)).toThrow(); + }); +} + +test("3996179135 incomplete held dialogue explicitly preserves only available module refs", () => { + seed(2); + const value = { + ...record(), + status: "held" as const, + holdReason: "missing module", + alignment: "incomplete" as const, + assessmentDigests: { ...record().assessmentDigests, atropos: null }, + recommendations: { ...record().recommendations, atropos: null }, + }; + store.recordResolution(snapshot.roundId, value, null); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(value); + expect(store.dialogueJudgmentRef(snapshot.roundId)).toBeNull(); +}); + +test("3996179135 conflicted dialogue retains textual conflict/concession reasons without option keys", () => { + seed(); + const value = { + ...record(), + alignment: "conflicted" as const, + conflicts: [ + { + moduleKind: "clotho" as const, + reason: "Forecast conflicts with continuity", + }, + ], + concessions: [ + { + moduleKind: "lachesis" as const, + reason: "Give continuity priority here", + }, + ], + }; + store.recordResolution(snapshot.roundId, value, null); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(value); +}); + +test("3998853070 dialogue checks intention currentness only on resolution, not history", () => { + seed(); + const original = record(); + store.recordResolution(snapshot.roundId, original, null); + const second = { ...snapshot, roundId: "second", sequence: 2 }; + store.openRound(second); + store.putIntention({ + schemaVersion: 1, + intentionId: "new-intention", + agentId: snapshot.agentId, + scopeId: snapshot.scopeId, + revision: 0, + kind: "user_commitment", + purposeRef: "purpose", + text: "New commitment", + acceptance: { + sourceRef: "request", + acceptedBy: "user", + policyRevision: 1, + acceptedAt: "2026-09-12T00:00:00.000Z", + }, + priority: 0, + deadline: null, + completionCondition: "receipt", + abortConditions: [], + relatedIntentions: [], + status: "proposed", + history: [], + }); + expect(() => + store.recordResolution( + second.roundId, + { ...original, roundId: second.roundId }, + null, + ), + ).toThrow("stale intention revision"); + expect(store.getRound(second.roundId)?.status).toBe("open"); + expect(store.getResolution(second.roundId)).toBeNull(); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(original); + expect(store.dialogueJudgmentRef(snapshot.roundId)).not.toBeNull(); +}); + +test("3996179135 action-shaped initial assessment cannot masquerade as dialogue", () => { + const first = assessments[0]; + if (!first) throw Error("missing fixture assessment"); + const changed = api.parseAssessment({ + ...first, + proposedOptionKeys: ["fake-option"], + }); + assessments[0] = changed; + seed(); + expect(() => + store.recordResolution(snapshot.roundId, record(), null), + ).toThrow("dialogue forbids action assessments"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); +}); + +test("3996179135 rehashed assessment rewrite disagrees with immutable dialogue refs", () => { + seed(); + store.recordResolution(snapshot.roundId, record(), null); + const first = assessments[0]; + if (!first) throw Error("missing fixture assessment"); + const changed = { ...first, completeText: "Rewritten initial opinion" }; + db() + .prepare( + "UPDATE assessments SET body = ?, digest = ? WHERE module_kind = 'clotho'", + ) + .run(JSON.stringify(changed), api.judgmentDigest(changed)); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "dialogue assessment digest mismatch", + ); + reopen(); + expect(() => store.getResolution(snapshot.roundId)).toThrow( + "dialogue assessment digest mismatch", + ); +}); + +test("3998853057 dialogue rejects new stale objective resolution but keeps already resolved history", () => { + seed(); + const original = record(); + store.recordResolution(snapshot.roundId, original, null); + const second = { ...snapshot, roundId: "second", sequence: 2 }; + store.openRound(second); + const profile = store.getObjectiveProfile("clotho", 1); + if (!profile) throw Error("missing fixture profile"); + const next = store.putObjectiveProfile({ ...profile, revision: 2 }); + store.activateObjectiveProfile("agent", "scope", next); + expect(() => + store.recordResolution( + second.roundId, + { ...original, roundId: second.roundId }, + null, + ), + ).toThrow("stale objective profile refs"); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(original); + expect(store.dialogueJudgmentRef(snapshot.roundId)).not.toBeNull(); + expect(store.getResolution(second.roundId)).toBeNull(); +}); diff --git a/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts b/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts index 5eec8b3..ced0fdc 100644 --- a/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts +++ b/packages/lina-core/test/judgment-policy-evidence-replay-review.test.ts @@ -72,7 +72,7 @@ for (const moduleKind of ["atropos", "clotho"] as const) expect( reopened.getSelectionSpec(input.snapshot.roundId)?.candidates[0]?.p0, ).toBe(1); - expect(reopened.getResolution(input.snapshot.roundId)?.excluded).toEqual( - [], - ); + expect( + reopened.getResolution(input.snapshot.roundId, "action")?.excluded, + ).toEqual([]); }); diff --git a/packages/lina-core/test/judgment-round.test.ts b/packages/lina-core/test/judgment-round.test.ts index 8911504..ce22932 100644 --- a/packages/lina-core/test/judgment-round.test.ts +++ b/packages/lina-core/test/judgment-round.test.ts @@ -564,7 +564,7 @@ test("autonomous personal.v1 round persists SelectionSpec and adopted intention, sortedAssessments(reopened.assessmentSet(snapshot.roundId).assessments), ).toEqual(sortedAssessments(assessments)); expect(reopened.getResolution(snapshot.roundId)).toEqual(resolution); - expect(reopened.getResolution(snapshot.roundId)?.order).toEqual([ + expect(reopened.getResolution(snapshot.roundId, "action")?.order).toEqual([ ...AUTONOMOUS_ORDER, ]); expect(reopened.getSelectionSpec(snapshot.roundId)).toEqual(spec); @@ -575,7 +575,7 @@ test("user_request personal.v1 round sets lambda 0 and atropos-first order", () const store = open(); const { snapshot } = playRound(store, "user_request"); const spec = store.getSelectionSpec(snapshot.roundId); - const resolution = store.getResolution(snapshot.roundId); + const resolution = store.getResolution(snapshot.roundId, "action"); expect(spec).not.toBeNull(); expect(resolution).not.toBeNull(); if (spec === null || resolution === null) From 5484813b9c3e391a0d2ff6f38baa76bb63db731d Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:08:26 +0900 Subject: [PATCH 40/47] fix(core): validate source intentions before transitions Parse restored intention records before reading status, acceptance, revision, or history. Reject invalid source evidence while preserving valid lifecycle transitions and input immutability. Clarify the retained legacy infeasible receipt stage. Verified four failing-first malformed-source cases, 539 judgment tests, strict TypeScript, and direct public API use. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../src/agents/judgment-validation.ts | 13 +++---- packages/lina-core/src/agents/judgment.ts | 1 + .../lina-core/test/judgment-contract.test.ts | 36 ++++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 89a0ff5..1af638d 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -955,6 +955,7 @@ export function transitionIntention( record: IntentionRecord, transition: Omit, ): IntentionRecord { + const source = parseIntentionRecord(record); for (const key of Reflect.ownKeys(transition)) if ( typeof key !== "string" || @@ -962,19 +963,19 @@ export function transitionIntention( ) throw Error("invalid intention transition"); const entry = parseIntentionTransition({ - from: record.status, + from: source.status, to: transition.to, reason: transition.reason, evidenceRef: transition.evidenceRef, at: transition.at, }); - validateTransition(entry, record.acceptance.sourceRef); - if (record.history.length >= MAX_LIST) + validateTransition(entry, source.acceptance.sourceRef); + if (source.history.length >= MAX_LIST) throw Error("invalid intention history"); return { - ...record, - revision: record.revision + 1, + ...source, + revision: source.revision + 1, status: entry.to, - history: [...record.history, entry], + history: [...source.history, entry], }; } diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts index c749d9d..62e8909 100644 --- a/packages/lina-core/src/agents/judgment.ts +++ b/packages/lina-core/src/agents/judgment.ts @@ -44,6 +44,7 @@ export type RoundStatus = (typeof ROUND_STATUSES)[number]; export const EXCLUSION_STAGES = [ "host_eligibility", "commitment_protection", + // Legacy receipt stage; current personal.v1 prerequisites use Host eligibility. "infeasible", ] as const; export type ExclusionStage = (typeof EXCLUSION_STAGES)[number]; diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index f196462..1700c30 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -681,10 +681,14 @@ test("intention acceptance requires user authority only for user commitments", ( acceptance: { ...intention.acceptance, acceptedBy }, }; if (kind === "user_commitment" && acceptedBy === "host_autonomy") { - for (const candidate of [record, adopt(record)]) + const adopted = { ...adopt(), kind, acceptance: record.acceptance }; + for (const candidate of [record, adopted]) expect(() => parseIntentionRecord(candidate)).toThrow( "user commitment requires user acceptance", ); + expect(() => adopt(record)).toThrow( + "user commitment requires user acceptance", + ); } else { expect(parseIntentionRecord(record)).toEqual(record); expect(parseIntentionRecord(adopt(record))).toEqual(adopt(record)); @@ -774,6 +778,36 @@ test("intention transitions complete the legal lifecycle without mutating input" ).toThrow(`invalid intention transition: completed -> ${to}`); }); +const malformedTransitionSources: IntentionRecord[] = [ + { ...intention, revision: -1 }, + { ...intention, revision: 1 }, + { + ...intention, + acceptance: { ...intention.acceptance, acceptedBy: "host_autonomy" }, + }, + { + ...intention, + relatedIntentions: [ + { intentionId: intention.intentionId, relation: "depends" as const }, + ], + }, +]; +test.each(malformedTransitionSources)( + "3998986145 transitions reject malformed source records: %j", + (record) => { + const before = structuredClone(record); + expect(() => + transitionIntention(record, { + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: transition.at, + }), + ).toThrow(); + expect(record).toEqual(before); + }, +); + test("caller-supplied from cannot skip intention states", () => { const forged = { ...transition, From fb1551912e6a4e3d5b09200384961febfa9ea2e6 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:25:54 +0900 Subject: [PATCH 41/47] fix(core): close dialogue and snapshot boundary gaps Allow incomplete dialogue to persist as deferred after budget exhaustion while still rejecting resolved incomplete records. Freeze only one revision per logical source kind and ID. Verified RED-to-GREEN boundary cases, 541 judgment tests, strict TypeScript, and real SQLite deferred/reopen and contradictory-source rejection. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-dialogue.ts | 4 +-- .../src/agents/judgment-validation.ts | 2 +- .../lina-core/test/judgment-contract.test.ts | 22 ++++++++++++ .../test/judgment-dialogue-contract.test.ts | 35 +++++++++++-------- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-dialogue.ts b/packages/lina-core/src/agents/judgment-dialogue.ts index 9318ece..4c708bf 100644 --- a/packages/lina-core/src/agents/judgment-dialogue.ts +++ b/packages/lina-core/src/agents/judgment-dialogue.ts @@ -211,9 +211,9 @@ export function parseDialogueResolutionRecord( ); if ( incomplete !== (result.alignment === "incomplete") || - (incomplete && result.status !== "held") + (incomplete && result.status === "resolved") ) - throw Error("incomplete dialogue requires held status"); + throw Error("invalid incomplete dialogue status"); for (const m of MODULE_KINDS) if ( (result.assessmentDigests[m] === null) !== diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 1af638d..780be8c 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -307,7 +307,7 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { }, "source refs", ), - (source) => JSON.stringify([source.kind, source.id, source.revision]), + (source) => JSON.stringify([source.kind, source.id]), "source refs", ); const domains = object(row["domainRevisions"], "domain revisions"); diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 1700c30..723cc06 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -436,6 +436,28 @@ test("snapshot source IDs use context owner bounds without widening agent or unk ); }); +test("3999054856 snapshot rejects conflicting revisions for one logical source", () => { + expect(() => + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [ + { kind: "request", id: "same-source", revision: 1 }, + { kind: "request", id: "same-source", revision: 2 }, + ], + }), + ).toThrow("duplicate source refs"); + expect( + parseJudgmentSnapshotRef({ + ...snapshot, + sourceRefs: [ + { kind: "request", id: "same-source", revision: 1 }, + { kind: "entry", id: "same-source", revision: 2 }, + { kind: "request", id: "other-source", revision: 2 }, + ], + }).sourceRefs, + ).toHaveLength(3); +}); + test("snapshot requires a bounded policy identity without a fallback", () => { const { policyRevision: _revision, ...withoutRevision } = snapshot; expect(() => parseJudgmentSnapshotRef(withoutRevision)).toThrow(); diff --git a/packages/lina-core/test/judgment-dialogue-contract.test.ts b/packages/lina-core/test/judgment-dialogue-contract.test.ts index 21b7999..d5ac871 100644 --- a/packages/lina-core/test/judgment-dialogue-contract.test.ts +++ b/packages/lina-core/test/judgment-dialogue-contract.test.ts @@ -372,21 +372,26 @@ for (const tamper of [ }); } -test("3996179135 incomplete held dialogue explicitly preserves only available module refs", () => { - seed(2); - const value = { - ...record(), - status: "held" as const, - holdReason: "missing module", - alignment: "incomplete" as const, - assessmentDigests: { ...record().assessmentDigests, atropos: null }, - recommendations: { ...record().recommendations, atropos: null }, - }; - store.recordResolution(snapshot.roundId, value, null); - reopen(); - expect(store.getResolution(snapshot.roundId)).toEqual(value); - expect(store.dialogueJudgmentRef(snapshot.roundId)).toBeNull(); -}); +test.each(["held", "deferred"] as const)( + "3999054852 incomplete %s dialogue preserves only available module refs", + (status) => { + seed(2); + const value = { + ...record(), + status, + holdReason: + status === "held" ? "missing module" : "evaluation budget exhausted", + alignment: "incomplete" as const, + assessmentDigests: { ...record().assessmentDigests, atropos: null }, + recommendations: { ...record().recommendations, atropos: null }, + }; + store.recordResolution(snapshot.roundId, value, null); + reopen(); + expect(store.getResolution(snapshot.roundId)).toEqual(value); + expect(store.getRound(snapshot.roundId)?.status).toBe(status); + expect(store.dialogueJudgmentRef(snapshot.roundId)).toBeNull(); + }, +); test("3996179135 conflicted dialogue retains textual conflict/concession reasons without option keys", () => { seed(); From ee47070c09492f8fc91d389faddb9f589a041c8a Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:55:02 +0900 Subject: [PATCH 42/47] fix(core): enforce intention chronology and raw argument bounds Reject intention transitions before acceptance or prior history while permitting equal timestamps. Bound raw option strings before normalization and omission, preserving bounded empty arguments. Verified 12 failing-first boundary cases, 556 judgment tests, strict TypeScript, and actual SQLite chronology/argument scenarios. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-catalog.ts | 5 +- .../src/agents/judgment-validation.ts | 11 +++- .../test/judgment-catalog-review.test.ts | 30 ++++++++++ .../lina-core/test/judgment-contract.test.ts | 58 +++++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-catalog.ts b/packages/lina-core/src/agents/judgment-catalog.ts index 94408f2..acaad48 100644 --- a/packages/lina-core/src/agents/judgment-catalog.ts +++ b/packages/lina-core/src/agents/judgment-catalog.ts @@ -1,6 +1,6 @@ import type { OptionKey } from "./judgment.ts"; import { judgmentDigest } from "./judgment-validation.ts"; -import { boundedId, boundedText } from "./validation.ts"; +import { boundedId, boundedText, MAX_TEXT } from "./validation.ts"; export const PERSONAL_CATALOG_ID = "personal.v1" as const; export const PERSONAL_OPTION_KINDS = [ @@ -134,7 +134,8 @@ export function normalizeOptionArgs( .flatMap((key) => { boundedId(key, "option argument key"); const value = row[key]; - if (typeof value !== "string") throw Error("invalid option argument"); + if (typeof value !== "string" || value.length > MAX_TEXT) + throw Error("invalid option argument"); const normalized = value.normalize("NFC").trim().replace(/\s+/g, " "); if (normalized === "") return []; boundedText(value, "option argument"); diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 780be8c..bb274bb 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -864,6 +864,7 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { if (kind === "user_commitment" && acceptedBy !== "user") throw Error("user commitment requires user acceptance"); const sourceRef = boundedId(acceptance["sourceRef"], "acceptance source ref"); + const acceptedAt = timestamp(acceptance["acceptedAt"], "accepted at"); const history = list( row["history"], parseIntentionTransition, @@ -878,11 +879,15 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { if (history.length !== recordRevision) throw Error("intention history revision mismatch"); let previous: IntentionStatus = "proposed"; + let previousTime = Date.parse(acceptedAt); for (const transition of history) { if (transition.from !== previous) throw Error("noncontiguous intention history"); + const time = Date.parse(transition.at); + if (time < previousTime) throw Error("nonchronological intention history"); validateTransition(transition, sourceRef); previous = transition.to; + previousTime = time; } if (previous !== status) throw Error("intention history status mismatch"); return { @@ -901,7 +906,7 @@ export function parseIntentionRecord(value: unknown): IntentionRecord { acceptance["policyRevision"], "acceptance policy revision", ), - acceptedAt: timestamp(acceptance["acceptedAt"], "accepted at"), + acceptedAt, }, priority: revision(row["priority"], "intention priority"), deadline: @@ -969,6 +974,10 @@ export function transitionIntention( evidenceRef: transition.evidenceRef, at: transition.at, }); + const previousTime = + source.history.at(-1)?.at ?? source.acceptance.acceptedAt; + if (Date.parse(entry.at) < Date.parse(previousTime)) + throw Error("nonchronological intention history"); validateTransition(entry, source.acceptance.sourceRef); if (source.history.length >= MAX_LIST) throw Error("invalid intention history"); diff --git a/packages/lina-core/test/judgment-catalog-review.test.ts b/packages/lina-core/test/judgment-catalog-review.test.ts index ed2cb8e..c41523f 100644 --- a/packages/lina-core/test/judgment-catalog-review.test.ts +++ b/packages/lina-core/test/judgment-catalog-review.test.ts @@ -3,6 +3,7 @@ import { buildCanonicalOption, type CanonicalOption, canonicalOptionKey, + normalizeOptionArgs, PERSONAL_OPTION_KINDS, type PersonalOptionKind, type PersonalPrecondition, @@ -64,6 +65,35 @@ function option(precondition: PersonalPrecondition): CanonicalOption { }); } +for (const [label, value] of [ + ["spaces", " ".repeat(1001)], + ["tabs", "\t".repeat(1001)], +] as const) { + const original = option({ kind: "noop", reason: "nothing needed" }); + const changed = { ...original, args: { empty: value } }; + const operations = { + normalize: () => normalizeOptionArgs(changed.args), + key: () => canonicalOptionKey(changed), + build: () => buildCanonicalOption(changed), + parse: () => parseCanonicalOption(changed), + }; + for (const [operation, run] of Object.entries(operations)) + test(`3999091818 ${operation} rejects oversized ${label} before omission`, () => { + expect(run).toThrow("invalid option argument"); + }); +} + +test("3999091818 bounded empty arguments are omitted while the full text ceiling survives", () => { + expect( + normalizeOptionArgs({ + empty: "", + spaces: " ".repeat(1000), + tabs: "\t".repeat(1000), + text: "x".repeat(1000), + }), + ).toEqual({ text: "x".repeat(1000) }); +}); + test("identity fixtures cover every personal option kind", () => { expect(preconditions.map((p) => p.kind)).toEqual([...PERSONAL_OPTION_KINDS]); }); diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 723cc06..0534697 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -752,6 +752,64 @@ function activate(): IntentionRecord { }); } +const laterAdoption = transitionIntention(intention, { + to: "adopted", + reason: "accepted", + evidenceRef: null, + at: "2026-09-12T00:00:00.000Z", +}); +for (const { label, source, change } of [ + { + label: "before acceptance", + source: intention, + change: { + to: "adopted" as const, + reason: "accepted", + evidenceRef: null, + at: "2026-09-10T00:00:00.000Z", + }, + }, + { + label: "before previous transition", + source: laterAdoption, + change: { + to: "active" as const, + reason: "started", + evidenceRef: null, + at: "2026-09-11T12:00:00.000Z", + }, + }, +]) { + test(`3999091815 restored history rejects ${label}`, () => { + expect(() => + parseIntentionRecord({ + ...source, + revision: source.revision + 1, + status: change.to, + history: [...source.history, { ...change, from: source.status }], + }), + ).toThrow("nonchronological intention history"); + }); + test(`3999091815 pure transition rejects ${label}`, () => { + expect(() => transitionIntention(source, change)).toThrow( + "nonchronological intention history", + ); + }); +} +test.each(["2026-09-12T00:00:00.000Z", "2026-09-12T01:00:00.000Z"])( + "3999091815 equal or increasing transition time remains valid: %s", + (at) => { + const result = transitionIntention(laterAdoption, { + to: "active", + reason: "started", + evidenceRef: null, + at, + }); + expect(parseIntentionRecord(result)).toEqual(result); + expect(result.history.at(-1)?.at).toBe(at); + }, +); + test("intention transitions complete the legal lifecycle without mutating input", () => { let record = intention; for (const to of [ From be294763fbf5e7b24a6beae465a9c1012ad499d8 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 16:55:47 +0900 Subject: [PATCH 43/47] test(runtime): isolate persona restart resources Use unchanged real prompt and preset bytes without unrelated shipped-avatar assets in the personal-growth restart fixture. Preserve all three real restarts and existing assertions, and verify no avatar import or unintended provider/conversation work. Removes repeated avatar migration I/O without increasing test timeouts. Verified 48 related tests, strict TypeScript, runtime build, and independent Fleet restart surface. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../test/life-runtime-fleet-fixture.ts | 1 + .../test/persona-world-cycle.test.ts | 32 +++++++++++++++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/lina-runtime/test/life-runtime-fleet-fixture.ts b/packages/lina-runtime/test/life-runtime-fleet-fixture.ts index f8bf6a3..1cad848 100644 --- a/packages/lina-runtime/test/life-runtime-fleet-fixture.ts +++ b/packages/lina-runtime/test/life-runtime-fleet-fixture.ts @@ -30,6 +30,7 @@ export async function fleetLifeFixture( | "createApp" | "createImageClient" | "enginePolicy" + | "resourceRoot" > = {}, respond?: (request: Request) => Response | Promise, ) { diff --git a/packages/lina-runtime/test/persona-world-cycle.test.ts b/packages/lina-runtime/test/persona-world-cycle.test.ts index cd4ef77..b90db5f 100644 --- a/packages/lina-runtime/test/persona-world-cycle.test.ts +++ b/packages/lina-runtime/test/persona-world-cycle.test.ts @@ -1,5 +1,13 @@ -import { expect, test } from "bun:test"; -import { renameSync } from "node:fs"; +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readdirSync, + renameSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { captureSourceProofs } from "../../lina-core/src/source-policy.ts"; import { WorldStore } from "../../lina-core/src/world/store.ts"; @@ -11,10 +19,26 @@ import { testSessionEngine } from "./fake-session-engine.ts"; import { nativeEpisode } from "./helpers/native-memory-source.ts"; import { fleetLifeFixture } from "./life-runtime-fleet-fixture.ts"; +let resourceRoot: string; +beforeEach(() => { + resourceRoot = mkdtempSync(join(tmpdir(), "lina-persona-resources-")); + mkdirSync(join(resourceRoot, "data", "personas"), { recursive: true }); + // Preserve the real prompt/profiles, not unrelated multi-megabyte avatar assets. + for (const file of ["app-system-prompt.md", "personas/presets.json"]) + copyFileSync( + join(process.cwd(), "data", file), + join(resourceRoot, "data", file), + ); +}); +afterEach(() => { + if (resourceRoot) rmSync(resourceRoot, { recursive: true, force: true }); +}); + test("Fleet uses persisted personal growth after restart without opening ordinary conversation", async () => { let enabled = true; let miraSource: Parameters[0]["world"]; const f = await fleetLifeFixture({ + resourceRoot, enginePolicy: () => ({ ...defaultEnginePolicy(), memory: { ...defaultEnginePolicy().memory, enabled }, @@ -29,6 +53,7 @@ test("Fleet uses persisted personal growth after restart without opening ordinar }, }); try { + expect(readdirSync(join(f.root, "state", "avatars"))).toEqual([]); f.setup(false); const app = await f.app.fleet.app("lina"); if (!(app.memory instanceof CompanionMemory)) @@ -142,6 +167,9 @@ test("Fleet uses persisted personal growth after restart without opening ordinar new AbortController().signal, ), ).rejects.toThrow(); + expect(f.app.fleet.opened("lina")).toBeUndefined(); + expect(f.providerCalls).toBe(0); + expect(readdirSync(join(f.root, "state", "avatars"))).toEqual([]); } finally { await f.close(); } From cf3a46e66b963152857107886d71c300d997bbcf Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:18:58 +0900 Subject: [PATCH 44/47] fix(core): reject cross-module objective aliases Reject reuse of one immutable objective profile revision across module slots in snapshots and selection specs, including conflicting digests. Preserve distinct stored revisions of a shared objective ID. Verified failing-first parser and direct arbitration cases, 563 judgment tests, strict TypeScript, and actual SQLite persistence/reopen. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../src/agents/judgment-validation.ts | 30 +++-- .../test/judgment-objective-alias.test.ts | 123 ++++++++++++++++++ 2 files changed, 143 insertions(+), 10 deletions(-) create mode 100644 packages/lina-core/test/judgment-objective-alias.test.ts diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index bb274bb..899be9a 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -227,6 +227,24 @@ export function parseObjectiveProfileRef(value: unknown): ObjectiveProfileRef { return objectiveProfileRef(value); } +function objectiveProfileRefs( + value: unknown, +): Record { + const refs = moduleRecord( + value, + objectiveProfileRef, + "objective profile refs", + ); + // One immutable profile revision has exactly one module owner. + uniqueSorted( + Object.values(refs), + (ref) => JSON.stringify([ref.objectiveId, ref.revision]), + "objective profile refs", + false, + ); + return refs; +} + export function parseObjectiveProfile(value: unknown): ObjectiveProfile { const row = fields( value, @@ -334,11 +352,7 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { ]), ), intentionRevision: revision(row["intentionRevision"], "intention revision"), - objectiveProfileRefs: moduleRecord( - row["objectiveProfileRefs"], - objectiveProfileRef, - "objective profile refs", - ), + objectiveProfileRefs: objectiveProfileRefs(row["objectiveProfileRefs"]), observationRef: nullableId(row["observationRef"], "observation ref"), frozenNeuralRef: nullableId(row["frozenNeuralRef"], "frozen neural ref"), situation: enumeration(row["situation"], SITUATIONS, "situation"), @@ -753,11 +767,7 @@ export function parseSelectionSpec(value: unknown): SelectionSpec { row["assessmentSetDigest"], "assessment set digest", ), - objectiveProfileRefs: moduleRecord( - row["objectiveProfileRefs"], - objectiveProfileRef, - "objective profile refs", - ), + objectiveProfileRefs: objectiveProfileRefs(row["objectiveProfileRefs"]), resolutionDigest: boundedId(row["resolutionDigest"], "resolution digest"), policyId: boundedId(row["policyId"], "policy id"), policyRevision: revision(row["policyRevision"], "policy revision"), diff --git a/packages/lina-core/test/judgment-objective-alias.test.ts b/packages/lina-core/test/judgment-objective-alias.test.ts new file mode 100644 index 0000000..bb36c8c --- /dev/null +++ b/packages/lina-core/test/judgment-objective-alias.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { + assessmentInputDigest, + buildCandidateSet, + judgmentDigest, + MODULE_KINDS, + parseJudgmentSnapshotRef, + parseSelectionSpec, + resolvePersonalRound, + snapshotDigest, +} from "../src/agents/index.ts"; +import { policyEvidenceFixture } from "./judgment-policy-evidence-fixture.ts"; + +for (const moduleKind of ["lachesis", "atropos"] as const) + for (const changedDigest of [false, true]) + test(`3999164943 snapshot rejects ${moduleKind} alias with changed digest=${changedDigest}`, () => { + const f = policyEvidenceFixture(); + try { + const snapshot = structuredClone(f.input.snapshot); + snapshot.objectiveProfileRefs[moduleKind] = { + ...snapshot.objectiveProfileRefs.clotho, + ...(changedDigest ? { digest: "another-digest" } : {}), + }; + expect(() => parseJudgmentSnapshotRef(snapshot)).toThrow( + "duplicate objective profile refs", + ); + } finally { + f.fixture.close(); + } + }); + +test("3999164943 direct arbitration rejects aliased objectives with matching assessment digests", () => { + const f = policyEvidenceFixture(); + try { + const snapshot = structuredClone(f.input.snapshot); + snapshot.objectiveProfileRefs.atropos = + snapshot.objectiveProfileRefs.clotho; + const digest = snapshotDigest(snapshot); + const set = { + ...f.input.set, + snapshotDigest: digest, + assessments: f.input.set.assessments.map((assessment) => { + const ref = { + snapshotDigest: digest, + objectiveRef: snapshot.objectiveProfileRefs[assessment.moduleKind], + mechanismRevision: assessment.mechanismRevision, + }; + return { + ...assessment, + ...ref, + inputDigest: assessmentInputDigest(ref), + }; + }), + }; + const candidates = buildCandidateSet({ + roundId: snapshot.roundId, + snapshotDigest: digest, + options: f.input.options, + eligibility: f.input.eligibility, + intentionRefs: f.input.evidence.candidates.intentionRefs, + }); + expect(() => + resolvePersonalRound({ + ...f.input, + snapshot, + set, + evidence: { ...f.input.evidence, candidates }, + }), + ).toThrow("duplicate objective profile refs"); + } finally { + f.fixture.close(); + } +}); + +test("3999164943 selection parser rejects aliases even with a recomputed digest", () => { + const f = policyEvidenceFixture(); + try { + const { spec } = resolvePersonalRound(f.input); + if (!spec) throw Error("missing selection"); + spec.objectiveProfileRefs.lachesis = spec.objectiveProfileRefs.clotho; + spec.specDigest = judgmentDigest({ ...spec, specDigest: undefined }); + expect(() => parseSelectionSpec(spec)).toThrow( + "duplicate objective profile refs", + ); + } finally { + f.fixture.close(); + } +}); + +test("3999164943 distinct stored revisions of one objective ID retain their module ownership", () => { + const f = policyEvidenceFixture(); + try { + for (const [index, moduleKind] of MODULE_KINDS.entries()) { + const ref = f.store.putObjectiveProfile({ + schemaVersion: 1, + objectiveId: "shared-objective", + revision: index + 1, + moduleKind, + objective: "compare", + comparisonCriteria: [], + reconsiderationConditions: [], + }); + f.store.activateObjectiveProfile( + f.input.snapshot.agentId, + f.input.snapshot.scopeId, + ref, + ); + } + const refs = f.store.activeObjectiveProfiles( + f.input.snapshot.agentId, + f.input.snapshot.scopeId, + ); + if (!refs) throw Error("missing active profiles"); + expect( + parseJudgmentSnapshotRef({ + ...f.input.snapshot, + objectiveProfileRefs: refs, + }).objectiveProfileRefs, + ).toEqual(refs); + } finally { + f.fixture.close(); + } +}); From fdce947002dd37f5b19461538bcc061387f17b5c Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:27:11 +0900 Subject: [PATCH 45/47] fix(core): fence unverified user commitment cancellation Reject live cancellation of adopted, active and suspended user commitments while F1 lacks intention-bound owner-verified authority. Acceptance refs, ranked candidates and caller-claimed approval fields do not grant cancellation permission. Preserve historical schema-v1 ledgers, proposed withdrawal and other lifecycle paths. Verified failing-first helper/store cases, 583 judgment tests, strict types, runtime build and actual SQLite mutation/reopen scenarios. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../src/agents/judgment-validation.ts | 7 + .../judgment-cancellation-authority.test.ts | 211 ++++++++++++++++++ .../lina-core/test/judgment-contract.test.ts | 22 +- 3 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 packages/lina-core/test/judgment-cancellation-authority.test.ts diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index 899be9a..cf0bed5 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -989,6 +989,13 @@ export function transitionIntention( if (Date.parse(entry.at) < Date.parse(previousTime)) throw Error("nonchronological intention history"); validateTransition(entry, source.acceptance.sourceRef); + // F1 restores history, but has no owner-verified user cancellation receipt. + if ( + source.kind === "user_commitment" && + source.status !== "proposed" && + entry.to === "cancelled" + ) + throw Error("user commitment cancellation authority is unavailable"); if (source.history.length >= MAX_LIST) throw Error("invalid intention history"); return { diff --git a/packages/lina-core/test/judgment-cancellation-authority.test.ts b/packages/lina-core/test/judgment-cancellation-authority.test.ts new file mode 100644 index 0000000..9110226 --- /dev/null +++ b/packages/lina-core/test/judgment-cancellation-authority.test.ts @@ -0,0 +1,211 @@ +import { expect, test } from "bun:test"; +import { DatabaseSync } from "node:sqlite"; +import { + type IntentionRecord, + intentionDigest, + JudgmentStore, + parseIntentionRecord, + resolvePersonalRound, + transitionIntention, +} from "../src/agents/index.ts"; +import { canonicalJson } from "../src/agents/judgment-validation.ts"; +import { + policyEvidenceFixture, + policyOption, +} from "./judgment-policy-evidence-fixture.ts"; + +const paths = { + adopted: [], + active: ["active"], + suspended: ["active", "suspended"], +} as const; + +function advance( + f: ReturnType, + status: keyof typeof paths, +): IntentionRecord { + let source = f.adopted; + for (const to of paths[status]) + source = f.store.transitionIntention( + source.intentionId, + { + to, + reason: "prepare lifecycle", + evidenceRef: source.acceptance.sourceRef, + at: source.acceptance.acceptedAt, + }, + source.revision, + ); + return source; +} + +function cancellation(source: IntentionRecord) { + return { + to: "cancelled" as const, + reason: "new interest without a cancellation request", + evidenceRef: source.acceptance.sourceRef, + at: source.acceptance.acceptedAt, + }; +} + +for (const status of ["adopted", "active", "suspended"] as const) { + for (const boundary of ["helper", "store"] as const) + test(`3999164940 ${boundary} rejects acceptance-only cancellation from ${status}`, () => { + const f = policyEvidenceFixture(); + try { + const source = advance(f, status); + const before = structuredClone(source); + const revision = f.store.intentionRevision( + source.agentId, + source.scopeId, + ); + expect(() => + boundary === "helper" + ? transitionIntention(source, cancellation(source)) + : f.store.transitionIntention( + source.intentionId, + cancellation(source), + source.revision, + ), + ).toThrow("user commitment cancellation authority is unavailable"); + expect(source).toEqual(before); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getIntention(source.intentionId)).toEqual(before); + expect(reopened.intentionRevision(source.agentId, source.scopeId)).toBe( + revision, + ); + } finally { + f.fixture.close(); + } + }); + + test(`3999164940 historical cancellation from ${status} remains readable but cannot be inserted`, () => { + const f = policyEvidenceFixture(); + try { + const source = advance(f, status); + const historical: IntentionRecord = { + ...source, + status: "cancelled", + revision: source.revision + 1, + history: [ + ...source.history, + { ...cancellation(source), from: source.status }, + ], + }; + expect(parseIntentionRecord(historical)).toEqual(historical); + expect(() => f.store.putIntention(historical)).toThrow( + "intention must be proposed", + ); + // Reconstruct a pre-authority F1 ledger, not a newly authorized command. + const db = new DatabaseSync(f.path); + try { + db.prepare( + "UPDATE intention_records SET revision = ?, status = ?, digest = ?, body = ? WHERE intention_id = ?", + ).run( + historical.revision, + historical.status, + intentionDigest(historical), + canonicalJson(historical), + historical.intentionId, + ); + db.prepare( + "INSERT INTO intention_transitions(intention_id, revision, from_status, to_status, reason, evidence_ref, at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).run( + source.intentionId, + historical.revision, + source.status, + "cancelled", + cancellation(source).reason, + source.acceptance.sourceRef, + source.acceptance.acceptedAt, + ); + } finally { + db.close(); + } + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getIntention(source.intentionId)).toEqual(historical); + expect(() => + reopened.transitionIntention( + source.intentionId, + { + ...cancellation(source), + to: "active", + }, + historical.revision, + ), + ).toThrow("invalid intention transition: cancelled -> active"); + } finally { + f.fixture.close(); + } + }); + + for (const kind of ["autonomous_goal", "task_binding"] as const) + test(`3999164940 ${kind} cancellation from ${status} retains the existing lifecycle`, () => { + const f = policyEvidenceFixture(); + try { + const source = { ...advance(f, status), kind }; + const result = transitionIntention(source, cancellation(source)); + expect(parseIntentionRecord(result).status).toBe("cancelled"); + expect(result.acceptance).toEqual(source.acceptance); + } finally { + f.fixture.close(); + } + }); +} + +test("3999164940 proposed user commitment withdrawal still persists and reopens", () => { + const f = policyEvidenceFixture(); + try { + const source: IntentionRecord = { + ...f.adopted, + intentionId: "proposal", + revision: 0, + status: "proposed", + history: [], + }; + f.store.putIntention(source); + const result = f.store.transitionIntention( + source.intentionId, + cancellation(source), + 0, + ); + expect(result.status).toBe("cancelled"); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getIntention(source.intentionId)).toEqual(result); + } finally { + f.fixture.close(); + } +}); + +test("3999164940 ranking and receipt-shaped catalog strings cannot authorize cancellation", () => { + const f = policyEvidenceFixture(policyOption("intention.cancel")); + try { + f.oppose("atropos", ["promise"]); + const result = resolvePersonalRound(f.input); + expect(result.resolution.status).toBe("resolved"); + expect(() => + f.store.transitionIntention("promise", cancellation(f.adopted), 1), + ).toThrow("user commitment cancellation authority is unavailable"); + } finally { + f.fixture.close(); + } +}); + +for (const field of ["authorityRef", "userConfirmationRef", "authorized"]) + test(`3999164940 caller-controlled ${field} cannot unlock cancellation`, () => { + const f = policyEvidenceFixture(); + try { + const input = { + ...cancellation(f.adopted), + [field]: "claimed-authority", + }; + expect(() => transitionIntention(f.adopted, input)).toThrow( + "invalid intention transition", + ); + expect(() => f.store.transitionIntention("promise", input, 1)).toThrow( + `unknown intention transition field ${field}`, + ); + } finally { + f.fixture.close(); + } + }); diff --git a/packages/lina-core/test/judgment-contract.test.ts b/packages/lina-core/test/judgment-contract.test.ts index 0534697..4deebdf 100644 --- a/packages/lina-core/test/judgment-contract.test.ts +++ b/packages/lina-core/test/judgment-contract.test.ts @@ -996,11 +996,27 @@ test("intention table and all prohibited edges are explicit", () => { to === "completed" ? "outcome-1" : intention.acceptance.sourceRef, at: transition.at, }; - if (INTENTION_TRANSITIONS[record.status].includes(to)) + if (INTENTION_TRANSITIONS[record.status].includes(to)) { expect( - parseIntentionRecord(transitionIntention(record, change)).status, + parseIntentionRecord({ + ...record, + status: to, + revision: record.revision + 1, + history: [...record.history, { ...change, from: record.status }], + }).status, ).toBe(to); - else + if ( + to === "cancelled" && + ["adopted", "active", "suspended"].includes(record.status) + ) + expect(() => transitionIntention(record, change)).toThrow( + "user commitment cancellation authority is unavailable", + ); + else + expect( + parseIntentionRecord(transitionIntention(record, change)).status, + ).toBe(to); + } else expect(() => transitionIntention(record, change)).toThrow( `invalid intention transition: ${record.status} -> ${to}`, ); From 0785edac16dd969bedc27ef89b1db1dd22780c6b Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:51:47 +0900 Subject: [PATCH 46/47] fix(core): prevent objective reactivation from reviving stale rounds Reject an activation whose resulting references would restore a stale scoped open snapshot. Preserve no-ops, forward revisions, isolated scopes and replay of closed rounds without changing serialized contracts. Verified four failing-first ABA cases, 590 judgment tests, strict TypeScript, build/lint and an actual reopened SQLite round that rejects stale work and resolves fresh objective inputs. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-store.ts | 16 ++ .../judgment-objective-activation.test.ts | 164 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 packages/lina-core/test/judgment-objective-activation.test.ts diff --git a/packages/lina-core/src/agents/judgment-store.ts b/packages/lina-core/src/agents/judgment-store.ts index 9f574c1..5fad73f 100644 --- a/packages/lina-core/src/agents/judgment-store.ts +++ b/packages/lina-core/src/agents/judgment-store.ts @@ -221,6 +221,22 @@ export class JudgmentStore { activeRevision === parsed.revision ) return { activationRevision: Number(previous) }; + const nextRefs = body({ + ...this.activeObjectiveProfiles(agent, scope), + [profile.moduleKind]: parsed, + }); + // An open snapshot may become stale, but activation must not revive it. + for (const row of this.db + .prepare( + "SELECT snapshot FROM rounds WHERE agent_id = ? AND scope_id = ? AND status = 'open'", + ) + .all(agent, scope)) { + const snapshot = parseJudgmentSnapshotRef( + JSON.parse(String(row["snapshot"])), + ); + if (body(snapshot.objectiveProfileRefs) === nextRefs) + throw Error("objective reactivation would revive stale round"); + } const activationRevision = revision(Number(previous ?? 0) + 1, 1); this.db .prepare(`INSERT INTO objective_profile_active(agent_id, scope_id, module_kind, objective_id, revision, activation_revision, updated_at) diff --git a/packages/lina-core/test/judgment-objective-activation.test.ts b/packages/lina-core/test/judgment-objective-activation.test.ts new file mode 100644 index 0000000..cb2ef37 --- /dev/null +++ b/packages/lina-core/test/judgment-objective-activation.test.ts @@ -0,0 +1,164 @@ +import { expect, test } from "bun:test"; +import { + JudgmentStore, + MODULE_KINDS, + type ModuleKind, + resolvePersonalRound, +} from "../src/agents/index.ts"; +import { policyEvidenceFixture } from "./judgment-policy-evidence-fixture.ts"; + +function replacement( + f: ReturnType, + moduleKind: ModuleKind, + revision: number, +) { + const ref = f.input.snapshot.objectiveProfileRefs[moduleKind]; + const profile = f.store.getObjectiveProfile(ref.objectiveId, ref.revision); + if (!profile) throw Error("missing objective"); + return f.store.putObjectiveProfile({ ...profile, revision }); +} + +function assessed(f: ReturnType) { + f.store.closeCandidateSet(f.input.evidence.candidates); + for (const assessment of f.input.set.assessments) + f.store.putAssessment(assessment); + return resolvePersonalRound(f.input); +} + +for (const moduleKind of MODULE_KINDS) + test(`3999233699 ${moduleKind} cannot reactivate a stale open round across reopen`, () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, roundId, objectiveProfileRefs } = + f.input.snapshot; + const result = assessed(f); + const a = objectiveProfileRefs[moduleKind]; + expect( + f.store.activateObjectiveProfile(agentId, scopeId, a) + .activationRevision, + ).toBe(1); + const b = replacement(f, moduleKind, 2); + expect( + f.store.activateObjectiveProfile(agentId, scopeId, b) + .activationRevision, + ).toBe(2); + expect(() => + f.store.activateObjectiveProfile(agentId, scopeId, a), + ).toThrow("objective reactivation would revive stale round"); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect( + reopened.activeObjectiveProfiles(agentId, scopeId)?.[moduleKind], + ).toEqual(b); + expect( + reopened.activateObjectiveProfile(agentId, scopeId, b) + .activationRevision, + ).toBe(2); + expect(() => + reopened.activateObjectiveProfile(agentId, scopeId, a), + ).toThrow("objective reactivation would revive stale round"); + expect(() => + reopened.recordResolution(roundId, result.resolution, result.spec), + ).toThrow("stale objective profile refs"); + // A fresh revision can express the original objective without reviving old work. + const next = replacement(f, moduleKind, 3); + expect( + reopened.activateObjectiveProfile(agentId, scopeId, next) + .activationRevision, + ).toBe(3); + const refs = reopened.activeObjectiveProfiles(agentId, scopeId); + if (!refs) throw Error("missing active objectives"); + const fresh = { + ...f.input.snapshot, + roundId: "fresh", + sequence: 2, + objectiveProfileRefs: refs, + }; + reopened.openRound(fresh); + expect(f.store.getRound("fresh")?.snapshot).toEqual(fresh); + } finally { + f.fixture.close(); + } + }); + +test("3999233699 rotating different modules cannot revive an old snapshot", () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, objectiveProfileRefs } = f.input.snapshot; + for (const moduleKind of ["clotho", "atropos"] as const) + f.store.activateObjectiveProfile( + agentId, + scopeId, + replacement(f, moduleKind, 2), + ); + // Restoring only one module is safe while another still invalidates the round. + expect( + f.store.activateObjectiveProfile( + agentId, + scopeId, + objectiveProfileRefs.clotho, + ).activationRevision, + ).toBe(3); + expect(() => + f.store.activateObjectiveProfile( + agentId, + scopeId, + objectiveProfileRefs.atropos, + ), + ).toThrow("objective reactivation would revive stale round"); + } finally { + f.fixture.close(); + } +}); + +for (const other of ["agent", "scope"] as const) + test(`3999233699 an open round does not block another ${other}`, () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, objectiveProfileRefs } = f.input.snapshot; + const agent = other === "agent" ? "other-agent" : agentId; + const scope = other === "scope" ? "other-scope" : scopeId; + for (const ref of Object.values(objectiveProfileRefs)) + f.store.activateObjectiveProfile(agent, scope, ref); + f.store.activateObjectiveProfile( + agent, + scope, + replacement(f, "clotho", 2), + ); + expect( + f.store.activateObjectiveProfile( + agent, + scope, + objectiveProfileRefs.clotho, + ).activationRevision, + ).toBe(3); + } finally { + f.fixture.close(); + } + }); + +test("3999233699 closed rounds remain replayable after objective reactivation", () => { + const f = policyEvidenceFixture(); + try { + const { agentId, scopeId, roundId, objectiveProfileRefs } = + f.input.snapshot; + const result = assessed(f); + f.store.recordResolution(roundId, result.resolution, result.spec); + f.store.activateObjectiveProfile( + agentId, + scopeId, + replacement(f, "clotho", 2), + ); + expect( + f.store.activateObjectiveProfile( + agentId, + scopeId, + objectiveProfileRefs.clotho, + ).activationRevision, + ).toBe(3); + const reopened = f.fixture.keep(new JudgmentStore(f.path)); + expect(reopened.getResolution(roundId)).toEqual(result.resolution); + expect(reopened.getSelectionSpec(roundId)).toEqual(result.spec); + } finally { + f.fixture.close(); + } +}); From ff241e6f811421a01f7fd8fefd3bbba099652f10 Mon Sep 17 00:00:00 2001 From: thisisjun786 <259586770+thisisjun786@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:21:03 +0900 Subject: [PATCH 47/47] fix(core): bind dialogue digests to frozen source provenance Freeze Host source fingerprints in the snapshot before assessment and require dialogue records and references to match them. Reject invented or rehashed provenance while preserving action-v1 snapshots without the optional field. Verified six failing-first provenance cases, 606 judgment tests, 4526 full-suite passes, clean source/test LSP diagnostics, strict types, build/lint and real DurableStore-to-JudgmentStore persistence/reopen with complete cleanup. Ultraworked with [omo](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: sisyphus-dev-ai --- .../lina-core/src/agents/judgment-dialogue.ts | 14 ++- .../src/agents/judgment-validation.ts | 31 ++++++ packages/lina-core/src/agents/judgment.ts | 7 ++ .../test/judgment-dialogue-contract.test.ts | 102 +++++++++++++++++- 4 files changed, 149 insertions(+), 5 deletions(-) diff --git a/packages/lina-core/src/agents/judgment-dialogue.ts b/packages/lina-core/src/agents/judgment-dialogue.ts index 4c708bf..50e1a03 100644 --- a/packages/lina-core/src/agents/judgment-dialogue.ts +++ b/packages/lina-core/src/agents/judgment-dialogue.ts @@ -1,6 +1,7 @@ import { validId } from "../context/validation.ts"; import { type Assessment, + type DialogueSourceRef, type JudgmentSnapshotRef, MODULE_KINDS, type ModuleKind, @@ -21,16 +22,13 @@ import { } from "./judgment-validation.ts"; import { boundedId, boundedText } from "./validation.ts"; -type DialogueProvenance = { +type DialogueProvenance = DialogueSourceRef & { roundId: string; snapshotDigest: string; objectiveProfileRefs: Record; assessmentDigests: Record; policyId: string; policyRevision: number; - requestId: string; - requestDigest: string; - sourceDigest: string; }; /** JSON capability v2, stored in the unchanged v1 ledger DDL. Old readers reject * this version; legacy action v1 bodies/digests are never rewritten or decorated. @@ -264,6 +262,14 @@ export function validateDialogueResolution( ) ) throw Error("dialogue request source mismatch"); + const expected = snapshot.dialogueSource; + if (!expected) throw Error("dialogue source provenance missing"); + if ( + record.requestId !== expected.requestId || + record.requestDigest !== expected.requestDigest || + record.sourceDigest !== expected.sourceDigest + ) + throw Error("dialogue source digest mismatch"); const seen = new Set(); for (const assessment of assessments) { const m = assessment.moduleKind; diff --git a/packages/lina-core/src/agents/judgment-validation.ts b/packages/lina-core/src/agents/judgment-validation.ts index cf0bed5..bb81407 100644 --- a/packages/lina-core/src/agents/judgment-validation.ts +++ b/packages/lina-core/src/agents/judgment-validation.ts @@ -5,6 +5,7 @@ import { ASSESSMENT_UNAVAILABLE_REASONS, type Assessment, type AssessmentSet, + type DialogueSourceRef, EXCLUSION_STAGES, INTENTION_KINDS, INTENTION_RELATIONS, @@ -280,6 +281,10 @@ export function parseObjectiveProfile(value: unknown): ObjectiveProfile { } export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { + const hasDialogueSource = + typeof value === "object" && + value !== null && + Object.hasOwn(value, "dialogueSource"); const row = fields( value, [ @@ -302,6 +307,7 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { "clockId", "sequence", "bindingGeneration", + ...(hasDialogueSource ? ["dialogueSource"] : []), ], "judgment snapshot ref", true, @@ -329,12 +335,37 @@ export function parseJudgmentSnapshotRef(value: unknown): JudgmentSnapshotRef { "source refs", ); const domains = object(row["domainRevisions"], "domain revisions"); + let dialogueSource: DialogueSourceRef | undefined; + if (hasDialogueSource) { + const source = fields( + row["dialogueSource"], + ["requestId", "requestDigest", "sourceDigest"], + "dialogue source", + ); + dialogueSource = { + requestId: validId(source["requestId"], "request id"), + requestDigest: boundedId(source["requestDigest"], "request digest"), + sourceDigest: boundedId(source["sourceDigest"], "source digest"), + }; + if ( + ![dialogueSource.requestDigest, dialogueSource.sourceDigest].every( + (value) => /^[a-f0-9]{64}$/.test(value), + ) + ) + throw Error("invalid dialogue source digest"); + const requestId = dialogueSource.requestId; + if ( + !sourceRefs.some((ref) => ref.kind === "request" && ref.id === requestId) + ) + throw Error("dialogue request source mismatch"); + } return { schemaVersion: 1, roundId: boundedId(row["roundId"], "round id"), agentId: boundedId(row["agentId"], "agent id"), scopeId: boundedId(row["scopeId"], "scope id"), sourceRefs, + ...(dialogueSource ? { dialogueSource } : {}), workingRevision: revision(row["workingRevision"], "working revision"), instructionRevision: revision( row["instructionRevision"], diff --git a/packages/lina-core/src/agents/judgment.ts b/packages/lina-core/src/agents/judgment.ts index 62e8909..39ccb9c 100644 --- a/packages/lina-core/src/agents/judgment.ts +++ b/packages/lina-core/src/agents/judgment.ts @@ -64,12 +64,19 @@ export type ObjectiveProfileRef = { digest: string; }; export type SourceRef = { kind: string; id: string; revision: number }; +export type DialogueSourceRef = { + requestId: string; + requestDigest: string; + sourceDigest: string; +}; export type JudgmentSnapshotRef = { schemaVersion: 1; roundId: string; agentId: string; scopeId: string; sourceRefs: SourceRef[]; + /** Host source-owner digests frozen before judgment; required for dialogue. */ + dialogueSource?: DialogueSourceRef; workingRevision: number; instructionRevision: number; policyId: string; diff --git a/packages/lina-core/test/judgment-dialogue-contract.test.ts b/packages/lina-core/test/judgment-dialogue-contract.test.ts index d5ac871..19c3def 100644 --- a/packages/lina-core/test/judgment-dialogue-contract.test.ts +++ b/packages/lina-core/test/judgment-dialogue-contract.test.ts @@ -33,6 +33,11 @@ beforeEach(() => { agentId: "agent", scopeId: "scope", sourceRefs: [{ kind: "request", id: "request", revision: 0 }], + dialogueSource: { + requestId: "request", + requestDigest: api.judgmentDigest({ request: "original" }), + sourceDigest: api.judgmentDigest({ source: "original" }), + }, workingRevision: 0, instructionRevision: 0, policyId: "personal.v1", @@ -125,12 +130,58 @@ function db() { return fixture.keep(new DatabaseSync(path)); } +for (const [field, value] of [ + ["requestDigest", "0".repeat(64)], + ["sourceDigest", "1".repeat(64)], +] as const) + for (const boundary of ["reference", "store", "reopen"] as const) + test(`3999243465 ${boundary} rejects an invented ${field}`, () => { + seed(); + const original = record(); + const forged = { ...original, [field]: value }; + switch (boundary) { + case "reference": + expect(() => + api.buildDialogueJudgmentRef({ + snapshot, + assessments, + resolution: forged, + }), + ).toThrow("dialogue source digest mismatch"); + break; + case "store": + expect(() => + store.recordResolution(snapshot.roundId, forged, null), + ).toThrow("dialogue source digest mismatch"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); + break; + case "reopen": + store.recordResolution(snapshot.roundId, original, null); + db() + .prepare( + "UPDATE resolution_records SET body = ?, digest = ? WHERE round_id = ?", + ) + .run( + JSON.stringify(forged), + api.judgmentDigest(forged), + snapshot.roundId, + ); + reopen(); + expect(() => store.dialogueJudgmentRef(snapshot.roundId)).toThrow( + "dialogue source digest mismatch", + ); + break; + } + }); + test("3996179135 action v1 bytes and digest remain unchanged alongside dialogue v2", () => { + const { dialogueSource: _source, ...legacySnapshot } = snapshot; const actionSnapshot = { - ...snapshot, + ...legacySnapshot, roundId: "historic-action", sequence: 0, }; + expect(api.parseJudgmentSnapshotRef(actionSnapshot)).toEqual(actionSnapshot); const action = api.parseResolutionRecord({ schemaVersion: 1, roundId: actionSnapshot.roundId, @@ -175,6 +226,55 @@ test("3996179135 action v1 bytes and digest remain unchanged alongside dialogue expect(original?.["digest"]).toBe(api.judgmentDigest(action)); }); +for (const field of ["requestDigest", "sourceDigest"] as const) + for (const value of ["not-a-digest", "A".repeat(64), "0".repeat(63), null]) + test(`3999243465 snapshot rejects malformed ${field}: ${String(value)}`, () => { + expect(() => + api.parseJudgmentSnapshotRef({ + ...snapshot, + dialogueSource: { ...snapshot.dialogueSource, [field]: value }, + }), + ).toThrow(); + }); + +test("3999243465 frozen source metadata is bound to a request source and snapshot digest", () => { + const source = snapshot.dialogueSource; + if (!source) throw Error("missing frozen source"); + expect(() => + api.parseJudgmentSnapshotRef({ + ...snapshot, + dialogueSource: { ...source, requestId: "other-request" }, + }), + ).toThrow("dialogue request source mismatch"); + const changed = api.parseJudgmentSnapshotRef({ + ...snapshot, + dialogueSource: { ...source, sourceDigest: "a".repeat(64) }, + }); + expect(api.snapshotDigest(changed)).not.toBe(api.snapshotDigest(snapshot)); + expect(changed.dialogueSource?.sourceDigest).toBe("a".repeat(64)); +}); + +test("3999243465 even held dialogue requires source digests frozen by the Host", () => { + const { dialogueSource: _source, ...legacySnapshot } = snapshot; + store.openRound(legacySnapshot); + expect(() => + store.recordResolution( + snapshot.roundId, + { + ...record(), + snapshotDigest: api.snapshotDigest(legacySnapshot), + assessmentDigests: { clotho: null, lachesis: null, atropos: null }, + recommendations: { clotho: null, lachesis: null, atropos: null }, + alignment: "incomplete", + status: "held", + holdReason: "missing input", + }, + null, + ), + ).toThrow("dialogue source provenance missing"); + expect(store.getResolution(snapshot.roundId)).toBeNull(); +}); + test("3996179135 dialogue v2 golden round-trip retains prose and immutable provenance", () => { const original = record(); const parsed = api.parseDialogueResolutionRecord(