fix(onboarding): show native proposal confirmation recipes (#18025)

## Thinking Path

> - Paperclip lets people manage agent work through tasks.
> - First-task onboarding asks a new agent to propose work before it
starts.
> - The user must accept a saved proposal before the agent can create
its child task.
> - A native agent can ask for approval in a question card, which does
not record proposal acceptance.
> - The skill names confirmation cards but does not show their native
tool arguments.
> - This change adds executable native proposal recipes and checks their
real approval path.
> - The task admission rules and Product E2E assertions stay in place.

## Linked Issues or Issue Description

**What happened?**

A fresh onboarding agent asked the user to approve a child task through
an Approve/Revise question. The question looked like a decision, but its
answer cannot authorize child creation. The first-task skill teaches
native question calls for interviews and names proposal confirmation
cards without showing the native mapping.

**Expected behavior**

The agent should create a confirmation card for a single-task proposal,
or a checkbox card bound to the saved plan revision. Only the saved
human acceptance should admit the child task.

**Steps to reproduce**

1. Start first-task onboarding with a native agent.
2. Choose one task and ask the agent to propose a child before it starts
work.
3. Have the agent use a question card with Approve/Revise options.
4. Answer Approve. Child creation remains blocked because no proposal
confirmation was accepted.

Related public context: #17940, #13517.

## What Changed

- Show the native `request_human_input` arguments for confirmation and
plan-bound checkbox proposals.
- Explain that question answers supply scope and cannot grant proposal
authority.
- Resume the agent after either acceptance or rejection, and use new
proposal keys for revisions.
- Parse the shipped recipes and execute them through the real native
tool authority and interaction service.
- Verify that pending proposals, agent-only acceptance, and an answered
Approve/Revise question cannot admit child work.
- Use the existing isolated PostgreSQL test helper when a local test
database is supplied.
- Fix the required clean Grok npm-install check to prefix synthetic
revision identifiers with `g`. The former all-numeric, leading-zero
identifier was invalid SemVer and caused a reproduced `ETARGET` failure.
Customer release versions are unchanged.

## Verification

- Red: both new executable recipe checks fail against the preceding
skill.
- Green: 44 focused tests pass with zero skips against a task-owned
PostgreSQL server. The actual tool authority and approval service are
exercised.
- Human acceptance of either recipe admits the child. Agent-only
acceptance does not. Answering the question leaves the issue set
unchanged.
- Red/green real HTTP checks prove rejection saves the assignee
continuation, starts no extra model run, and still denies child
creation.
- `git diff --check` passes.
- Full repository `pnpm -r typecheck` and `pnpm build` passed on the
final approval-guidance revision.
- The first full local test attempt used an unmigrated external fixture
and was stopped after the cause was confirmed. A new migrated fixture
resolved that setup error; the second full attempt reached its explicit
10-minute limit before finishing. Both attempts are preserved and are
not claimed as passing. The temporary owned PostgreSQL container was
removed. The complete Linux CI workflow passed on the final PR head.
- The exact synthetic-version failure was reproduced with real npm and
an empty local packed registry. The new maintained regression keeps the
invalid-version negative control and installs corrected numeric-hash and
normal-hash controls. All 27 existing Grok helper tests and three
offline registry-boundary checks pass.
- Final head `52d0b92a00dcb0653b6fb48415cfaea979b8f21f`: [CI
40706933035, attempt
1](https://github.com/paperclipai/paperclip/actions/runs/40706933035)
passed; 55 successful checks, two skipped and one neutral. Greptile is
5/5 for this exact head with no unresolved threads. The original failed
build remains preserved.
- Live fresh-workspace acceptance has not passed yet. It follows the
reviewed testing rollout; this source test does not prove model
compliance.

## Risks

The change guides model tool selection. An agent can still ignore the
skill. Product admission remains the authority, and the live journey
must still verify human approval, native child execution, downloaded
artifact bytes, context reuse, and cleanup. There is no schema or API
change. The CI helper changes only temporary verifier versions and
preserves the existing registry integrity, install, lifecycle, and
isolation checks.

## Model Used

OpenAI Codex `gpt-6.1-sol`, with reasoning, source inspection, tool use,
and code execution. The host does not expose its configured
context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Dotta authored and GitHub committed 2026-10-28 09:47:55 -05:00
1 parent 8049fbf51d
commit b25f3e2af8
5 files changed
+167 -8

No files matched your search

@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
import { grokPublicInstallVersion } from '../grok-public-install-sandbox.mjs';
import { startPackedRegistry, writePackedRegistryCatalog } from '../public-install-registry.mjs';
const script = fileURLToPath(new URL('../public-install-registry.mjs', import.meta.url));
@@ -15,8 +16,8 @@ const scratch = async body => {
};
const environment = root => ({ ...process.env, HOME: root, npm_config_cache: join(root, 'cache'),
npm_config_userconfig: join(root, 'no-npmrc'), npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false', npm_config_fetch_retries: '0', npm_config_fetch_timeout: '10000' });
const run = (command, args, cwd) => new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, env: environment(cwd), stdio: ['ignore', 'pipe', 'pipe'] });
const run = (command, args, cwd, env = environment(cwd)) => new Promise((resolve, reject) => {
const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '';
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => { output += chunk; });
child.once('error', reject);
@@ -59,6 +60,37 @@ const installedFiles = directory => {
return entries.sort(([a], [b]) => a.localeCompare(b));
};
test('synthetic Grok versions install from packed bytes even when the hash starts with leading-zero digits', { timeout: 30_000 }, async () => scratch(async root => {
const sourceRevision = '096091501644cf20f2e35d1ab02b28c35579b973';
const cases = [
{ version: `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`, rejected: true },
{ version: grokPublicInstallVersion(sourceRevision) },
{ version: grokPublicInstallVersion('8fd48a8d52038fd0d8b286f3742ea13ce40a7ab9') },
];
for (const [index, { version, rejected }] of cases.entries()) {
const cwd = join(root, String(index)), source = join(cwd, 'source'), assets = join(cwd, 'assets'), consumer = join(cwd, 'consumer');
for (const directory of [join(source, 'package'), assets, consumer]) mkdirSync(directory, { recursive: true });
writeFileSync(join(source, 'package/package.json'), JSON.stringify({ name: '@ci/grok-version', version }));
execFileSync('tar', ['-czf', join(assets, 'package.tgz'), '-C', source, 'package']);
await writePackedRegistryCatalog(assets);
writeFileSync(join(consumer, 'package.json'), '{"private":true}');
const userconfig = join(cwd, 'user.npmrc'), globalconfig = join(cwd, 'global.npmrc');
writeFileSync(userconfig, ''); writeFileSync(globalconfig, '');
const env = { PATH: process.env.PATH, HOME: cwd, npm_config_cache: join(cwd, 'cache'),
npm_config_userconfig: userconfig, npm_config_globalconfig: globalconfig,
npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false', npm_config_fetch_retries: '0' };
const registry = await startPackedRegistry(assets);
try {
const install = run('npm', ['install', '--registry', registry.url, '--replace-registry-host=never', '--ignore-scripts', '--omit=dev', ...registry.specs], consumer, env);
if (rejected) await assert.rejects(install, /ETARGET/);
else {
await install;
assert.equal(JSON.parse(readFileSync(join(consumer, 'node_modules/@ci/grok-version/package.json'))).version, version);
}
} finally { await registry.close(); }
}
}));
test('real npm installs identical packed bytes, bundles, optional dependencies and bins without running scripts early', { timeout: 90_000 }, async () => scratch(async root => {
const assets = fixture(root, { publicDependency: true }), catalog = await writePackedRegistryCatalog(assets);
assert.ok(catalog.every(entry => entry.manifest.version === '1.0.0'), 'metadata must come from packed bytes, not changed source');
+6
View File
@@ -11,6 +11,12 @@ export const GROK_PUBLIC_INSTALL_LIFECYCLE = [
'npm', 'rebuild', '--offline', '--ignore-scripts=false', '--dangerously-allow-all-scripts',
];
export function grokPublicInstallVersion(sourceRevision) {
assert.match(sourceRevision, /^[a-f0-9]{40}$/);
// An all-numeric hash with a leading zero is not a valid numeric prerelease.
return `0.0.0-grok-verify.g${sourceRevision.slice(0, 12)}`;
}
export function runMacInitialInstallWithDiagnostics(install, { sourceRevision, platform, architecture, cacheDirectory, receiptPath }) {
const started = performance.now();
try {
+2 -2
View File
@@ -14,7 +14,7 @@ import { fileURLToPath } from 'node:url';
import { packConcurrency, packStagedPackages, packTimings } from './pack-staged-packages.mjs';
import { materializePublishManifest } from './prepare-bundled-package.mjs';
import { publicInstallClosure } from './public-install-closure.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs';
import { GROK_PUBLIC_INSTALL_IMAGE, GROK_PUBLIC_INSTALL_LIFECYCLE, assertInstalledNativeConsumerContinuity, assertNoBundledCodexPayloads, grokConsumerDockerArgs, grokPublicInstallVersion, installedClaudeProbeSource, installedCodexProbeSource, installedDaemonProbeSource, macNodeHeadersDirectory, macPublicInstallLifecycleProfile, macInstalledStartupProbeSource, runMacInitialInstallWithDiagnostics } from './grok-public-install-sandbox.mjs';
import { startPrefetch, stopPrefetches } from './prefetch-child.mjs';
import { retainRunnerQualificationPackages } from './retain-runner-qualification-packages.mjs';
import { bundledInstallTimings, startBundledInstalls } from './stage-bundled-packages.mjs';
@@ -204,7 +204,7 @@ mkdirSync(cache, { recursive: true });
const env = { ...process.env, NODE_PATH: '', ...(process.env.PAPERCLIP_PUBLIC_INSTALL_NPM_CACHE ? { npm_config_cache: cache } : {}), PAPERCLIP_RELEASE_REUSE_UI_DIST: '1', npm_config_ignore_scripts: 'false', npm_config_audit: 'false', npm_config_fund: 'false' };
const run = (cmd, args, cwd = root) => execFileSync(cmd, args, { cwd, env, stdio: 'pipe', maxBuffer: 32 * 1024 * 1024 });
const sourceRevision = run('git', ['rev-parse', 'HEAD'], repo).toString().trim();
const releaseVersion = `0.0.0-grok-verify.${sourceRevision.slice(0, 12)}`;
const releaseVersion = grokPublicInstallVersion(sourceRevision);
// The two network downloads the probes need do not depend on anything packed
// below, so they overlap the CPU-bound staging instead of running after it.
// The consumer image is pinned by digest and the Grok binary is checked against
@@ -1,23 +1,41 @@
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { agentWakeupRequests, authUsers, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
import { createIssueThreadInteractionSchema } from "@paperclipai/shared";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import { documentService } from "../services/documents.js";
import { resolveConfirmationFromComment } from "../services/confirmation-comment-resolution.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
const support = await getEmbeddedPostgresTestSupport();
async function nativeProposalRecipe(kind: "confirmation" | "checkbox", planRevisionId = randomUUID()) {
const skill = await readFile(new URL("../onboarding-assets/first-task/skills/first-task/SKILL.md", import.meta.url), "utf8");
const recipes = [...skill.matchAll(/^ {0,3}```json\n([\s\S]*?)^ {0,3}```/gm)].map(match => JSON.parse(match[1]!) as Record<string, unknown>);
const matches = recipes.filter(recipe => recipe.interactionKind === kind);
expect(matches, `one executable native ${kind} recipe must ship with the first-task skill`).toHaveLength(1);
return JSON.parse(JSON.stringify(matches[0]).replaceAll("<child-title>", "Approved child")
.replaceAll("<plan-revision-id>", planRevisionId)) as Record<string, unknown>;
}
it.each(["confirmation", "checkbox"] as const)("ships a native %s recipe accepted by the production interaction schema", async kind => {
const recipe = await nativeProposalRecipe(kind);
const parsed = createIssueThreadInteractionSchema.parse({ kind: kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation",
idempotencyKey: recipe.idempotencyKey, title: recipe.title, continuationPolicy: recipe.continuationPolicy,
payload: { ...(recipe.payload as Record<string, unknown>), version: 1, prompt: recipe.prompt } });
expect(parsed.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation");
});
if (!support.supported) console.warn(`Skipping first-task admission database tests: ${support.reason}`);
describe.skipIf(!support.supported)("onboarding first-task execution admission", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer();
server = await startRunnerApiTestServer({ humanSessions: true });
}, 60_000);
afterAll(async () => {
await server?.close();
@@ -86,6 +104,72 @@ describe.skipIf(!support.supported)("onboarding first-task execution admission",
await p.svc.acceptInteraction(p.issue, p.card.id, p.plan ? { selectedOptionIds: ["execute"] } : {}, { userId: f.responsibleUserId! });
};
it("an explicit Approve/Revise question remains input rather than proposal authority after the human answers", async () => {
const f = await fixture();
const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort();
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: {
idempotencyKey: "first-task-proposal-1", interactionKind: "questions", title: "Approve child task",
prompt: "I propose exactly one child task and will wait for approval.", continuationPolicy: "wake_assignee",
payload: { questionSet: { schema: "paperclip.question_set.v1", questions: [{ id: "approve_child_task",
prompt: 'Approve exactly one child task named "Approved child"?', required: true, answerMode: "single_select",
options: [{ id: "approve", label: "Approve" }, { id: "revise", label: "Revise" }] }] } },
} }) as { interaction: { id: string; kind: string } };
expect(interaction.kind).toBe("ask_user_questions");
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
const answered = await issueThreadInteractionService(server.db).answerQuestions({ id: f.issueId, companyId: f.companyId }, interaction.id,
{ answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] }, { userId: f.responsibleUserId! });
expect(answered).toMatchObject({ status: "answered", resolvedByUserId: f.responsibleUserId,
result: { answers: [{ questionId: "approve_child_task", optionIds: ["approve"] }] } });
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds);
});
it.each(["confirmation", "checkbox"] as const)("the shipped native %s recipe requires a human decision before it admits child work", async kind => {
for (const actor of ["human", "agent"] as const) {
const f = await fixture();
const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan",
title: "Approved child plan", format: "markdown", body: "Create one child for the proposed deliverable.",
createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null;
const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined);
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string; kind: string } };
expect(interaction.kind).toBe(kind === "confirmation" ? "request_confirmation" : "request_checkbox_confirmation");
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
const selectedOptionIds = kind === "checkbox" ? ((args.payload as { options: { id: string }[] }).options.map(option => option.id)) : undefined;
await issueThreadInteractionService(server.db).acceptInteraction({ id: f.issueId, companyId: f.companyId }, interaction.id,
selectedOptionIds ? { selectedOptionIds } : {}, actor === "human" ? { userId: f.responsibleUserId! } : { agentId: f.agentId, runId: f.runId });
if (actor === "human") await expect(create(f)).resolves.toMatchObject({ task: { parentId: f.issueId } });
else await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
}
});
it.each(["confirmation", "checkbox"] as const)("rejecting the shipped native %s recipe saves an assignee continuation without authorizing child work", async kind => {
const f = await fixture();
const issueIds = (await f.snapshot()).issues.map(issue => issue.id).sort();
const plan = kind === "checkbox" ? (await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan",
title: "Proposed child plan", format: "markdown", body: "Create one child for the proposed deliverable.",
createdByAgentId: f.agentId, createdByRunId: f.runId })).document : null;
const args = await nativeProposalRecipe(kind, plan?.latestRevisionId ?? undefined);
const { interaction } = await f.authority.execute({ tool: "request_human_input", callId: randomUUID(), arguments: args }) as { interaction: { id: string } };
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/interactions/${interaction.id}/reject`, {
method: "POST", headers: { cookie: await server.humanSession(f.responsibleUserId!), "Content-Type": "application/json", origin: server.apiUrl },
body: JSON.stringify({ reason: "Please revise the proposed scope." }),
});
const rejected = await response.json();
expect(rejected, JSON.stringify(rejected)).toMatchObject({ status: "rejected", continuationPolicy: "wake_assignee",
resolvedByUserId: f.responsibleUserId, result: { outcome: "rejected", reason: "Please revise the proposed scope." } });
expect(response.status).toBe(200);
const wakes = (await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId)))
.filter(wake => wake.payload?.interactionId === interaction.id);
expect(wakes).toHaveLength(1);
expect(wakes[0]).toMatchObject({ agentId: f.agentId, requestedByActorType: "user", requestedByActorId: f.responsibleUserId,
payload: { issueId: f.issueId, interactionId: interaction.id, interactionStatus: "rejected",
paperclipAgentMessage: { text: "Please revise the proposed scope.", source: "interaction_rejection" } } });
// The fixture disables execution: observe the real durable wake, not a model call.
expect((await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).map(run => run.id)).toEqual([f.runId]);
await expect(create(f)).rejects.toThrow(/proposal.*accept/i);
expect((await f.snapshot()).issues.map(issue => issue.id).sort()).toEqual(issueIds);
});
it.each([false, true])("admits persisted human proposal acceptance (checkbox=%s)", async checkbox => {
const f = await fixture();
const p = await proposal(f, checkbox);
@@ -35,8 +35,45 @@ Work in this order.
- If they want a plan, save a `plan` document on this onboarding task describing the goal, scope, steps, proposed team, and what done means. Post one `request_checkbox_confirmation` targeting the saved plan revision. A card or thread message alone is not a saved plan. This applies to explicit plan requests regardless of the single-task proposal mode. Proposing a team does not authorize hiring it.
- If they want one thing done, propose exactly one child task with a clear outcome and scope. Ask them to accept it before creating the child. Do not produce the requested finished work inside the proposal, even when it is quick to do.
- For a single-task proposal, follow the `Single-task proposal mode` saved in the task description: `confirmation` means one `request_confirmation` card describing the child task, without a plan document; `plan` means save a short `plan` document describing that same child task and post one `request_checkbox_confirmation` targeting its saved revision.
- With native tools, create those proposal cards using `request_human_input`: `interactionKind: "confirmation"` creates `request_confirmation`; `interactionKind: "checkbox"` creates `request_checkbox_confirmation`. Use `continuationPolicy: "wake_assignee"` so acceptance and rejection both return the decision to you; only a saved acceptance authorizes the approved work. Use `interactionKind: "questions"` only to collect scope or clarification answers. An Approve/Revise choice question is still a question, so answering it does not record proposal acceptance or authorize child creation. Present the proposal through the matching confirmation kind instead.
- Keep this task `in_review` while waiting. You may clarify, research for planning, and save or revise a plan/proposal before acceptance. Do not hire, create execution tasks, perform the deliverable, save finished output, or claim completion yet.
For a native single-task confirmation, call `request_human_input` with arguments like these. Replace `<child-title>` with the proposed title and describe its actual scope in the prompt. Use a new idempotency key for each changed or revised proposal. Reuse a key only for the exact same proposal when reconciling an interrupted call; the example key below is for the first proposal.
```json
{
"idempotencyKey": "first-task-proposal-confirmation",
"interactionKind": "confirmation",
"title": "Approve child task",
"prompt": "Approve creating exactly one child task named \"<child-title>\" for the scope described above?",
"continuationPolicy": "wake_assignee",
"payload": {
"acceptLabel": "Approve",
"rejectLabel": "Revise",
"rejectRequiresReason": true
}
}
```
For a native single-task proposal in plan mode, save the plan first, then call `request_human_input` with a checkbox card bound to its actual revision. Replace `<child-title>` and `<plan-revision-id>`; make the options describe the scope the user is approving. For a plan-only request, the option approves the plan alone and does not authorize implementation or hiring.
```json
{
"idempotencyKey": "first-task-proposal-plan:<plan-revision-id>",
"interactionKind": "checkbox",
"title": "Approve child task plan",
"prompt": "Approve the proposed scope in this saved plan?",
"continuationPolicy": "wake_assignee",
"payload": {
"options": [{ "id": "scope", "label": "Approve the proposed scope for <child-title>" }],
"defaultSelectedOptionIds": ["scope"],
"minSelected": 1,
"maxSelected": 1,
"target": { "type": "issue_document", "key": "plan", "revisionId": "<plan-revision-id>" }
}
}
```
3. Interpret the next reply against the latest proposal.
- Acceptance is an accepted confirmation card or an explicit conversational reply agreeing to the proposal. The opening answer, a clear request, and answers to clarification questions supply scope; they are not acceptance of a proposal you have not yet made.