diff --git a/README.md b/README.md index dd23f99..06369f6 100644 --- a/README.md +++ b/README.md @@ -72,27 +72,34 @@ assessment when the declared capability risk warrants it. A live obligation's ta structural: an affirmation or rejection of a pending gate binds to the obligation's frozen revision, not to a new referent question. `planTurnCommands` is the one whole-turn compiler: proposal, confirmation, cancellation, correction and grounded answer are all -evaluated over the same frame identity, structural, exclusion, dependency and citation -checks, and every command comes from that result. An accepted adjustment that cannot -become a revision is a clarification, never a silent confirmation of the targets the user -adjusted; a request never converts into a revision of the pending operation. Queries are -answered from materialized frame facts and never authorize a mutation. - -Clarification is durable and interaction-scoped: an ambiguous turn records an obligation -transition with the pinned turn, the next frame carries the question back, and a -redelivery completes an interrupted append without calling the model again. Unrelated -queries and other interactions sharing the store cannot consume or borrow the question. -Transaction pause/resume and non-ref slot filling still require a language workflow over -the typed command API. +evaluated in a single ordered pass over the same frame identity, structural, exclusion, +dependency and citation checks, so a continuation can only depend on a predecessor that +pass already accepted. A confirmation executes the frozen obligation target set: any +accepted or unvalidated adjustment to that set becomes a revision or a question, never a +confirmation of the old revision. A correction must bind refs certified by the frame and +carries their frozen versions. A request with an accepted capability is a new proposal, +never a revision. Queries are answered from materialized frame facts and never authorize a +mutation. + +Clarification is durable, interaction-scoped and explicit: an ambiguous turn records an +obligation transition with the pinned turn, the next frame carries the question back, and +a redelivery completes an interrupted append without calling the model again. Only a +proposition that names the obligation (an explicit clarification answer, or a rejection +that abandons it) closes it, and only when its commands were not rejected at runtime; +unrelated requests and other interactions sharing the store can neither consume nor +borrow the question. Transaction pause/resume and non-ref slot filling still require a +language workflow over the typed command API. `@circe/adapter-http-tasks` and `@circe/task-service` are the first real integration. The service is a separate local HTTP system that owns task identity, versions, transitions and operation records; the adapter is the only translator, and no service concept enters the -kernel. A mutation stages the task and operation, persists both (write, fsync, rename, -directory fsync) and only then publishes them, so a failed write is an internal fault, not -a refusal. The adapter turns only recognized, contract-valid refusals into typed failures; -a lost or malformed outcome is unconfirmed, so the kernel records UNKNOWN and -reconciliation reads the operation back. Relations travel through a declared +kernel. A mutation stages the task and operation, writes every byte, fsyncs the file, +renames and fsyncs the directory before acknowledging; a write failure leaves memory +unchanged, and a post-rename directory-sync failure is an unconfirmed outcome whose +operation is still recorded, so the caller reconciles rather than retries. The adapter +turns only recognized, contract-valid refusals into typed failures; a lost, malformed or +unrecognized outcome (including an unrecognized 404) is unconfirmed, so the kernel records +UNKNOWN and reconciliation reads the operation back. Relations travel through a declared `RelationObservationProvider` contract on the authority, deduplicated by edge identity. `pnpm --filter @circe/inspector demo:real` runs the first-release acceptance path against the service: a query that mutates nothing, a low-risk pause, a gated completion, a diff --git a/apps/inspector/src/host.ts b/apps/inspector/src/host.ts index d0e42d7..6c69e7d 100644 --- a/apps/inspector/src/host.ts +++ b/apps/inspector/src/host.ts @@ -331,8 +331,12 @@ function applyObligationTransitions( interactionId: string, at: string, transitions: ObligationTransition[], + resolveAllowed: boolean, ): void { for (const transition of transitions) { + // A rejected answer leaves the question open: resolution is a consequence + // of the accepted, successfully applied answer, never of the attempt. + if (transition.kind === "resolved" && !resolveAllowed) continue; const question = transition.question; const refs = question.candidates; // A question with no candidate refs cannot be indexed or looked up again; @@ -544,8 +548,8 @@ export function createContinuingInteractionHost( question = plan.question; // Deterministic obligation transitions, pinned with the turn: a // question this turn opens, the one it supersedes, or the one it - // answers with a consequence. A query alone never consumes a - // pending clarification. + // explicitly answers. Only a proposition that names the obligation + // closes it — an emitted command never implies resolution. if (plan.clarification !== undefined && question !== undefined) { obligations.push({ kind: "opened", @@ -559,7 +563,10 @@ export function createContinuingInteractionHost( if (previousQuestion) { obligations.push({ kind: "resolved", question: previousQuestion }); } - } else if (previousQuestion && commands.length > 0) { + } else if ( + previousQuestion && + plan.answers.includes(previousQuestion.questionId) + ) { obligations.push({ kind: "resolved", question: previousQuestion }); } const record: AcceptedTurnRecord = { @@ -588,8 +595,16 @@ export function createContinuingInteractionHost( // Apply the pinned transitions on first delivery and on redelivery, // idempotently: an append interrupted after pinning is completed - // without reinterpreting the observation. - applyObligationTransitions(config.context, config.interactionId, input.now, obligations); + // without reinterpreting the observation. A question is only closed + // when the commands that answered it were not rejected at runtime. + const runtimeOk = results.every((result) => !result.errors?.length); + applyObligationTransitions( + config.context, + config.interactionId, + input.now, + obligations, + runtimeOk, + ); return { frame, diff --git a/apps/inspector/src/language-plan.ts b/apps/inspector/src/language-plan.ts index f268dd9..b7072b0 100644 --- a/apps/inspector/src/language-plan.ts +++ b/apps/inspector/src/language-plan.ts @@ -27,6 +27,8 @@ export interface LanguageCommandPlan { question?: string; /** structured form of `question`, for the durable obligation record */ clarification?: TurnClarification; + /** question ids the accepted turn explicitly answers or supersedes */ + answers: string[]; } export interface LanguageCommandPlanInput { @@ -49,6 +51,7 @@ export function planLanguageCommands(input: LanguageCommandPlanInput): LanguageC return { commands: plan.commands, rejected: plan.rejected, + answers: plan.answers, ...(plan.answer !== undefined ? { answer: plan.answer } : {}), ...(plan.clarification !== undefined ? { diff --git a/apps/inspector/src/real-cli.ts b/apps/inspector/src/real-cli.ts index d676f76..8728d3e 100644 --- a/apps/inspector/src/real-cli.ts +++ b/apps/inspector/src/real-cli.ts @@ -35,7 +35,7 @@ import { createFileContinuingInteractionHost } from "./file-host"; const T0 = "2026-09-26T09:00:00.000Z"; interface TurnPlan { - act: "request" | "query" | "affirm" | "reject" | "correction" | "none"; + act: "request" | "query" | "affirm" | "reject" | "correction" | "clarify" | "none"; include?: string[]; exclude?: string[]; about?: string[]; @@ -219,7 +219,7 @@ try { console.log("\n[host restarted again: the open question is still recorded]"); await turn("The nightly pipeline.", "2026-09-26T09:07:00.000Z", { - act: "request", + act: "clarify", include: ["t1"], capability: "task.complete", risk: 1, diff --git a/apps/inspector/src/real-integration.test.ts b/apps/inspector/src/real-integration.test.ts index e958cae..25a8d04 100644 --- a/apps/inspector/src/real-integration.test.ts +++ b/apps/inspector/src/real-integration.test.ts @@ -30,7 +30,7 @@ import { createFileContinuingInteractionHost, type FileContinuingInteractionHost */ interface TurnPlan { - act: "request" | "query" | "affirm" | "reject" | "correction" | "none"; + act: "request" | "query" | "affirm" | "reject" | "correction" | "clarify" | "none"; include?: string[]; exclude?: string[]; about?: string[]; @@ -49,6 +49,7 @@ const T5 = "2026-09-26T09:05:00.000Z"; const T6 = "2026-09-26T09:06:00.000Z"; const T7 = "2026-09-26T09:07:00.000Z"; const T8 = "2026-09-26T09:08:00.000Z"; +const T9 = "2026-09-26T09:09:00.000Z"; function choiceDistribution(spec: DecisionSpec, selected: string): DecisionAnswer { const options = spec.kind === "choice" ? spec.options.map((option) => option.id) : []; @@ -264,9 +265,10 @@ describe("real task service acceptance path", () => { expect(ambiguous.question).toBeDefined(); // 7. Restart again; the recorded question is visible to the next frame and - // the answer continues safely into a new gated proposal. + // an explicit clarification answer continues safely into a new gated + // proposal. restart(); - plan = { act: "request", include: ["t1"], capability: "task.complete", risk: 1 }; + plan = { act: "clarify", include: ["t1"], capability: "task.complete", risk: 1 }; const answer = await host().runTurn({ observationId: "event:answer", utterance: "The nightly pipeline.", @@ -289,6 +291,15 @@ describe("real task service acceptance path", () => { expect(finalBoard.find((task) => task.id === "t3")).toMatchObject({ status: "paused", version: 1 }); expect(finalBoard.find((task) => task.id === "t4")).toMatchObject({ status: "running", version: 1 }); + // The explicitly answered question is no longer pending. + plan = { act: "none" }; + const settled = await host().runTurn({ + observationId: "event:settled", + utterance: "Thanks.", + now: T9, + }); + expect(settled.frame.interaction.pendingQuestion).toBeUndefined(); + // Every applied operation is recorded under its effect identity: nothing // executed twice across the restarts. const operations = service.snapshot().operations; diff --git a/apps/inspector/src/review-fixes.test.ts b/apps/inspector/src/review-fixes.test.ts index 6e6e44b..222e1f0 100644 --- a/apps/inspector/src/review-fixes.test.ts +++ b/apps/inspector/src/review-fixes.test.ts @@ -12,6 +12,7 @@ import { createInMemoryContextStore } from "@circe/context"; import { createInMemoryJournal } from "@circe/core"; import { ACCEPTANCE_POLICY_V1, + compileSemanticTurn, createInterpretationGraph, planTurnCommands, type DecisionAnswer, @@ -22,6 +23,7 @@ import type { ContextFrame } from "@circe/context"; import type { EntityRef, Observation } from "@circe/core"; import { createFileContinuingInteractionHost, type FileContinuingInteractionHost } from "./file-host"; import { createContinuingInteractionHost } from "./host"; +import { planLanguageCommands } from "./language-plan"; /** * Regression reproductions from the PR #11 review. Each one provides correct @@ -30,7 +32,7 @@ import { createContinuingInteractionHost } from "./host"; */ interface TurnPlan { - act: "request" | "query" | "affirm" | "reject" | "correction" | "none"; + act: "request" | "query" | "affirm" | "reject" | "correction" | "clarify" | "none"; include?: string[]; exclude?: string[]; about?: string[]; @@ -444,4 +446,186 @@ describe("PR #11 review regressions", () => { writeFileSync(path, `${lines.map((entry) => JSON.stringify(entry)).join("\n")}\n`); expect(() => x.restart()).toThrow(); }); + + it("N1 a partial sibling exclusion cannot confirm the original frozen targets", async () => { + const x = await setup(); + await selectAll(x.host()); + plan = { act: "request", include: ["t2", "t4"], capability: "task.complete", risk: 1 }; + await x.host().runTurn({ observationId: "n1-gate", utterance: "Complete both", now: T1 }); + plan = { act: "affirm" }; + const original = await x.host().runTurn({ observationId: "n1-frame", utterance: "Yes", now: T2 }); + const turn = structuredClone(original.turn); + turn.propositions.push({ + act: "request", + operation: "exclude", + polarity: "exclude", + refs: [ref("t2")], + decisions: turn.propositions[0]!.decisions, + }); + const result = planLanguageCommands({ + turn, + frame: original.frame, + commandIdFor: (_proposition, index) => `n1:${index}`, + }); + expect(result.commands.some((command) => command.kind === "confirm")).toBe(false); + }); + + it("N2 an unaccepted sibling exclusion prevents confirmation", async () => { + const x = await setup(); + await selectAll(x.host()); + await gateComplete(x.host()); + plan = { act: "affirm" }; + const original = await x.host().runTurn({ observationId: "n2-frame", utterance: "Yes", now: T2 }); + const turn = structuredClone(original.turn); + turn.propositions.push({ + act: "request", + operation: "exclude", + polarity: "exclude", + refs: [ref("t2")], + decisions: ["unaccepted-decision"], + }); + const result = planLanguageCommands({ + turn, + frame: original.frame, + commandIdFor: (_proposition, index) => `n2:${index}`, + }); + expect(result.commands).toEqual([]); + }); + + it("N3 confirmation cannot depend on a rejected prerequisite", async () => { + const x = await setup(); + await selectAll(x.host()); + await gateComplete(x.host()); + plan = { act: "affirm" }; + const original = await x.host().runTurn({ observationId: "n3-frame", utterance: "Yes", now: T2 }); + const turn = structuredClone(original.turn); + const confirm = turn.propositions[0]!; + const predecessor: (typeof turn.propositions)[number] = { + act: "request", + operation: "reference", + refs: [ref("t2")], + polarity: "include", + decisions: confirm.decisions, + }; + turn.propositions = [ + predecessor, + { ...confirm, relations: [{ kind: "dependsOn", targetIndex: 0 }] }, + ]; + const compiled = compileSemanticTurn(turn, { frame: original.frame }); + expect(compiled.rejected.some((entry) => entry.index === 0)).toBe(true); + const result = planLanguageCommands({ + turn, + frame: original.frame, + commandIdFor: (_proposition, index) => `n3:${index}`, + }); + expect(result.commands).toEqual([]); + }); + + it("N4 a correction cannot bind a ref missing from the certified frame", async () => { + const x = await setup(); + await selectAll(x.host()); + await gateComplete(x.host()); + plan = { act: "affirm" }; + const original = await x.host().runTurn({ observationId: "n4-frame", utterance: "Yes", now: T2 }); + const proposition = original.turn.propositions[0]!; + const turn = structuredClone(original.turn); + turn.propositions = [ + { + ...proposition, + operation: "correction", + refs: [ref("not-in-frame")], + slots: [{ slot: "targets", refs: [ref("not-in-frame")] }], + targetIntentRevision: original.frame.interaction.liveObligation!.revision, + }, + ]; + const result = planLanguageCommands({ + turn, + frame: original.frame, + commandIdFor: (_proposition, index) => `n4:${index}`, + }); + expect(result.commands).toEqual([]); + }); + + it("N5 an unrelated request does not resolve a pending clarification", async () => { + const x = await setup(); + await selectAll(x.host()); + plan = { act: "request", diffuseInclude: 0.55, capability: "task.complete" }; + const ambiguous = await x.host().runTurn({ + observationId: "n5-open", + utterance: "Complete the failing one", + now: T1, + }); + plan = { act: "request", include: ["t4"], capability: "task.pause" }; + const other = await x.host().runTurn({ + observationId: "n5-other", + utterance: "Separately pause Docs refresh", + now: T2, + }); + expect( + other.results.flatMap((result) => result.effects).some((effect) => effect.status === "SUCCEEDED"), + ).toBe(true); + plan = { act: "none" }; + const after = await x.host().runTurn({ observationId: "n5-next", utterance: "Hmm", now: T3 }); + expect(after.frame.interaction.pendingQuestion?.question).toBe(ambiguous.question); + }); + + it("N5b an explicit clarification answer resolves the question", async () => { + const x = await setup(); + await selectAll(x.host()); + plan = { act: "request", diffuseInclude: 0.55, capability: "task.complete" }; + const ambiguous = await x.host().runTurn({ + observationId: "n5b-open", + utterance: "Complete the failing one", + now: T1, + }); + plan = { act: "clarify", include: ["t1"], capability: "task.complete", risk: 1 }; + const answer = await x.host().runTurn({ + observationId: "n5b-answer", + utterance: "The nightly pipeline.", + now: T2, + }); + expect(answer.frame.interaction.pendingQuestion?.question).toBe(ambiguous.question); + expect(answer.commands.map((command) => command.kind)).toEqual(["propose"]); + plan = { act: "none" }; + const after = await x.host().runTurn({ observationId: "n5b-next", utterance: "Hmm", now: T3 }); + expect(after.frame.interaction.pendingQuestion).toBeUndefined(); + }); + + it("N8 an unrecognized 404 response does not prove the action was refused", async () => { + const service = await createTaskService({ seed: demoTaskSeed() }); + services.push(service); + const flakyFetch: typeof fetch = async (input, init) => { + const response = await fetch(input, init); + if (String(input).includes("/actions")) { + return new Response("upstream not found", { status: 404 }); + } + return response; + }; + const adapter = createHttpTaskAdapter({ baseUrl: service.url, fetch: flakyFetch }); + const graph = createInterpretationGraph({ capabilityRisk: taskCapabilityRisk() }); + const directory = mkdtempSync(join(tmpdir(), "circe-review-404-")); + directories.push(directory); + const host = createFileContinuingInteractionHost({ + interactionId: "interaction:review-404", + journalPath: join(directory, "journal.jsonl"), + contextPath: join(directory, "context.jsonl"), + capabilities: TASK_CAPABILITY_SEMANTICS, + workflow: graph.workflow, + backend, + policy: ACCEPTANCE_POLICY_V1, + propositions: graph.propositions, + authorities: [adapter.authority], + executors: [adapter.executor], + }); + hosts.push(host); + await selectAll(host); + plan = { act: "request", include: ["t1"], capability: "task.pause" }; + const report = await host.runTurn({ + observationId: "n8-response", + utterance: "Pause nightly pipeline", + now: T1, + }); + expect(service.snapshot().tasks.find((task) => task.id === "t1")?.status).toBe("paused"); + expect(report.results.flatMap((result) => result.effects)[0]?.status).toBe("UNKNOWN"); + }); }); diff --git a/apps/task-service/src/durability.test.ts b/apps/task-service/src/durability.test.ts new file mode 100644 index 0000000..a2e2b0c --- /dev/null +++ b/apps/task-service/src/durability.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +/** + * Durability fault injection at the filesystem boundary: a short write and a + * directory fsync I/O failure must never be acknowledged as a durable success. + */ + +const fault = vi.hoisted(() => ({ short: false, dirSync: false })); + +vi.mock("node:fs", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + writeSync: (descriptor: number, value: string, ...rest: unknown[]) => { + if (fault.short) { + fault.short = false; + return fs.writeSync(descriptor, (value as string).slice(0, 24)); + } + return (fs.writeSync as (...args: unknown[]) => number)(descriptor, value, ...rest); + }, + fsyncSync: (descriptor: number) => { + if (fault.dirSync && fs.fstatSync(descriptor).isDirectory()) { + const error = new Error("injected directory I/O failure") as NodeJS.ErrnoException; + error.code = "EIO"; + throw error; + } + return fs.fsyncSync(descriptor); + }, + }; +}); + +const { createTaskService } = await import("./service"); + +describe("task service durability faults", () => { + it("completes a short write instead of acknowledging truncated state", async () => { + const directory = mkdtempSync(join(tmpdir(), "circe-short-")); + const file = join(directory, "state.json"); + const service = await createTaskService({ file, seed: [{ id: "t1", title: "Task" }] }); + try { + fault.short = true; + const response = await fetch(`${service.url}/tasks/t1/actions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "pause", operationKey: "short-op", expectedVersion: 1 }), + }); + expect(response.status).toBe(200); + expect(() => JSON.parse(readFileSync(file, "utf8")) as unknown).not.toThrow(); + const persisted = JSON.parse(readFileSync(file, "utf8")) as { + tasks: Array<{ id: string; status: string }>; + }; + expect(persisted.tasks.find((task) => task.id === "t1")?.status).toBe("paused"); + } finally { + fault.short = false; + await service.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); + + it("reports a directory fsync I/O failure instead of acknowledging durability", async () => { + const directory = mkdtempSync(join(tmpdir(), "circe-dir-sync-")); + const file = join(directory, "state.json"); + const service = await createTaskService({ file, seed: [{ id: "t1", title: "Task" }] }); + try { + fault.dirSync = true; + const response = await fetch(`${service.url}/tasks/t1/actions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ action: "pause", operationKey: "sync-op", expectedVersion: 1 }), + }); + expect(response.status).toBeGreaterThanOrEqual(500); + // The rename already happened, so the operation is recorded and the + // caller reconciles rather than retrying. + expect(service.snapshot().operations.map((operation) => operation.key)).toContain("sync-op"); + } finally { + fault.dirSync = false; + await service.close(); + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/task-service/src/service.ts b/apps/task-service/src/service.ts index 90bd7bd..86d2924 100644 --- a/apps/task-service/src/service.ts +++ b/apps/task-service/src/service.ts @@ -31,10 +31,12 @@ import { dirname } from "node:path"; * replay returns the original outcome and reconciliation can read it back. * * Durability boundary: a successful response means the operation is durable - * against process crash and against power loss (the file and its directory - * entry are fsynced before the response). An internal fault (a failed write, - * for example) is reported as 5xx, never as a refusal: the caller must treat - * the outcome as unconfirmed and reconcile. + * against process crash and power loss (every byte written, the file fsynced, + * the rename applied, and the directory entry fsynced before the response). + * A write failure before the rename is an internal fault with memory + * unchanged; a directory-sync failure after the rename is an unconfirmed + * durability outcome (5xx) whose operation is still recorded, so the caller + * reconciles rather than retries. */ export type TaskStatus = "running" | "paused" | "completed"; @@ -270,32 +272,39 @@ export function createTaskService(options: TaskServiceOptions = {}): Promise { } }); + it("treats an unrecognized 404 body as an unconfirmed outcome", async () => { + // A proxy or an upstream error page can return 404 without the service's + // refusal payload; that is missing outcome evidence, not a refusal. + const stub = await startStub((_request, respond) => { + respond(404, { error: "upstream-not-found" }); + }); + const adapter = createHttpTaskAdapter({ baseUrl: stub.url }); + await expect(adapter.executor.invoke(invokeRequest())).rejects.toThrow(/404/); + await stub.close(); + + const plain = await startStub((_request, respond) => { + respond(404, "upstream not found"); + }); + const second = createHttpTaskAdapter({ baseUrl: plain.url }); + await expect(second.executor.invoke(invokeRequest())).rejects.toThrow(/404/); + await plain.close(); + }); + it("throws on an unconfirmed transport outcome instead of claiming failure", async () => { const stub = await startStub((_request, respond) => respond(503, { error: "unavailable" })); const adapter = createHttpTaskAdapter({ baseUrl: stub.url }); diff --git a/packages/adapter-http-tasks/src/adapter.ts b/packages/adapter-http-tasks/src/adapter.ts index 340436a..923c0a6 100644 --- a/packages/adapter-http-tasks/src/adapter.ts +++ b/packages/adapter-http-tasks/src/adapter.ts @@ -424,7 +424,7 @@ export function createHttpTaskAdapter(config: HttpTaskAdapterConfig): HttpTaskAd // Any other status or body (5xx, unknown codes, unreadable payload) is // an unconfirmed outcome and must not be reported as a refusal. const code = isRecord(body) && typeof body.error === "string" ? body.error : undefined; - if (response.status === 404) { + if (response.status === 404 && code === "not-found") { return { ok: false, failure: { kind: "TARGET_INVALIDATED", detail: `task ${target.id} no longer exists` }, diff --git a/packages/perception/src/interpret.test.ts b/packages/perception/src/interpret.test.ts index b5e0a95..173633f 100644 --- a/packages/perception/src/interpret.test.ts +++ b/packages/perception/src/interpret.test.ts @@ -8,7 +8,7 @@ import type { import { describe, expect, it } from "vitest"; import { createInterpretationGraph, type InterpretationGraph } from "./interpret"; import { runPerceptionWorkflow } from "./orchestrator"; -import { compileSemanticTurn, semanticTurnFromRun } from "./proposals"; +import { compileSemanticTurn, semanticTurnFromRun, planTurnCommands } from "./proposals"; import { ACCEPTANCE_POLICY_V1 } from "./policy"; import type { DecisionAnswer, PerceptionBackend, PerceptionStatus, RiskLevel } from "./types"; @@ -420,6 +420,72 @@ describe("interpretation graph", () => { expect(compiled.commands).toEqual([]); }); + it("names the pending question when an answer resolves it", async () => { + const graph = createInterpretationGraph(); + const contextFrame = tasksFrame({ + pendingQuestion: { + questionId: "question:1", + awaiting: "disambiguation", + question: "Which item did you mean?", + candidates: [a, b, c], + }, + }); + const runResult = await run(graph, contextFrame, { + act: choice("act", "clarify", { clarify: 0.94, request: 0.02, query: 0.02, reject: 0.01, none: 0.01 }), + "role.include.0": binary("role.include.0", 0.95), + "role.include.1": binary("role.include.1", 0.02), + "role.include.2": binary("role.include.2", 0.02), + "role.exclude.0": binary("role.exclude.0", 0.02), + "role.exclude.1": binary("role.exclude.1", 0.02), + "role.exclude.2": binary("role.exclude.2", 0.02), + capability: choice("capability", "task.pause", { "task.pause": 0.95, "task.complete": 0.03, none: 0.02 }), + }); + expect(runResult.status).toBe("RESOLVED"); + const propositions = graph.propositions(runResult, contextFrame); + expect(propositions).toHaveLength(1); + expect(propositions[0]).toMatchObject({ + operation: "reference", + capability: "task.pause", + answersQuestionId: "question:1", + }); + }); + + it("abandons a pending question on rejection without authorizing anything", async () => { + const graph = createInterpretationGraph(); + const contextFrame = tasksFrame({ + pendingQuestion: { + questionId: "question:2", + awaiting: "disambiguation", + question: "Which item did you mean?", + candidates: [a, b], + }, + }); + const runResult = await run(graph, contextFrame, { + act: choice("act", "reject", { reject: 0.94, request: 0.02, query: 0.02, clarify: 0.01, none: 0.01 }), + "role.include.0": binary("role.include.0", 0.02), + "role.include.1": binary("role.include.1", 0.02), + "role.include.2": binary("role.include.2", 0.02), + "role.exclude.0": binary("role.exclude.0", 0.02), + "role.exclude.1": binary("role.exclude.1", 0.02), + "role.exclude.2": binary("role.exclude.2", 0.02), + }); + const propositions = graph.propositions(runResult, contextFrame); + expect(propositions).toEqual([ + expect.objectContaining({ + operation: "cancel", + answersQuestionId: "question:2", + refs: [a, b], + }), + ]); + const plan = planTurnCommands(semanticTurnFromRun(runResult, propositions), { + frame: contextFrame, + commandIdFor: (_proposition, index) => `plan:${index}`, + }); + // Abandoning a question is an acknowledgement, not a command. + expect(plan.commands).toEqual([]); + expect(plan.answers).toEqual(["question:2"]); + }); + it("works the same for message candidates", async () => { const graph = createInterpretationGraph(); const m1 = ref("m1", "message"); diff --git a/packages/perception/src/interpret.ts b/packages/perception/src/interpret.ts index 0ede981..0e8bfaf 100644 --- a/packages/perception/src/interpret.ts +++ b/packages/perception/src/interpret.ts @@ -102,6 +102,7 @@ export function createInterpretationGraph( const hasObligation = frame.interaction.liveObligation !== undefined || frame.interaction.pendingIntent !== undefined; + const hasQuestion = frame.interaction.pendingQuestion !== undefined; return { id: ACT_SPEC, kind: "choice", @@ -135,6 +136,23 @@ export function createInterpretationGraph( }, ] : []), + ...(hasQuestion + ? [ + { + id: "clarify", + description: + "An answer to the pending question shown in the frame, resolving which item was meant", + }, + ...(hasObligation + ? [] + : [ + { + id: "reject", + description: "Abandoning the pending question without an answer", + }, + ]), + ] + : []), { id: "none", description: "Social speech, an unsupported act, or no actionable instruction", @@ -157,7 +175,13 @@ export function createInterpretationGraph( affects: [entityRefKey(candidate.ref)], })); } - if (act !== "request" && act !== "correction" && act !== "affirm" && act !== "reject") { + if ( + act !== "request" && + act !== "correction" && + act !== "affirm" && + act !== "reject" && + act !== "clarify" + ) { return []; } const specs: DecisionSpec[] = []; @@ -253,7 +277,7 @@ export function createInterpretationGraph( id: "capability", specs: (state, frame) => { const act = choiceOf(state, ACT_SPEC); - if (act !== "request" && act !== "correction") return []; + if (act !== "request" && act !== "correction" && act !== "clarify") return []; const acceptedIncludes = acceptedRoleRefs(state.accepted, frame, "include"); if (acceptedIncludes.length === 0) return []; if (frame.capabilities.length === 0) return []; @@ -313,7 +337,7 @@ export function createInterpretationGraph( ], resolveStatus(state: WorkflowState) { const act = choiceOf(state, ACT_SPEC); - if (act !== "request" && act !== "correction") return undefined; + if (act !== "request" && act !== "correction" && act !== "clarify") return undefined; // A request whose roles were all judged false names nothing to act on; // asking which item is safer than silently doing nothing. const namedSomething = [...state.accepted.entries()].some( @@ -396,7 +420,24 @@ export function createInterpretationGraph( ...exclusionPropositionsFor(excludes, decisionsFor), ]; } - if (!obligation) return []; + if (!obligation) { + // Rejecting a pending question abandons it; it authorizes nothing. + // The proposition names the obligation so the host can close it. + const question = frame.interaction.pendingQuestion; + if (act === "reject" && question) { + return [ + { + act, + operation: "cancel", + refs: question.candidates.map(cloneRef), + polarity: "include", + answersQuestionId: question.questionId, + decisions: [actTrace], + }, + ]; + } + return []; + } return [ { act, @@ -408,7 +449,20 @@ export function createInterpretationGraph( ]; } - if (act !== "request" && act !== "correction") return []; + if (act !== "request" && act !== "correction" && act !== "clarify") return []; + + // An answer to a pending question builds the same operation a request + // would, and names the obligation so resolution is explicit rather than + // inferred from an emitted command. + const answeredQuestionId = + act === "clarify" ? frame.interaction.pendingQuestion?.questionId : undefined; + const withAnswer = (propositions: SemanticProposition[]): SemanticProposition[] => + answeredQuestionId === undefined + ? propositions + : propositions.map((proposition) => ({ + ...proposition, + answersQuestionId: answeredQuestionId, + })); // A correction adjusts the pending intent. A request always builds the // operation the accepted capability names: an exclusion never converts a @@ -425,19 +479,19 @@ export function createInterpretationGraph( ...excludes.map((entry) => entry.specId), ]), }); - return [ + return withAnswer([ ...(adjustment ? [adjustment] : []), ...exclusionPropositionsFor(excludes, decisionsFor), - ]; + ]); } const exclusionPropositions = exclusionPropositionsFor(excludes, decisionsFor); const capability = acceptedChoice(run, CAPABILITY_SPEC); - if (includes.length === 0) return exclusionPropositions; - if (!capability || capability === "none") return exclusionPropositions; + if (includes.length === 0) return withAnswer(exclusionPropositions); + if (!capability || capability === "none") return withAnswer(exclusionPropositions); const capabilityTrace = traceIdFor(run, CAPABILITY_SPEC); - if (!capabilityTrace) return exclusionPropositions; + if (!capabilityTrace) return withAnswer(exclusionPropositions); const riskAnswer = run.state.accepted.get(RISK_SPEC); const risk = riskAnswer?.kind === "score" @@ -468,11 +522,7 @@ export function createInterpretationGraph( ...(riskTrace ? [riskTrace] : []), ], }; - const includeIndex = exclusionPropositions.length; - return [ - ...exclusionPropositions, - include, - ]; + return withAnswer([...exclusionPropositions, include]); } return { workflow, propositions }; diff --git a/packages/perception/src/proposals.ts b/packages/perception/src/proposals.ts index c03bb89..c093ad3 100644 --- a/packages/perception/src/proposals.ts +++ b/packages/perception/src/proposals.ts @@ -491,7 +491,14 @@ interface EvaluatedProposition { index: number; valid: boolean; reason?: string; + /** executable proposal command (proposals only) */ command?: ProposeCommand; + /** typed continuation command (confirm/cancel/revise) */ + continuation?: KernelCommand; + /** grounded answer to an inspect proposition */ + answer?: string; + /** a refused adjustment that must be asked about, never silently dropped */ + clarification?: TurnClarification; } interface EvaluateTurnOptions { @@ -503,14 +510,29 @@ interface EvaluateTurnOptions { requireFrameEvidence: boolean; commandIdFor: (proposition: SemanticProposition, index: number) => string; citationFor?: (proposition: SemanticProposition) => string | undefined; + /** + * Evaluate commit/cancel/correction/inspect as consequence-bearing + * operations. When false they are rejected with the legacy propose-only + * reasons; either way they run through this same ordered evaluation. + */ + continuations?: boolean; +} + +function isContinuationOperation(operation: SemanticProposition["operation"]): boolean { + return ( + operation === "commit" || + operation === "cancel" || + operation === "correction" || + operation === "inspect" + ); } /** - * One whole-turn evaluation used by both entry points: structural validity, + * One whole-turn evaluation used by every entry point: structural validity, * prohibitions, exclusions, dependency propagation, evidence coverage and - * command construction. A proposition is only valid when every check passes - * and every dependency is itself valid — acceptance of a judgment is not - * acceptance of the proposition it supports. + * command construction. Proposals and continuations share the same ordered + * pass, so a continuation can only depend on a predecessor this evaluation + * already accepted, and no operation kind has a private validator. */ function evaluateTurn( turn: SemanticTurn, @@ -524,17 +546,207 @@ function evaluateTurn( })); const rejectedIndexes = new Set(); + const dependencyIssue = ( + proposition: SemanticProposition, + index: number, + ): string | undefined => + (proposition.relations ?? []) + .filter((relation) => relation.kind === "dependsOn") + .map((relation) => { + if (relation.targetIndex >= index) { + return "forward dependencies are not an executable form"; + } + if (rejectedIndexes.has(relation.targetIndex)) { + return "a dependency proposition was rejected"; + } + const related = turn.propositions[relation.targetIndex]; + if (!related) return "malformed proposition: relation target is missing"; + if (!options.isAccepted(related)) { + return "a dependency proposition was not accepted"; + } + return undefined; + }) + .find((problem) => problem !== undefined); + turn.propositions.forEach((proposition, index) => { const fail = (reason: string): void => { results[index] = { index, valid: false, reason }; rejectedIndexes.add(index); }; + const clarify = (reason: string, candidates: EntityRef[]): void => { + results[index] = { + index, + valid: false, + reason, + clarification: { reason, candidates: candidates.map(cloneRef) }, + }; + rejectedIndexes.add(index); + }; if (issues[index]) return fail(issues[index]!); if (analysis.prohibited.has(index)) return fail("prohibited by another proposition"); if (analysis.unvalidatedProhibited.has(index)) { return fail("a prohibition affecting this proposition could not be validated"); } if (isExclusion(proposition)) return fail("exclusion constraint"); + + if (options.continuations && isContinuationOperation(proposition.operation)) { + if (!decisionsAccepted(proposition, options.acceptedDecisions)) { + return fail("supporting decisions were not accepted"); + } + const citation = options.citationFor?.(proposition); + if (citation) return fail(citation); + const dependency = dependencyIssue(proposition, index); + if (dependency) return fail(dependency); + const relation = relationIssue(turn, proposition, index, options.isAccepted); + if (relation) return fail(relation); + if (!options.frame) return fail("no frame evidence supplied"); + + const frame = options.frame; + const obligation = frame.interaction.liveObligation; + const pendingIntent = frame.interaction.pendingIntent; + const pending = obligation ?? pendingIntent; + const commandId = options.commandIdFor(proposition, index); + + if (proposition.operation === "commit" || proposition.operation === "cancel") { + if (proposition.act !== "affirm" && proposition.act !== "reject") { + return fail(`operation ${proposition.operation} requires an affirmation or rejection`); + } + if (proposition.operation === "cancel") { + if (!obligation) { + // Abandoning a pending question authorizes nothing: the accepted + // proposition names the obligation and carries no command. + if (proposition.answersQuestionId !== undefined) { + results[index] = { index, valid: true }; + return; + } + return fail("no live obligation to cancel"); + } + results[index] = { + index, + valid: true, + continuation: { + kind: "cancel", + commandId, + transactionId: obligation.transactionId, + }, + }; + return; + } + if (!obligation) return fail("no live obligation to confirm"); + // A confirmation executes the frozen obligation target set. An + // adjustment — accepted or unvalidated — that changes that set must + // become a revision or a question; the old revision is never confirmed. + const frozenKeys = new Set(obligation.targets.map(entityRefKey)); + const unvalidatedHit = [...analysis.unvalidatedExclusionRefs].some((key) => + frozenKeys.has(key), + ); + if (unvalidatedHit) { + return clarify( + "an unvalidated exclusion affects the frozen obligation targets", + obligation.targets, + ); + } + const acceptedHit = [...analysis.excludedRefs].some((key) => frozenKeys.has(key)); + if (acceptedHit) { + return clarify( + "an accepted adjustment changes the frozen obligation targets; revise or clarify", + obligation.targets, + ); + } + const refs = proposition.refs; + const refsMatch = + refs.length === frozenKeys.size && + refs.every((ref) => frozenKeys.has(entityRefKey(ref))); + if (!refsMatch) { + return clarify( + "confirmation refs do not match the frozen obligation targets", + obligation.targets, + ); + } + results[index] = { + index, + valid: true, + continuation: { + kind: "confirm", + commandId, + transactionId: obligation.transactionId, + intentRevision: obligation.revision, + }, + }; + return; + } + + if (proposition.operation === "correction") { + if (proposition.targetIntentRevision === undefined) { + return fail("correction does not name the revision it replaces"); + } + if (!pending) return fail("no pending intent to correct"); + const capability = pendingIntent?.capability; + const offered = capability + ? frame.capabilities.find((entry) => entry.capabilityId === capability) + : undefined; + const declaredNames = new Set((offered?.slots ?? []).map((slot) => slot.name)); + const declaredSlots = + proposition.slots && proposition.slots.length > 0 + ? proposition.slots + : [{ slot: "targets", refs: proposition.refs }]; + if (offered && declaredNames.size > 0) { + const unknown = declaredSlots.find((slot) => !declaredNames.has(slot.slot)); + if (unknown) { + return fail(`slot ${unknown.slot} is not offered by capability ${capability}`); + } + } + const slots = declaredSlots + .map((slot) => ({ + slot: slot.slot, + refs: slot.refs.filter((ref) => !analysis.excludedRefs.has(entityRefKey(ref))), + })) + .filter((slot) => slot.refs.length > 0); + if (slots.length === 0) { + return clarify("an accepted adjustment leaves no targets to correct", pending.targets); + } + const refs = [...slots.flatMap((slot) => slot.refs), ...argumentRefsOf(proposition)]; + const evidence = expectedVersionsFromFrame(frame, refs); + for (const ref of refs) { + if ( + !evidence.some( + (precondition) => entityRefKey(precondition.ref) === entityRefKey(ref), + ) + ) { + return fail(`no frame observation for ${entityRefKey(ref)}`); + } + } + results[index] = { + index, + valid: true, + continuation: { + kind: "revise", + commandId, + ...(obligation !== undefined ? { transactionId: obligation.transactionId } : {}), + expectedIntentRevision: proposition.targetIntentRevision, + slots: slots.map((slot) => ({ + slot: slot.slot, + refs: slot.refs, + expectations: evidence.filter((precondition) => + slot.refs.some( + (ref) => entityRefKey(ref) === entityRefKey(precondition.ref), + ), + ), + })), + }, + }; + return; + } + + // inspect + results[index] = { + index, + valid: true, + answer: groundedAnswer(frame, proposition.refs), + }; + return; + } + const executable = executableIssue(proposition); if (executable) return fail(executable); // An empty or unaccepted decision list is not evidence of anything. @@ -588,17 +800,7 @@ function evaluateTurn( } // Dependencies must be satisfiable executable forms: a rejected or // forward prerequisite cannot silently authorize its dependent. - const dependencyProblem = (proposition.relations ?? []) - .filter((relation) => relation.kind === "dependsOn") - .map((relation) => { - if (relation.targetIndex >= index) return "forward dependencies are not an executable form"; - if (rejectedIndexes.has(relation.targetIndex)) return "a dependency proposition was rejected"; - if (!options.isAccepted(turn.propositions[relation.targetIndex]!)) { - return "a dependency proposition was not accepted"; - } - return undefined; - }) - .find((problem) => problem !== undefined); + const dependencyProblem = dependencyIssue(proposition, index); if (dependencyProblem) return fail(dependencyProblem); const relation = relationIssue(turn, proposition, index, options.isAccepted); if (relation) return fail(relation); @@ -809,6 +1011,11 @@ export interface TurnPlan { answer?: string; /** the turn could not become a command and must be clarified */ clarification?: TurnClarification; + /** + * Question ids the accepted turn explicitly answers or supersedes. Only + * these close an obligation; an emitted command never implies resolution. + */ + answers: string[]; } export interface TurnPlanOptions { @@ -878,47 +1085,14 @@ export function clarificationText(frame: ContextFrame, clarification: TurnClarif return `Which item did you mean by "${frame.utterance.text}"? Candidates: ${list}`; } -function continuationIssue( - turn: SemanticTurn, - proposition: SemanticProposition, - index: number, - analysis: TurnAnalysis, - acceptedDecisions: ReadonlySet, - tracesById: ReadonlyMap, - tracesAvailable: boolean, -): string | undefined { - if (analysis.prohibited.has(index)) return "prohibited by another proposition"; - if (analysis.unvalidatedProhibited.has(index)) { - return "a prohibition affecting this proposition could not be validated"; - } - const citation = citationIssue( - proposition, - turn, - acceptedDecisions, - tracesById, - tracesAvailable, - ); - if (citation) return citation; - for (const relation of proposition.relations ?? []) { - if (relation.kind !== "dependsOn") continue; - if (relation.targetIndex >= index) return "forward dependencies are not an executable form"; - const related = turn.propositions[relation.targetIndex]; - if (!related) return "malformed proposition: relation target is missing"; - const relatedAccepted = - decisionsAccepted(related, acceptedDecisions) && - citationIssue(related, turn, acceptedDecisions, tracesById, tracesAvailable) === undefined; - if (!relatedAccepted) return "a dependency proposition was not accepted"; - } - return undefined; -} - /** - * The one whole-turn command plan. Proposals come from the existing - * whole-turn evaluation; confirm/cancel/correction come from the same turn - * analysis (frame identity, structural issues, prohibitions, exclusions, - * dependencies, citations and frame evidence) instead of a parallel check. - * An accepted adjustment that cannot become a revision is a clarification, - * never a silent confirmation of the targets the user adjusted. + * The one whole-turn command plan. Every consequence-bearing operation is + * evaluated by the same ordered `evaluateTurn` pass — frame identity, + * structural issues, prohibitions, exclusions, dependencies, citations and + * frame evidence — so a continuation can only depend on a predecessor that + * pass accepted, and no operation kind has a private validator. An accepted + * adjustment that cannot become a revision is a clarification, never a silent + * confirmation of the targets the user adjusted. */ export function planTurnCommands( turn: SemanticTurn, @@ -932,6 +1106,7 @@ export function planTurnCommands( const rejectAll = (reason: string): TurnPlan => ({ commands: [], rejected: turn.propositions.map((_, index) => ({ index, reason })), + answers: [], }); // Evidence must come from the frame the turn was decided against: both the @@ -960,6 +1135,7 @@ export function planTurnCommands( index, reason: `turn status ${turn.status} is not executable`, })), + answers: [], ...(clarification !== undefined ? { clarification } : {}), }; } @@ -972,6 +1148,7 @@ export function planTurnCommands( index, reason: issues[index] ?? "turn contains a malformed proposition", })), + answers: [], }; } @@ -990,163 +1167,53 @@ export function planTurnCommands( commandIdFor, citationFor: (proposition) => citationIssue(proposition, turn, acceptedDecisions, tracesById, tracesAvailable), + continuations: true, }); - const analysis = analyzeTurn(turn, accepted, issues); - const obligation = frame.interaction.liveObligation; - const pendingIntent = frame.interaction.pendingIntent; - const pending = obligation ?? pendingIntent; + const pending = frame.interaction.liveObligation ?? frame.interaction.pendingIntent; const commands: KernelCommand[] = []; const rejected: RejectedProposition[] = []; + const answers = new Set(); let answer: string | undefined; let clarification: TurnClarification | undefined; - let adjustmentWithoutTargets = false; let acceptedExclusions = 0; turn.propositions.forEach((proposition, index) => { - if (issues[index]) { - rejected.push({ index, reason: issues[index]! }); - return; - } + const entry = evaluated[index]!; if (isExclusion(proposition)) { if (accepted(proposition)) acceptedExclusions += 1; - rejected.push({ index, reason: "exclusion constraint" }); + rejected.push({ index, reason: entry.reason ?? "exclusion constraint" }); return; } - const operation = proposition.operation; - - if (operation === "commit" || operation === "cancel" || operation === "correction") { - const issue = continuationIssue( - turn, - proposition, - index, - analysis, - acceptedDecisions, - tracesById, - tracesAvailable, - ); - if (issue) { - rejected.push({ index, reason: issue }); - return; + if (!entry.valid) { + rejected.push({ index, reason: entry.reason ?? "proposition is not executable" }); + if (entry.clarification && clarification === undefined) { + clarification = entry.clarification; } - - if (operation === "commit" || operation === "cancel") { - if (proposition.act !== "affirm" && proposition.act !== "reject") { - rejected.push({ - index, - reason: `operation ${operation} requires an affirmation or rejection`, - }); - return; - } - if (!obligation) { - rejected.push({ - index, - reason: `no live obligation to ${operation === "commit" ? "confirm" : "cancel"}`, - }); - return; - } - if (operation === "cancel") { - commands.push({ - kind: "cancel", - commandId: commandIdFor(proposition, index), - transactionId: obligation.transactionId, - }); - return; - } - // A sibling exclusion constrains a confirmation exactly as it - // constrains a proposal. - const remaining = proposition.refs.filter( - (ref) => !analysis.excludedRefs.has(entityRefKey(ref)), - ); - if (remaining.length === 0) { - rejected.push({ index, reason: "an accepted adjustment leaves no targets to confirm" }); - adjustmentWithoutTargets = true; - return; - } - commands.push({ - kind: "confirm", - commandId: commandIdFor(proposition, index), - transactionId: obligation.transactionId, - intentRevision: obligation.revision, - }); - return; - } - - if (proposition.targetIntentRevision === undefined) { - rejected.push({ index, reason: "correction does not name the revision it replaces" }); - return; - } - if (!pending) { - rejected.push({ index, reason: "no pending intent to correct" }); - return; - } - const declaredSlots = - proposition.slots && proposition.slots.length > 0 - ? proposition.slots - : [{ slot: "targets", refs: proposition.refs }]; - const slots = declaredSlots - .map((slot) => ({ - slot: slot.slot, - refs: slot.refs.filter((ref) => !analysis.excludedRefs.has(entityRefKey(ref))), - })) - .filter((slot) => slot.refs.length > 0); - if (slots.length === 0) { - rejected.push({ index, reason: "an accepted adjustment leaves no targets to correct" }); - adjustmentWithoutTargets = true; - return; - } - commands.push({ - kind: "revise", - commandId: commandIdFor(proposition, index), - ...(obligation !== undefined ? { transactionId: obligation.transactionId } : {}), - expectedIntentRevision: proposition.targetIntentRevision, - slots, - }); return; } - - if (operation === "inspect") { - if (!accepted(proposition)) { - rejected.push({ - index, - reason: - citationIssue(proposition, turn, acceptedDecisions, tracesById, tracesAvailable) ?? - "supporting decisions were not accepted", - }); - return; - } - answer = groundedAnswer(frame, proposition.refs); - return; + if (entry.command) commands.push(entry.command); + if (entry.continuation) commands.push(entry.continuation); + if (entry.answer !== undefined) answer = entry.answer; + if (proposition.answersQuestionId !== undefined) { + answers.add(proposition.answersQuestionId); } - - const entry = evaluated[index]!; - if (entry.valid && entry.command) { - commands.push(entry.command); - return; - } - rejected.push({ index, reason: entry.reason ?? "proposition is not executable" }); }); // An accepted adjustment that could not become a command must be asked // about, never dropped and never treated as agreement with the old targets. - if (commands.length === 0 && pending) { - if (adjustmentWithoutTargets) { - clarification = { - reason: "an accepted adjustment leaves no targets", - candidates: pending.targets.map(cloneRef), - }; - } else if (acceptedExclusions > 0) { - clarification = { - reason: "an accepted exclusion produced no executable change", - candidates: pending.targets.map(cloneRef), - }; - } + if (commands.length === 0 && pending && clarification === undefined && acceptedExclusions > 0) { + clarification = { + reason: "an accepted exclusion produced no executable change", + candidates: pending.targets.map(cloneRef), + }; } return { commands, rejected, + answers: [...answers], ...(answer !== undefined ? { answer } : {}), ...(clarification !== undefined ? { clarification } : {}), }; diff --git a/packages/perception/src/types.ts b/packages/perception/src/types.ts index e9f99cd..09d8b76 100644 --- a/packages/perception/src/types.ts +++ b/packages/perception/src/types.ts @@ -144,6 +144,12 @@ export interface SemanticProposition { risk?: PropositionRisk; decisions: DecisionId[]; relations?: PropositionRelation[]; + /** + * The pending question this proposition explicitly answers or supersedes. + * Resolution is never inferred from an emitted command: only a proposition + * that names the obligation can close it. + */ + answersQuestionId?: string; } export interface SemanticTurn {