From 0604cd0b64d7bb27ff87d6bf0a11f14d23324e0e Mon Sep 17 00:00:00 2001 From: POLAARK <88390706+POLAARK@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:34:10 +0200 Subject: [PATCH 1/2] fix(agentcore): read checkpoint answers given in the gate UI The gate UI answers a checkpoint (confirm_summary / request_plan_approval) with the generic QuestionEditor, which writes option indices: { answers: [{ selectedOptions: [i], freeText? }] }. chosenLabel only read perQuestion, freeText, decision or a bare string, so it returned '' for every UI answer. The agent was told "nothing is authorized yet" after the human clicked "Looks correct", re-raised the checkpoint, and looped; on the resume leg no receipt was minted and a UI "Request changes" never recorded the withdrawal. chosenLabel now resolves a single well-formed index against the gate's own questions[0].options at every call site (inline answer, resume-leg rehydration, resume message). Only that exact label can authorize: a multi-select, out-of-range or non-integer index, or a typed note, names no label and re-asks. answerFreeText carries the UI note to the agent as feedback. --- lambda/agentcore/commands/run-stage.js | 9 +- lambda/agentcore/mcp/process-bridge.js | 51 ++++++++-- .../test/checkpoint-confirmation.test.js | 98 ++++++++++++++----- .../agentcore/test/checkpoint-ladder.test.js | 21 ++++ 4 files changed, 143 insertions(+), 36 deletions(-) diff --git a/lambda/agentcore/commands/run-stage.js b/lambda/agentcore/commands/run-stage.js index cf9cd3194..f43014a15 100644 --- a/lambda/agentcore/commands/run-stage.js +++ b/lambda/agentcore/commands/run-stage.js @@ -97,7 +97,7 @@ import { } from '../git-engine.js'; import { resolveStageModel } from '../model-resolver.js'; import { createGraphWriter, closeGraphSource } from '../mcp/graph-writer.js'; -import { CHECKPOINTS, chosenLabel } from '../mcp/process-bridge.js'; +import { CHECKPOINTS, answerFreeText, chosenLabel } from '../mcp/process-bridge.js'; import { LOOP_BACK_OPTION, parseChoice } from '../../shared/gate-answer.js'; import { ingestStageCodeTraceability as defaultIngestStageCodeTraceability } from '../code-traceability.js'; import { createSensorRunner, isReleaseDependencyError } from '../sensor-runner.js'; @@ -1304,7 +1304,8 @@ const runCheckpointLadder = async ({ // Render an answered gate into the message that re-enters the parked conversation. // The agent asked structured questions; we feed back the human's answer so it // continues from where it parked. Tolerant of the answer shapes the resume lambda -// / phaseb-answer write (`perQuestion[]`, `freeText`, or a raw string). +// / phaseb-answer / gate UI write (`perQuestion[]`, `answers[]`, `freeText`, or a +// raw string). const formatResumeAnswer = (gate, stageId) => { const a = gate?.answer ?? null; // A checkpoint gate (summary confirmation / plan approval) is a `question` row @@ -1315,9 +1316,9 @@ const formatResumeAnswer = (gate, stageId) => { if (checkpoint) { // The same label parser and labels the bridge uses to decide whether a // receipt is written, so this message never contradicts the receipt. - const label = chosenLabel(a); + const label = chosenLabel(a, gate.questions); const approved = label === CHECKPOINTS[checkpoint]?.approve; - const free = typeof a === 'string' ? '' : (a?.freeText ?? a?.feedback ?? ''); + const free = answerFreeText(a); if (approved) { return ( `The human answered the ${checkpoint} checkpoint: "${label}".` + diff --git a/lambda/agentcore/mcp/process-bridge.js b/lambda/agentcore/mcp/process-bridge.js index 413ddc3a3..90c52088b 100644 --- a/lambda/agentcore/mcp/process-bridge.js +++ b/lambda/agentcore/mcp/process-bridge.js @@ -82,12 +82,45 @@ const authorizationRefusal = (gate) => { const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +// The gate UI's answer shape: `answers[0].selectedOptions` holds indices into the +// gate's own `questions[0].options`, so the label is read from the gate row. Only +// one well-formed index names a label; anything else names none. +const selectedOptionLabel = (answer, questions) => { + const selected = answer?.answers?.[0]?.selectedOptions; + if (!Array.isArray(selected) || selected.length !== 1) return null; + const [index] = selected; + if (!Number.isInteger(index) || index < 0) return null; + let parsed = questions; + if (typeof parsed === 'string') { + try { + parsed = JSON.parse(parsed); + } catch { + return null; + } + } + return Array.isArray(parsed) ? (parsed[0]?.options?.[index]?.label ?? null) : null; +}; + +// The human's free-text note, across the same answer shapes as chosenLabel. +export const answerFreeText = (answer) => + typeof answer === 'string' + ? '' + : (answer?.freeText ?? answer?.answers?.[0]?.freeText ?? answer?.feedback ?? ''); + // The human's chosen label, across every answer shape the answer endpoints write -// (`perQuestion[]`, `freeText`, or a bare string — see run-stage formatResumeAnswer). -export const chosenLabel = (answer) => { +// (`perQuestion[]`, the gate UI's `answers[].selectedOptions` resolved against the +// gate's `questions`, `freeText`, `decision`, or a bare string — see run-stage +// formatResumeAnswer). A note typed in the gate UI is feedback, never a label. +export const chosenLabel = (answer, questions) => { if (typeof answer === 'string') return answer.trim(); const perQuestion = Array.isArray(answer?.perQuestion) ? answer.perQuestion[0]?.answer : null; - return String(perQuestion ?? answer?.freeText ?? answer?.decision ?? '').trim(); + return String( + perQuestion ?? + selectedOptionLabel(answer, questions) ?? + answer?.freeText ?? + answer?.decision ?? + '', + ).trim(); }; export const createProcessBridge = ({ @@ -342,11 +375,9 @@ export const createProcessBridge = ({ // the human's words, and any other shape is a re-ask with no receipt — so a // free-text answer can never be mistaken for a confirmation. const resolveCheckpointAnswer = async ({ spec, checkpoint, task }) => { - const label = chosenLabel(task.answer); + const label = chosenLabel(task.answer, task.questions); const feedback = - typeof task.answer === 'string' - ? task.answer - : (task.answer?.freeText ?? task.answer?.feedback ?? label); + typeof task.answer === 'string' ? task.answer : answerFreeText(task.answer) || label; if (label === spec.approve) { // The same provenance rule the resume leg applies: an answer that records // no human, or no digest of what was shown, authorizes nothing. @@ -521,7 +552,7 @@ export const createProcessBridge = ({ if ( latest && latest.humanTaskId !== existing.humanTaskId && - chosenLabel(latest.answer) === spec.reject + chosenLabel(latest.answer, latest.questions) === spec.reject ) { continue; } @@ -529,7 +560,7 @@ export const createProcessBridge = ({ continue; } if (!latest || latest.status === 'pending') continue; - if (chosenLabel(latest.answer) === spec.reject) { + if (chosenLabel(latest.answer, latest.questions) === spec.reject) { // The rejection was answered while no container existed, so nothing has // recorded the withdrawal yet. Writing it here is what stops an EARLIER // round's approval from still satisfying the durable evaluator — and it is @@ -559,7 +590,7 @@ export const createProcessBridge = ({ } continue; } - if (chosenLabel(latest.answer) !== spec.approve) continue; + if (chosenLabel(latest.answer, latest.questions) !== spec.approve) continue; const refusal = authorizationRefusal(latest); if (refusal) { await refuseAuthorization({ diff --git a/lambda/agentcore/test/checkpoint-confirmation.test.js b/lambda/agentcore/test/checkpoint-confirmation.test.js index 58efde862..84b272d56 100644 --- a/lambda/agentcore/test/checkpoint-confirmation.test.js +++ b/lambda/agentcore/test/checkpoint-confirmation.test.js @@ -233,6 +233,42 @@ describe('confirm_summary — the authorization it does and does not create', () expect(store.receipts.size).toBe(1); }); + // The gate UI (QuestionEditor) answers with option indices into the gate's own + // questions, not with the label: `{ answers: [{ selectedOptions: [i] }] }`. + it('reads the gate UI answer shape: option indices resolved against the gate options', async () => { + const store = fakeStore(); + const rejecting = inlineBridge(store, { + answer: { answers: [{ selectedOptions: [1], freeText: 'model refunds too' }] }, + }); + const first = await rejecting.confirmSummary({ summary: 'v1' }); + expect(first.decision).toBe('changes-requested'); + expect(first.choice).toBe('Request changes'); + expect(first.feedback).toBe('model refunds too'); + expect(store.receipts.size).toBe(0); + + const accepting = inlineBridge(store, { answer: { answers: [{ selectedOptions: [0] }] } }); + const second = await accepting.confirmSummary({ summary: 'v2 with refunds' }); + expect(second.decision).toBe('approved'); + expect(second.choice).toBe('Looks correct'); + expect(store.receipts.size).toBe(1); + }); + + it('creates NO authority from a malformed gate UI answer', async () => { + for (const answer of [ + { answers: [{ selectedOptions: [0, 1] }] }, + { answers: [{ selectedOptions: [2] }] }, + { answers: [{ selectedOptions: [-1] }] }, + { answers: [{ selectedOptions: [null] }] }, + { answers: [{ selectedOptions: ['0'] }] }, + { answers: [{ selectedOptions: [], freeText: 'Looks correct' }] }, + ]) { + const store = fakeStore(); + const result = await inlineBridge(store, { answer }).confirmSummary({ summary: 'a summary' }); + expect(result.decision).toBe('re-ask'); + expect(store.receipts.size).toBe(0); + } + }); + it('refuses to raise the checkpoint forever', async () => { const store = fakeStore(); // Pre-fill every round slot as answered-but-rejected. @@ -370,6 +406,18 @@ describe('park / resume across a confirmation', () => { }); }); + it('mints the receipt on the resume leg from a gate UI answer', async () => { + const store = fakeStore(); + await parkingBridge(store).confirmSummary({ summary: 'please confirm' }); + answerLatestGate(store, { answers: [{ selectedOptions: [0] }] }); + + const resumed = createProcessBridge({ store, scope: SCOPE }); + const authorizationId = await resumed.rehydrateAuthorizations(); + + expect(authorizationId).toMatch(/^RECEIPT#summary-confirmation#si-1#0#-$/); + expect(eventTypes(store)).toContain('v2.summary.confirmed'); + }); + it('does not invent an authorization from a parked or rejected gate', async () => { const pending = fakeStore(); await parkingBridge(pending).confirmSummary({ summary: 's' }); @@ -850,28 +898,34 @@ describe('checkpoint authority is the latest decision, not the first', () => { ]); }); - it('withdraws it on the resume leg too, when the rejection was answered while parked', async () => { - const store = fakeStore(); - const confirming = inlineBridge(store, { - answer: { perQuestion: [{ answer: 'Looks correct' }] }, - }); - await confirming.confirmSummary({ summary: 'v1' }); - await confirming.stampArtifact({ artifactId: 'd1', artifactType: 'design' }); - expect(evaluate(store).ok).toBe(true); - - // Round 1 parks and is answered by the human while no container exists. - await parkingBridge(store).confirmSummary({ summary: 'a different summary' }); - answerLatestGate(store, { perQuestion: [{ answer: 'Request changes' }] }); - - const resumed = createProcessBridge({ store, scope: SCOPE }); - expect(await resumed.rehydrateAuthorizations()).toBeNull(); - - const result = evaluate(store); - expect(result.ok).toBe(false); - expect(result.findings.map((finding) => finding.code)).toEqual([ - 'summary_confirmation_missing', - ]); - }); + it.each([ + ['a perQuestion answer', { perQuestion: [{ answer: 'Request changes' }] }], + ['a gate UI answer', { answers: [{ selectedOptions: [1] }] }], + ])( + 'withdraws it on the resume leg too, when the rejection was answered while parked (%s)', + async (_shape, rejection) => { + const store = fakeStore(); + const confirming = inlineBridge(store, { + answer: { perQuestion: [{ answer: 'Looks correct' }] }, + }); + await confirming.confirmSummary({ summary: 'v1' }); + await confirming.stampArtifact({ artifactId: 'd1', artifactType: 'design' }); + expect(evaluate(store).ok).toBe(true); + + // Round 1 parks and is answered by the human while no container exists. + await parkingBridge(store).confirmSummary({ summary: 'a different summary' }); + answerLatestGate(store, rejection); + + const resumed = createProcessBridge({ store, scope: SCOPE }); + expect(await resumed.rehydrateAuthorizations()).toBeNull(); + + const result = evaluate(store); + expect(result.ok).toBe(false); + expect(result.findings.map((finding) => finding.code)).toEqual([ + 'summary_confirmation_missing', + ]); + }, + ); it('records the parked withdrawal once, however many containers resume after it', async () => { const store = fakeStore(); diff --git a/lambda/agentcore/test/checkpoint-ladder.test.js b/lambda/agentcore/test/checkpoint-ladder.test.js index b2f16573d..38373e1d4 100644 --- a/lambda/agentcore/test/checkpoint-ladder.test.js +++ b/lambda/agentcore/test/checkpoint-ladder.test.js @@ -542,6 +542,27 @@ describe('a parked checkpoint is delivered to the agent as a decision, not a Q&A } }); + it('reads the gate UI answer shape by resolving the option index on the gate', () => { + const questions = JSON.stringify([ + { type: 'single', options: [{ label: 'Looks correct' }, { label: 'Request changes' }] }, + ]); + const approved = formatResumeAnswer({ + detail: { checkpoint: 'summary-confirmation' }, + questions, + answer: { answers: [{ selectedOptions: [0] }] }, + }); + expect(approved).toContain('"Looks correct"'); + expect(approved).toContain('authorization is recorded'); + + const rejected = formatResumeAnswer({ + detail: { checkpoint: 'summary-confirmation' }, + questions, + answer: { answers: [{ selectedOptions: [1], freeText: 'split the aggregate' }] }, + }); + expect(rejected).toContain('"Request changes" — nothing is authorized yet'); + expect(rejected).toContain('What they want changed: split the aggregate'); + }); + it('leaves an ordinary question and a validation gate exactly as before', () => { // The checkpoint branch must not capture the two shapes that already worked. expect( From fac364585d676ba13fb493b401c5059c4682b388 Mon Sep 17 00:00:00 2001 From: POLAARK <88390706+POLAARK@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:20:59 +0200 Subject: [PATCH 2/2] refactor(agentcore): simplify checkpoint label resolution Review follow-up: chosenLabel takes the gate row so the answer and the options it indexes always come from the same row, and rehydration reads the latest gate's label once. Drop dead guards (the array branch for questions, which is always a JSON string; the negative-index check; the string branches in answerFreeText and resolveCheckpointAnswer), fix the misleading comments, and parametrise the existing tests over both answer shapes instead of copying them. --- lambda/agentcore/commands/run-stage.js | 8 +- lambda/agentcore/mcp/process-bridge.js | 53 +++--- .../test/checkpoint-confirmation.test.js | 161 ++++++++---------- 3 files changed, 100 insertions(+), 122 deletions(-) diff --git a/lambda/agentcore/commands/run-stage.js b/lambda/agentcore/commands/run-stage.js index f43014a15..cd7dfff62 100644 --- a/lambda/agentcore/commands/run-stage.js +++ b/lambda/agentcore/commands/run-stage.js @@ -1304,8 +1304,7 @@ const runCheckpointLadder = async ({ // Render an answered gate into the message that re-enters the parked conversation. // The agent asked structured questions; we feed back the human's answer so it // continues from where it parked. Tolerant of the answer shapes the resume lambda -// / phaseb-answer / gate UI write (`perQuestion[]`, `answers[]`, `freeText`, or a -// raw string). +// / phaseb-answer write (`perQuestion[]`, `freeText`, or a raw string). const formatResumeAnswer = (gate, stageId) => { const a = gate?.answer ?? null; // A checkpoint gate (summary confirmation / plan approval) is a `question` row @@ -1315,8 +1314,9 @@ const formatResumeAnswer = (gate, stageId) => { const checkpoint = gate?.detail?.checkpoint ?? null; if (checkpoint) { // The same label parser and labels the bridge uses to decide whether a - // receipt is written, so this message never contradicts the receipt. - const label = chosenLabel(a, gate.questions); + // receipt is written, so this message never contradicts the receipt. It also + // reads the gate UI's `answers[]` shape (option indices into `gate.questions`). + const label = chosenLabel(gate); const approved = label === CHECKPOINTS[checkpoint]?.approve; const free = answerFreeText(a); if (approved) { diff --git a/lambda/agentcore/mcp/process-bridge.js b/lambda/agentcore/mcp/process-bridge.js index 90c52088b..55d7c3d20 100644 --- a/lambda/agentcore/mcp/process-bridge.js +++ b/lambda/agentcore/mcp/process-bridge.js @@ -89,34 +89,31 @@ const selectedOptionLabel = (answer, questions) => { const selected = answer?.answers?.[0]?.selectedOptions; if (!Array.isArray(selected) || selected.length !== 1) return null; const [index] = selected; - if (!Number.isInteger(index) || index < 0) return null; - let parsed = questions; - if (typeof parsed === 'string') { - try { - parsed = JSON.parse(parsed); - } catch { - return null; - } + if (!Number.isInteger(index)) return null; + try { + return JSON.parse(questions)?.[0]?.options?.[index]?.label ?? null; + } catch { + return null; } - return Array.isArray(parsed) ? (parsed[0]?.options?.[index]?.label ?? null) : null; }; -// The human's free-text note, across the same answer shapes as chosenLabel. +// The human's free-text note: top-level `freeText`, the gate UI's +// `answers[0].freeText`, or `feedback`; '' for a bare string. export const answerFreeText = (answer) => - typeof answer === 'string' - ? '' - : (answer?.freeText ?? answer?.answers?.[0]?.freeText ?? answer?.feedback ?? ''); - -// The human's chosen label, across every answer shape the answer endpoints write -// (`perQuestion[]`, the gate UI's `answers[].selectedOptions` resolved against the -// gate's `questions`, `freeText`, `decision`, or a bare string — see run-stage -// formatResumeAnswer). A note typed in the gate UI is feedback, never a label. -export const chosenLabel = (answer, questions) => { + answer?.freeText ?? answer?.answers?.[0]?.freeText ?? answer?.feedback ?? ''; + +// The human's chosen label on an answered gate row, across every answer shape the +// answer endpoints write (`perQuestion[]`, the gate UI's `answers[0].selectedOptions` +// resolved against the row's `questions`, `freeText`, `decision`, or a bare string — +// see run-stage formatResumeAnswer). The gate UI's `answers[0].freeText` is +// feedback, never a label. +export const chosenLabel = (gate) => { + const answer = gate?.answer; if (typeof answer === 'string') return answer.trim(); const perQuestion = Array.isArray(answer?.perQuestion) ? answer.perQuestion[0]?.answer : null; return String( perQuestion ?? - selectedOptionLabel(answer, questions) ?? + selectedOptionLabel(answer, gate?.questions) ?? answer?.freeText ?? answer?.decision ?? '', @@ -375,9 +372,8 @@ export const createProcessBridge = ({ // the human's words, and any other shape is a re-ask with no receipt — so a // free-text answer can never be mistaken for a confirmation. const resolveCheckpointAnswer = async ({ spec, checkpoint, task }) => { - const label = chosenLabel(task.answer, task.questions); - const feedback = - typeof task.answer === 'string' ? task.answer : answerFreeText(task.answer) || label; + const label = chosenLabel(task); + const feedback = answerFreeText(task.answer) || label; if (label === spec.approve) { // The same provenance rule the resume leg applies: an answer that records // no human, or no digest of what was shown, authorizes nothing. @@ -511,6 +507,7 @@ export const createProcessBridge = ({ if (!policy) return null; for (const [checkpoint, spec] of Object.entries(CHECKPOINTS)) { const { latest } = await latestCheckpointGate(checkpoint); + const latestLabel = latest ? chosenLabel(latest) : ''; const round = latest ? checkpointRoundOf(checkpoint, latest) : 0; const existing = await store .getReceipt(executionId, { @@ -549,18 +546,14 @@ export const createProcessBridge = ({ continue; } // A "Request changes" answered after this confirmation withdrew it. - if ( - latest && - latest.humanTaskId !== existing.humanTaskId && - chosenLabel(latest.answer, latest.questions) === spec.reject - ) { + if (latest && latest.humanTaskId !== existing.humanTaskId && latestLabel === spec.reject) { continue; } activeAuthorizationId = existing.sk; continue; } if (!latest || latest.status === 'pending') continue; - if (chosenLabel(latest.answer, latest.questions) === spec.reject) { + if (latestLabel === spec.reject) { // The rejection was answered while no container existed, so nothing has // recorded the withdrawal yet. Writing it here is what stops an EARLIER // round's approval from still satisfying the durable evaluator — and it is @@ -590,7 +583,7 @@ export const createProcessBridge = ({ } continue; } - if (chosenLabel(latest.answer, latest.questions) !== spec.approve) continue; + if (latestLabel !== spec.approve) continue; const refusal = authorizationRefusal(latest); if (refusal) { await refuseAuthorization({ diff --git a/lambda/agentcore/test/checkpoint-confirmation.test.js b/lambda/agentcore/test/checkpoint-confirmation.test.js index 84b272d56..60ecff264 100644 --- a/lambda/agentcore/test/checkpoint-confirmation.test.js +++ b/lambda/agentcore/test/checkpoint-confirmation.test.js @@ -210,63 +210,54 @@ describe('confirm_summary — the authorization it does and does not create', () expect(eventTypes(store)).toEqual(['v2.summary.requested']); }); - it('loops on Request changes without creating authority, then succeeds on the retry', async () => { - const store = fakeStore(); - const rejecting = inlineBridge(store, { - answer: { perQuestion: [{ answer: 'Request changes' }], freeText: 'model refunds too' }, - }); - - const first = await rejecting.confirmSummary({ summary: 'v1' }); - expect(first.decision).toBe('changes-requested'); - expect(first.feedback).toBe('model refunds too'); - expect(store.receipts.size).toBe(0); - expect(eventTypes(store)).toEqual(['v2.summary.requested', 'v2.summary.changes_requested']); - - // The revision is a SECOND gate, not a re-answer of the first: the first is - // already answered, so the derived id advances a round. - const accepting = inlineBridge(store, { - answer: { perQuestion: [{ answer: 'Looks correct' }] }, - }); - const second = await accepting.confirmSummary({ summary: 'v2 with refunds' }); - expect(second.decision).toBe('approved'); - expect(store.humanTasks.size).toBe(2); - expect(store.receipts.size).toBe(1); - }); - // The gate UI (QuestionEditor) answers with option indices into the gate's own // questions, not with the label: `{ answers: [{ selectedOptions: [i] }] }`. - it('reads the gate UI answer shape: option indices resolved against the gate options', async () => { - const store = fakeStore(); - const rejecting = inlineBridge(store, { - answer: { answers: [{ selectedOptions: [1], freeText: 'model refunds too' }] }, - }); - const first = await rejecting.confirmSummary({ summary: 'v1' }); - expect(first.decision).toBe('changes-requested'); - expect(first.choice).toBe('Request changes'); - expect(first.feedback).toBe('model refunds too'); - expect(store.receipts.size).toBe(0); - - const accepting = inlineBridge(store, { answer: { answers: [{ selectedOptions: [0] }] } }); - const second = await accepting.confirmSummary({ summary: 'v2 with refunds' }); - expect(second.decision).toBe('approved'); - expect(second.choice).toBe('Looks correct'); - expect(store.receipts.size).toBe(1); - }); - - it('creates NO authority from a malformed gate UI answer', async () => { - for (const answer of [ - { answers: [{ selectedOptions: [0, 1] }] }, - { answers: [{ selectedOptions: [2] }] }, - { answers: [{ selectedOptions: [-1] }] }, - { answers: [{ selectedOptions: [null] }] }, - { answers: [{ selectedOptions: ['0'] }] }, - { answers: [{ selectedOptions: [], freeText: 'Looks correct' }] }, - ]) { + it.each([ + [ + 'a perQuestion answer', + { perQuestion: [{ answer: 'Request changes' }], freeText: 'model refunds too' }, + { perQuestion: [{ answer: 'Looks correct' }] }, + ], + [ + 'a gate UI answer', + { answers: [{ selectedOptions: [1], freeText: 'model refunds too' }] }, + { answers: [{ selectedOptions: [0] }] }, + ], + ])( + 'loops on Request changes without creating authority, then succeeds on the retry (%s)', + async (_shape, rejection, approval) => { const store = fakeStore(); - const result = await inlineBridge(store, { answer }).confirmSummary({ summary: 'a summary' }); - expect(result.decision).toBe('re-ask'); + const rejecting = inlineBridge(store, { answer: rejection }); + + const first = await rejecting.confirmSummary({ summary: 'v1' }); + expect(first.decision).toBe('changes-requested'); + expect(first.choice).toBe('Request changes'); + expect(first.feedback).toBe('model refunds too'); expect(store.receipts.size).toBe(0); - } + expect(eventTypes(store)).toEqual(['v2.summary.requested', 'v2.summary.changes_requested']); + + // The revision is a SECOND gate, not a re-answer of the first: the first is + // already answered, so the derived id advances a round. + const accepting = inlineBridge(store, { answer: approval }); + const second = await accepting.confirmSummary({ summary: 'v2 with refunds' }); + expect(second.decision).toBe('approved'); + expect(second.choice).toBe('Looks correct'); + expect(store.humanTasks.size).toBe(2); + expect(store.receipts.size).toBe(1); + }, + ); + + it.each([ + ['a multi-select names no label', { selectedOptions: [0, 1] }], + ['an out-of-range index names no label', { selectedOptions: [2] }], + ['a non-integer index names no label', { selectedOptions: ['0'] }], + ['a typed note is not a label', { selectedOptions: [], freeText: 'Looks correct' }], + ])('creates NO authority from a malformed gate UI answer: %s', async (_rule, given) => { + const store = fakeStore(); + const answer = { answers: [given] }; + const result = await inlineBridge(store, { answer }).confirmSummary({ summary: 'a summary' }); + expect(result.decision).toBe('re-ask'); + expect(store.receipts.size).toBe(0); }); it('refuses to raise the checkpoint forever', async () => { @@ -381,42 +372,36 @@ describe('park / resume across a confirmation', () => { expect(store.receipts.size).toBe(0); }); - it('mints the receipt on the resume leg from the answered gate, not from memory', async () => { - const store = fakeStore(); - // The container that raised the checkpoint parked and is gone. - await parkingBridge(store).confirmSummary({ summary: 'please confirm' }); - answerLatestGate(store, { perQuestion: [{ answer: 'Looks correct' }] }); - - // A FRESH bridge — as the resume leg builds — must recover the authorization. - const resumed = createProcessBridge({ store, scope: SCOPE }); - const authorizationId = await resumed.rehydrateAuthorizations(); - - expect(authorizationId).toMatch(/^RECEIPT#summary-confirmation#si-1#0#-$/); - expect(store.receipts.size).toBe(1); - expect(eventTypes(store)).toContain('v2.summary.confirmed'); - // A write made after the resume carries the recovered authorization. - await resumed.stampArtifact({ - artifactId: 'a1', - artifactType: 'business-logic-model', - contentHash: 'deadbeef', - }); - expect(store.events.at(-1).detail).toMatchObject({ - artifactType: 'business-logic-model', - authorizationId, - }); - }); - - it('mints the receipt on the resume leg from a gate UI answer', async () => { - const store = fakeStore(); - await parkingBridge(store).confirmSummary({ summary: 'please confirm' }); - answerLatestGate(store, { answers: [{ selectedOptions: [0] }] }); - - const resumed = createProcessBridge({ store, scope: SCOPE }); - const authorizationId = await resumed.rehydrateAuthorizations(); + it.each([ + ['a perQuestion answer', { perQuestion: [{ answer: 'Looks correct' }] }], + ['a gate UI answer', { answers: [{ selectedOptions: [0] }] }], + ])( + 'mints the receipt on the resume leg from the answered gate, not from memory (%s)', + async (_shape, approval) => { + const store = fakeStore(); + // The container that raised the checkpoint parked and is gone. + await parkingBridge(store).confirmSummary({ summary: 'please confirm' }); + answerLatestGate(store, approval); - expect(authorizationId).toMatch(/^RECEIPT#summary-confirmation#si-1#0#-$/); - expect(eventTypes(store)).toContain('v2.summary.confirmed'); - }); + // A FRESH bridge — as the resume leg builds — must recover the authorization. + const resumed = createProcessBridge({ store, scope: SCOPE }); + const authorizationId = await resumed.rehydrateAuthorizations(); + + expect(authorizationId).toMatch(/^RECEIPT#summary-confirmation#si-1#0#-$/); + expect(store.receipts.size).toBe(1); + expect(eventTypes(store)).toContain('v2.summary.confirmed'); + // A write made after the resume carries the recovered authorization. + await resumed.stampArtifact({ + artifactId: 'a1', + artifactType: 'business-logic-model', + contentHash: 'deadbeef', + }); + expect(store.events.at(-1).detail).toMatchObject({ + artifactType: 'business-logic-model', + authorizationId, + }); + }, + ); it('does not invent an authorization from a parked or rejected gate', async () => { const pending = fakeStore();