diff --git a/README.md b/README.md index 06369f6..9d847ce 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,13 @@ abstention, and rejects a changed payload. Decision traces are persisted before The host exposes `command` for typed confirmation, revision, cancellation and resumption, and `reconcile` for unknown effects. +Correction replay now pins both the intent identity and its revision. Existing context +frames without the new obligation identity still decode, but older accepted-turn logs +containing corrections may fail replay validation. There is no automatic migration for +those pre-release logs: preserve the files and finish outstanding interactions with the +version that wrote them before starting a fresh interaction on this version. Current +version correction replay and changed-command rejection are covered by restart tests. + The reusable interpretation graph (`interpret-utterance.v1` in `@circe/perception`) runs over the actual frame: an act decision, per-candidate include/exclude roles for requests and corrections (combined with set algebra in code, so mixed clauses need no clause @@ -77,7 +84,12 @@ dependency and citation checks, so a continuation can only depend on a predecess 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, +carries their frozen versions. Proposals and corrections share binding normalization, +including unresolved exclusions, slot arity and summary/slot consistency. The compiler +checks the correction's destination identity and revision against the same pending intent; +the kernel checks that identity again before applying the revision. Entity-ref-valued +correction arguments and correction constraints currently clarify or reject because the +revision command cannot carry their complete evidence or semantics. 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. diff --git a/apps/inspector/src/host.test.ts b/apps/inspector/src/host.test.ts index 7b2b5e1..6906a84 100644 --- a/apps/inspector/src/host.test.ts +++ b/apps/inspector/src/host.test.ts @@ -155,6 +155,7 @@ describe("continuing interaction host", () => { ]); expect(interaction.liveObligation).toEqual({ transactionId: "tx:1", + intentId: "intent:1", state: "AWAITING_CONFIRMATION", revision: 3, targets: [ref], diff --git a/apps/inspector/src/host.ts b/apps/inspector/src/host.ts index 6c69e7d..dfeae36 100644 --- a/apps/inspector/src/host.ts +++ b/apps/inspector/src/host.ts @@ -201,6 +201,7 @@ export function frameInteractionFor( const liveObligation: FrameLiveObligation | undefined = presented ? { transactionId: presented.tx.id, + intentId: presented.intent.id, state: presented.tx.state, revision: presented.intent.revision, targets: boundRefs(presented.intent), diff --git a/apps/inspector/src/review-fixes.test.ts b/apps/inspector/src/review-fixes.test.ts index 222e1f0..63502e9 100644 --- a/apps/inspector/src/review-fixes.test.ts +++ b/apps/inspector/src/review-fixes.test.ts @@ -546,6 +546,73 @@ describe("PR #11 review regressions", () => { expect(result.commands).toEqual([]); }); + it("correction requires the matching intent identity and revision", async () => { + const x = await setup(); + await selectAll(x.host()); + await gateComplete(x.host()); + plan = { act: "affirm" }; + const original = await x.host().runTurn({ observationId: "intent-match-frame", utterance: "Yes", now: T2 }); + const base = original.turn.propositions[0]!; + const pending = original.frame.interaction.pendingIntent!; + const obligation = original.frame.interaction.liveObligation!; + expect(pending.intentId).toBe(obligation.intentId); + + const makeTurn = (overrides: Record) => { + const { omitIntentId, ...properties } = overrides; + const turn = structuredClone(original.turn); + turn.propositions = [{ + ...base, + operation: "correction", + refs: [ref("t4")], + slots: [{ slot: "targets", refs: [ref("t4")] }], + ...(omitIntentId ? {} : { targetIntentId: pending.intentId }), + targetIntentRevision: pending.revision, + ...properties, + } as typeof base]; + return turn; + }; + const compile = (turn: typeof original.turn, frame: ContextFrame = original.frame) => + planLanguageCommands({ turn, frame, commandIdFor: (_proposition, index) => `intent:${index}` }); + + expect(compile(makeTurn({})).commands.map((command) => command.kind)).toEqual(["revise"]); + expect(compile(makeTurn({ omitIntentId: true })).commands[0]).toMatchObject({ expectedIntentId: pending.intentId }); + expect(compile(makeTurn({ targetIntentId: "other-intent" })).commands).toEqual([]); + expect(compile(makeTurn({ targetIntentRevision: pending.revision - 1 })).commands).toEqual([]); + + const unassociated = structuredClone(original.frame); + unassociated.interaction.pendingIntent!.intentId = "other-intent"; + expect(compile(makeTurn({}), unassociated).commands).toEqual([]); + }); + + it("control: a certified retarget revokes confirmation and completes only the new target", async () => { + const x = await setup(); + await selectAll(x.host()); + await gateComplete(x.host()); + plan = { act: "correction", include: ["t4"], capability: "task.complete", risk: 1 }; + const revised = await x.host().runTurn({ observationId: "identity-retarget", utterance: "Use Docs refresh instead", now: T2 }); + expect(revised.commands.map((command) => command.kind)).toEqual(["revise"]); + expect(revised.results.flatMap((result) => result.errors ?? [])).toEqual([]); + expect(revised.results.flatMap((result) => result.effects)).toEqual([]); + const command = revised.commands[0]!; + if (command.kind !== "revise") throw new Error("expected a correction command"); + expect(command.expectedIntentId).toBe(revised.frame.interaction.pendingIntent?.intentId); + const beforeRestart = x.host().state(); + x.restart(); + expect(x.host().context.frame(revised.frame.id)?.interaction.liveObligation?.intentId) + .toBe(command.expectedIntentId); + await x.host().command(command, T3); + expect(x.host().state().intents).toEqual(beforeRestart.intents); + await expect(x.host().command({ ...command, expectedIntentId: "other-intent" }, T3)) + .rejects.toThrow(/reused with different content/); + plan = { act: "affirm" }; + const operationsBeforeConfirm = x.service.snapshot().operations.length; + await x.host().runTurn({ observationId: "identity-retarget-confirm", utterance: "Yes", now: T3 }); + await x.host().runTurn({ observationId: "identity-retarget-confirm", utterance: "Yes", now: T3 }); + expect(x.service.snapshot().operations).toHaveLength(operationsBeforeConfirm + 1); + expect(x.service.snapshot().tasks.find((task) => task.id === "t4")?.status).toBe("completed"); + expect(x.service.snapshot().tasks.find((task) => task.id === "t2")?.status).toBe("running"); + }); + it("N5 an unrelated request does not resolve a pending clarification", async () => { const x = await setup(); await selectAll(x.host()); diff --git a/packages/context-file/src/codec.ts b/packages/context-file/src/codec.ts index 2022f2b..f837fcd 100644 --- a/packages/context-file/src/codec.ts +++ b/packages/context-file/src/codec.ts @@ -632,6 +632,9 @@ function decodeFrameInteraction(value: unknown, where: string): ContextFrame["in const obligation = asRecord(liveObligation, `${where}.liveObligation`); return { transactionId: asString(field(obligation, "transactionId", where), `${where}.liveObligation.transactionId`), + ...(field(obligation, "intentId", where) !== undefined + ? { intentId: asString(field(obligation, "intentId", where), `${where}.liveObligation.intentId`) } + : {}), state: asEnum(field(obligation, "state", where), `${where}.liveObligation.state`, TRANSACTION_STATES), revision: asFiniteNumber(field(obligation, "revision", where), `${where}.liveObligation.revision`), targets: asRefs(field(obligation, "targets", where), `${where}.liveObligation.targets`), diff --git a/packages/context-file/src/index.test.ts b/packages/context-file/src/index.test.ts index dde7a88..80a870b 100644 --- a/packages/context-file/src/index.test.ts +++ b/packages/context-file/src/index.test.ts @@ -153,6 +153,35 @@ function buildRichStore(store: SemanticContextStoreContract) { } describe("durable semantic context store", () => { + it.each(["intent:1", undefined])("preserves obligation identity %s through restart", async (intentId) => { + const path = tempPath(); + const store = createFileContextStore(path); + const { frame } = await assembleFrame(store, { + interactionId: "conversation-1", + observationId: "identity-frame", + utterance: { text: "Use the other task", source: "text" }, + interaction: { + recentReferents: [], + liveObligation: { + transactionId: "tx:1", + ...(intentId === undefined ? {} : { intentId }), + state: "AWAITING_CONFIRMATION", + revision: 3, + targets: [a], + }, + }, + now: NOW, + }); + store.close(); + const reopened = createFileContextStore(path); + try { + expect(reopened.frame(frame.id)).toEqual(frame); + expect(reopened.frame(frame.id)?.interaction.liveObligation?.intentId).toBe(intentId); + } finally { + reopened.close(); + } + }); + it("preserves special probability keys while decoding", () => { const trace = { ...decisionTrace("decision:proto"), diff --git a/packages/context/src/types.ts b/packages/context/src/types.ts index 5994b69..bd7276a 100644 --- a/packages/context/src/types.ts +++ b/packages/context/src/types.ts @@ -333,6 +333,8 @@ export interface FramePendingIntent { export interface FrameLiveObligation { transactionId: string; + /** Intent backing this transaction, when the frame can materialize it. */ + intentId?: IntentId; state: TransactionState; revision: number; targets: EntityRef[]; diff --git a/packages/core/src/runtime.ts b/packages/core/src/runtime.ts index d22ef0d..ce2f095 100644 --- a/packages/core/src/runtime.ts +++ b/packages/core/src/runtime.ts @@ -1658,6 +1658,11 @@ export function createRuntime(deps: RuntimeDeps): SemanticRuntime { ctx.reply = "none"; return; } + if (command.expectedIntentId !== undefined && intent.id !== command.expectedIntentId) { + ctx.errors.push("STALE_INTENT"); + ctx.reply = "none"; + return; + } if (intent.revision !== command.expectedIntentRevision) { ctx.errors.push("STALE_INTENT"); ctx.reply = "none"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 55c8843..537f917 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1199,6 +1199,8 @@ export interface ReviseCommand { observationId?: EventId; transactionId?: TransactionId; expectedIntentRevision: number; + /** Reject if the transaction now points at a different intent. */ + expectedIntentId?: IntentId; slots: ProposedSlot[]; arguments?: Readonly>; provenance?: Provenance; diff --git a/packages/evals/src/accepted-turn-evidence-ownership.test.ts b/packages/evals/src/accepted-turn-evidence-ownership.test.ts index cefe6b1..48058b1 100644 --- a/packages/evals/src/accepted-turn-evidence-ownership.test.ts +++ b/packages/evals/src/accepted-turn-evidence-ownership.test.ts @@ -189,6 +189,34 @@ function evidenceRig( } describe("evidence ownership", () => { + it("rejects a revision if its expected intent identity differs from the transaction", async () => { + const a = ref("A"); + const r = evidenceRig("affirmative-confirmation"); + await r.runtime.command({ + kind: "propose", + commandId: "identity-p", + capability: "task.resume", + slots: [{ slot: "targets", refs: [a] }], + expectedVersions: [{ ref: a, token: token("1") }], + }); + const revised = await r.runtime.command({ + kind: "revise", + commandId: "identity-r", + expectedIntentId: "another-intent", + expectedIntentRevision: 1, + slots: [], + arguments: { note: "must not apply" }, + }); + expect(revised.errors).toContain("STALE_INTENT"); + const confirmed = await r.runtime.command({ + kind: "confirm", + commandId: "identity-c", + intentRevision: 1, + }); + expect(confirmed.errors).toBeUndefined(); + expect(r.invokes).toHaveLength(1); + }); + it("retires argument usage that a revision stops reading", async () => { const a = ref("A"); const b = ref("B"); diff --git a/packages/perception/src/interpret.test.ts b/packages/perception/src/interpret.test.ts index 173633f..d28f1fc 100644 --- a/packages/perception/src/interpret.test.ts +++ b/packages/perception/src/interpret.test.ts @@ -321,6 +321,66 @@ describe("interpretation graph", () => { }); }); + it("shares proposal binding safety checks with corrections", async () => { + async function correctionPlan() { + const graph = createInterpretationGraph(); + const contextFrame = tasksFrame({ + pendingIntent: { + intentId: "intent:binding", + revision: 3, + capability: "task.pause", + targets: [a, b], + state: "AWAITING_CONFIRMATION", + missingSlots: [], + }, + liveObligation: { + transactionId: "transaction:binding", + intentId: "intent:binding", + state: "AWAITING_CONFIRMATION", + revision: 3, + targets: [a, b], + }, + }); + const runResult = await run(graph, contextFrame, { + act: choice("act", "affirm", { affirm: 0.9, request: 0.04, reject: 0.03, correction: 0.02, 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.03), + "role.exclude.1": binary("role.exclude.1", 0.93), + "role.exclude.2": binary("role.exclude.2", 0.02), + }); + const turn = semanticTurnFromRun(runResult, graph.propositions(runResult, contextFrame)); + return { contextFrame, turn }; + } + + const valid = await correctionPlan(); + const validPlan = planTurnCommands(valid.turn, { frame: valid.contextFrame }); + expect(validPlan.commands.map((command) => command.kind)).toEqual(["revise"]); + expect(validPlan.commands[0]).toMatchObject({ + expectedIntentRevision: 3, + slots: [{ slot: "targets", refs: [a] }], + }); + + const unresolved = await correctionPlan(); + const correction = unresolved.turn.propositions.find((entry) => entry.operation === "correction")!; + unresolved.turn.propositions.push({ + act: "request", operation: "exclude", polarity: "exclude", refs: [a], decisions: ["unaccepted"], + }); + const unresolvedPlan = planTurnCommands(unresolved.turn, { frame: unresolved.contextFrame }); + expect(unresolvedPlan.commands).toEqual([]); + expect(unresolvedPlan.rejected[0]?.reason).toContain("exclusion affecting these targets"); + expect(correction.targetIntentId).toBe("intent:binding"); + expect(correction.targetIntentRevision).toBe(3); + + const contradictory = await correctionPlan(); + const target = contradictory.turn.propositions.find((entry) => entry.operation === "correction")!; + target.refs = [c]; + const contradictoryPlan = planTurnCommands(contradictory.turn, { frame: contradictory.contextFrame }); + expect(contradictoryPlan.commands).toEqual([]); + expect(contradictoryPlan.rejected[0]?.reason).toContain("summary refs contradict"); + }); + it("commits a live obligation on a bare affirmation and cancels on rejection", async () => { const graph = createInterpretationGraph(); const contextFrame = tasksFrame({ diff --git a/packages/perception/src/proposals.ts b/packages/perception/src/proposals.ts index c093ad3..8f27edf 100644 --- a/packages/perception/src/proposals.ts +++ b/packages/perception/src/proposals.ts @@ -267,6 +267,79 @@ function constrainSlots( .filter((slot) => slot.refs.length > 0); } +interface NormalizedBindings { + declared: Array<{ slot: string; refs: EntityRef[] }>; + slots: Array<{ slot: string; refs: EntityRef[] }>; + argumentRefs: EntityRef[]; + issue?: string; +} + +/** Apply the proposal binding contract to proposals and revisions alike. */ +function normalizeBindings(input: { + proposition: SemanticProposition; + frame?: ContextFrame; + capability?: string; + fallbackSlot: string; + excludedRefs: ReadonlySet; + unvalidatedExclusionRefs: ReadonlySet; + preserveEmptiedSlots?: boolean; +}): NormalizedBindings { + const { proposition, frame, fallbackSlot, excludedRefs, unvalidatedExclusionRefs } = input; + const effectiveCapability = input.capability ?? proposition.capability; + const offered = frame?.capabilities.find( + (entry) => entry.capabilityId === effectiveCapability, + ); + const declaredSlots = new Map((offered?.slots ?? []).map((slot) => [slot.name, slot])); + if (offered && offered.slots.length > 0) { + const unknown = (proposition.slots ?? []).find((slot) => !declaredSlots.has(slot.slot)); + if (unknown) return { declared: [], slots: [], argumentRefs: [], issue: `slot ${unknown.slot} is not offered by capability ${effectiveCapability}` }; + } + const declared = slotsOf(proposition, fallbackSlot).map((slot) => { + const seen = new Set(); + return { ...slot, refs: slot.refs.filter((ref) => { + const key = entityRefKey(ref); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) }; + }); + const slots = constrainSlots(declared, excludedRefs); + const suppliedByArguments = (name: string): boolean => + isRecord(proposition.arguments) && name in proposition.arguments; + const argumentRefs = argumentRefsOf(proposition); + const issue = (message: string): NormalizedBindings => ({ declared, slots, argumentRefs, issue: message }); + const overfull = slots.find((slot) => { + const arity = declaredSlots.get(slot.slot)?.arity ?? (slot.slot === fallbackSlot ? "many" : undefined); + return arity === "one" && slot.refs.length > 1; + }); + if (overfull) return issue(`slot ${overfull.slot} accepts a single ref`); + if (slots.length === 0 && !declared.every((slot) => suppliedByArguments(slot.slot))) { + return issue("no targets remain after exclusions"); + } + const requiredEmptied = declared.filter((slot) => + slot.refs.length > 0 && !slots.some((entry) => entry.slot === slot.slot) && + !suppliedByArguments(slot.slot) && declaredSlots.get(slot.slot)?.required === true, + ); + if (requiredEmptied.length > 0) { + return issue(`an exclusion emptied a required slot (${requiredEmptied.map((slot) => slot.slot).join(", ")})`); + } + if (input.preserveEmptiedSlots && declared.some((slot) => + slot.refs.length > 0 && !slots.some((entry) => entry.slot === slot.slot), + )) return issue("an exclusion emptied a revision slot; the prior binding would otherwise remain"); + if (slots.some((slot) => slot.refs.some((ref) => unvalidatedExclusionRefs.has(entityRefKey(ref))))) { + return issue("an exclusion affecting these targets could not be validated"); + } + const hiddenExcluded = argumentRefs.filter((ref) => + excludedRefs.has(entityRefKey(ref)) || unvalidatedExclusionRefs.has(entityRefKey(ref)), + ); + if (hiddenExcluded.length > 0) return issue(`an excluded target appears in arguments (${hiddenExcluded.map(entityRefKey).join(", ")})`); + const executableKeys = new Set([...declared.flatMap((slot) => slot.refs), ...argumentRefs].map(entityRefKey)); + if (proposition.refs.length > 0 && !proposition.refs.every((ref) => executableKeys.has(entityRefKey(ref)))) { + return issue("proposition summary refs contradict its executable bindings"); + } + return { declared, slots, argumentRefs }; +} + function expectedVersionsFromFrame( frame: ContextFrame, refs: EntityRef[], @@ -681,31 +754,54 @@ function evaluateTurn( return fail("correction does not name the revision it replaces"); } if (!pending) return fail("no pending intent to correct"); + // Pair identity and revision against the same materialized object. A + // live gate cannot be combined with an unrelated pending question. + const intentId = pendingIntent?.intentId; + if (!intentId || (proposition.targetIntentId !== undefined && proposition.targetIntentId !== intentId)) { + return fail("correction does not match the pending intent"); + } + if ( + proposition.targetIntentRevision !== pendingIntent?.revision || + (obligation && (obligation.intentId !== intentId || obligation.revision !== proposition.targetIntentRevision)) + ) { + return fail("correction does not match the pending intent revision"); + } const capability = pendingIntent?.capability; + if (proposition.capability !== undefined && proposition.capability !== capability) { + return fail("correction capability does not match the pending intent"); + } 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}`); + if (proposition.constraints && Object.keys(proposition.constraints).length > 0) { + return fail("correction constraints are not supported by the revision command"); + } + const normalized = normalizeBindings({ + proposition, + frame, + ...(capability ? { capability } : {}), + fallbackSlot: offered ? resolveTargetSlot(offered) : "targets", + excludedRefs: analysis.excludedRefs, + unvalidatedExclusionRefs: analysis.unvalidatedExclusionRefs, + preserveEmptiedSlots: true, + }); + if (normalized.issue) { + if ( + normalized.issue === "no targets remain after exclusions" || + normalized.issue.startsWith("an exclusion emptied a revision slot") + ) { + return clarify(normalized.issue, pending.targets); } + return fail(normalized.issue); + } + if (normalized.argumentRefs.length > 0) { + return fail("correction arguments with entity refs are not supported by the revision evidence contract"); } - const slots = declaredSlots - .map((slot) => ({ - slot: slot.slot, - refs: slot.refs.filter((ref) => !analysis.excludedRefs.has(entityRefKey(ref))), - })) - .filter((slot) => slot.refs.length > 0); + const slots = normalized.slots; 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 refs = [...slots.flatMap((slot) => slot.refs), ...normalized.argumentRefs]; const evidence = expectedVersionsFromFrame(frame, refs); for (const ref of refs) { if ( @@ -723,7 +819,9 @@ function evaluateTurn( kind: "revise", commandId, ...(obligation !== undefined ? { transactionId: obligation.transactionId } : {}), + expectedIntentId: intentId, expectedIntentRevision: proposition.targetIntentRevision, + ...(proposition.arguments ? { arguments: structuredClone(proposition.arguments) } : {}), slots: slots.map((slot) => ({ slot: slot.slot, refs: slot.refs, @@ -764,28 +862,6 @@ function evaluateTurn( const fallbackSlot = offeredCapability ? resolveTargetSlot(offeredCapability) : "targets"; - const declaredSlots = new Map( - (offeredCapability?.slots ?? []).map((slot) => [slot.name, slot]), - ); - if (offeredCapability && offeredCapability.slots.length > 0) { - const unknownSlot = (proposition.slots ?? []).find( - (slot) => !declaredSlots.has(slot.slot), - ); - if (unknownSlot) { - return fail( - `slot ${unknownSlot.slot} is not offered by capability ${proposition.capability}`, - ); - } - } - // Semantic arity is optional metadata: when it is absent the compiler - // must not invent a constraint the kernel does not have. The resolved - // target slot is set-valued by construction; every other undeclared slot - // is left to the kernel's mechanical contract. - const effectiveArity = (slotName: string): "one" | "many" | undefined => { - const declaredArity = declaredSlots.get(slotName)?.arity; - if (declaredArity !== undefined) return declaredArity; - return slotName === fallbackSlot ? "many" : undefined; - }; const citation = options.citationFor?.(proposition); if (citation) return fail(citation); if ( @@ -805,88 +881,17 @@ function evaluateTurn( const relation = relationIssue(turn, proposition, index, options.isAccepted); if (relation) return fail(relation); - // Canonicalize duplicate refs so a slot's effective value matches what - // the kernel will see. - const declared = slotsOf(proposition, fallbackSlot).map((slot) => { - const seen = new Set(); - return { - ...slot, - refs: slot.refs.filter((ref) => { - const key = entityRefKey(ref); - if (seen.has(key)) return false; - seen.add(key); - return true; - }), - }; + // Proposals and revisions use the same binding normalization contract. + const normalized = normalizeBindings({ + proposition, + ...(options.frame ? { frame: options.frame } : {}), + fallbackSlot, + excludedRefs: analysis.excludedRefs, + unvalidatedExclusionRefs: analysis.unvalidatedExclusionRefs, }); - const constrained = constrainSlots(declared, analysis.excludedRefs); - // Arity is validated on the executable value (after exclusions), exactly - // as the kernel will see it: the resolved target slot is set-valued by - // construction, every other slot is scalar unless the overlay says - // otherwise. - const overfull = constrained.find( - (slot) => effectiveArity(slot.slot) === "one" && slot.refs.length > 1, - ); - if (overfull) { - return fail(`slot ${overfull.slot} accepts a single ref`); - } - const suppliedByArgumentsFor = (slotName: string): boolean => - isRecord(proposition.arguments) && slotName in proposition.arguments; - if ( - constrained.length === 0 && - !declared.every((slot) => suppliedByArgumentsFor(slot.slot)) - ) { - return fail("no targets remain after exclusions"); - } - // A declared-required slot that an exclusion empties (and that no - // argument supplies) would be rejected by the kernel as an invalid - // command; reject it here instead. Unknown requiredness is left to the - // kernel so an optional slot can legitimately be dropped. - const suppliedByArguments = (slotName: string): boolean => - isRecord(proposition.arguments) && slotName in proposition.arguments; - const requiredEmptied = declared.filter( - (slot) => - slot.refs.length > 0 && - !constrained.some((entry) => entry.slot === slot.slot) && - !suppliedByArguments(slot.slot) && - offeredCapability?.slots.find((entry) => entry.name === slot.slot)?.required === true, - ); - if (requiredEmptied.length > 0) { - return fail( - `an exclusion emptied a required slot (${requiredEmptied - .map((slot) => slot.slot) - .join(", ")})`, - ); - } - if ( - constrained.some((slot) => - slot.refs.some((ref) => analysis.unvalidatedExclusionRefs.has(entityRefKey(ref))), - ) - ) { - return fail("an exclusion affecting these targets could not be validated"); - } - const argumentRefs = argumentRefsOf(proposition); - const hiddenExcluded = argumentRefs.filter( - (ref) => - analysis.excludedRefs.has(entityRefKey(ref)) || - analysis.unvalidatedExclusionRefs.has(entityRefKey(ref)), - ); - if (hiddenExcluded.length > 0) { - return fail( - `an excluded target appears in arguments (${hiddenExcluded - .map(entityRefKey) - .join(", ")})`, - ); - } - const declaredRefs = new Set( - [...declared.flatMap((slot) => slot.refs), ...argumentRefs].map(entityRefKey), - ); - if ( - proposition.refs.length > 0 && - !proposition.refs.every((ref) => declaredRefs.has(entityRefKey(ref))) - ) { - return fail("proposition summary refs contradict its executable bindings"); - } + if (normalized.issue) return fail(normalized.issue); + const constrained = normalized.slots; + const argumentRefs = normalized.argumentRefs; const commandId = options.commandIdFor(proposition, index); if (!options.frame) { if (options.requireFrameEvidence) return fail("no frame evidence supplied");