ADR-0052: Record review from the decision board only after the host's own confirmation
| Field | Value |
|---|---|
| Status | proposed |
| Date | 2026-10-09 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | component |
| Scope | component |
| Tags | agent-plugin, copilot, canvas, governance |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Relates to | 0016, 0044, 0046, 0050, 0051 |
| Affects | path:packages/adapters/agent-plugin/extensions/adrkit/board-review-write.mjs, path:packages/adapters/agent-plugin/extensions/adrkit/board*.mjs, path:packages/adapters/agent-plugin/extensions/adrkit/panel-http.mjs, path:packages/adapters/agent-plugin/extensions/adrkit/register.mjs, path:packages/adapters/agent-plugin/extensions/adrkit/extension.mjs, path:packages/adapters/agent-plugin/test/wiring.test.ts |
| Source | docs/adr/0052-record-review-from-the-decision-board-only-after-the-host-s-own-confirmation.md |
Status: proposed. Agent-drafted and not ratified. This record amends ADR-0050 (the board is no longer write-free: its page can record review) and implements the plugin half that ADR-0051 planned under “Agent boundary”. It supersedes neither. ADR-0050’s read-only actions, allowlists, budgets, and the absent readiness verdict stay binding.
Revised after review (round 1). The first version treated the page’s two clicks and a nonce as the boundary. Review measured that the model receives the panel’s URL and token and can do everything the page does over HTTP. The boundary is now the host’s confirmation dialog, which the model cannot answer. The nonce and the two clicks remain as defence in depth only.
Revised again (round 2). The dialog now leads with the record and the action, a write that arrives while another is pending is told which one, the board may ask at most once per 10 seconds and five times per 10 minutes across all its panels, and an unreadable agent mode is refused. The record was retitled from “… with a confirmed, single-use nonce”, which named a control that is not the boundary.
Context
Section titled “Context”ADR-0051 added adr approve, adr object, and adr resolve, and kept every
agent surface away from them with a wiring guard over every plugin component,
the canvases included. It named one future path: a person pressing a button on
the Copilot app’s canvas, with the identity taken from ADRKIT_REVIEWER, never
from the model or a tool argument, under one narrow, tested exception for a
single module that handles the page’s POST.
The decision board (ADR-0050) already lists every open proposal with its approvals against quorum and its objection counts. A reviewer reading it has to leave the app to change those counts.
Facts that shape the design, each measured on 2026-10-09:
- The model holds the panel’s URL and token. The runtime’s built-in
open_canvastool returns the provider’sOpenCanvasInstance,urlincluded, as the model-visible tool result, and the model can open its own board instance at any time. With its shell tool it can then call any route the page calls, with any headers. Measured by review on thecopilot1.0.94-3 runtime throughrpc.tools.execute('open_canvas')andbashwithcurl: an approval was recorded with no page and no click. - An extension’s
session.ui.confirmgoes to the host, not the model. It is an elicitation: the runtime routes it to the host’s elicitation handler (the person’s UI) and returns its answer. The model only ever sees the enclosing result. With no handler,session.capabilities.ui.elicitationisfalseandconfirmthrowsElicitation is not supported by the host. - No permission or agent mode answers an elicitation on its own at the
runtime layer. Measured through an SDK host with a handler that records
each call: in permission modes
manual,assisted, andallow-all, aftersetApproveAll(true), and in agent modeautopilot(withallow-alland withmanual), eachconfirmreached the handler exactly once and returned its answer (falsefor a decline,truefor an accept). - The CLI’s terminal UI declines elicitations in autopilot. Read from the
1.0.93 bundle (
cli-main.js): onelicitation.requested, when the agent mode isautopilot, it answers{ action: "decline" }without showing a dialog; in every other mode it queues a dialog. Not exercised interactively. - A refusal has no machine-readable reason.
@adrkit/cli0.18.0 with--jsonprints{ id, path, by, changed, … }on exit 0. On a refusal (exit 1) it prints nothing on stdout; the reason goes to stderr. - An older CLI is indistinguishable by exit code. 0.17.0 exits 2 on the
unknown subcommand, as 0.18.0 does on a usage error;
adr --versiontells them apart. --summaryand--bytake the next argument as their value unless written as--flag=value, so a value that starts with-must be passed as one element.
Decision
Section titled “Decision”We will add approve, object, and resolve controls to each open-proposal row of the decision board, behind this boundary:
- The person confirms every write in the host’s own dialog. Before it
spawns anything, the write route calls
session.ui.confirm(...), and only a literaltruewithin two minutes proceeds. A throw, a timeout, a decline, or any other answer writes nothing and returns a fixed “not confirmed” result. If the host does not offer elicitation (capabilities.ui.elicitationis nottrue), the controls render disabled with a fixed note (“this host cannot ask you to confirm a review write”) and both routes refuse. If the session’s agent mode readsautopilot, the route refuses before asking, whatever the host would do. If reading the mode throws or does not answer within 5 seconds, it refuses too; a session with no mode method at all is still asked, because the confirmation still guards. After a yes, the corpus directory the board shows is read and confined again right before the spawn, because the dialog can stay open for two minutes. The dialog text is built only from fixed strings, the kind, the validated record id, the identity fromADRKIT_REVIEWER, and numbers (the objection index, or the summary’s length). It never contains the summary or a title, which are untrusted and which a forged request controls. Its first line is the record and the action, such asADR-0042 · approve as @x, and the fixed explanation follows, so the record is what the person reads first.- A pending confirmation is named. While one write waits for the host, another gets a 409 that names the pending kind and record (“GitHub Copilot is already asking you to confirm an approval of ADR-0042. Decline it unless you started it, then try again.”), and the page shows it as a warning.
- Dialogs are rate-limited across the whole extension: at most one per 10 seconds and five per sliding 10 minutes, counted over every board panel in the extension process, so opening, closing, or reopening a panel does not reset the budget. A Copilot session belongs to one person, so a shared budget throttles nobody else. Over either limit the route answers 429 with a fixed message, asks nothing, spawns nothing, and logs the refusal.
- The identity is the environment’s. The extension reads
ADRKIT_REVIEWERon every request. It must pass core’sisWritableIdentityrule, mirrored in the extension (an extension cannot import core) and compared with core by a test, plus a 320-character cap. Unset or invalid, the controls are disabled with a fixed note and both routes refuse. A request body with any key beyond the documented ones (byamong them) is refused. Canvas action results carry whether the controls are on and the fixed reason, never the identity; the page’s own HTTP state carries it for its labels. - One module may name the verbs.
board-review-write.mjsholds the POST handler and is the only plugin module the wiring guard exempts, for the three review verbs only and never for ratification. Onlyboard.mjsnames it, once, as a static import it does not re-export, and only its HTTP route handler calls it. No canvas action, tool, hook, or workflow imports it. This is a code-structure rule. It does not keep the model away from the write: the model reaches the routes over HTTP (fact 1). Decision 1 is what does. - The page’s two clicks and the nonce are defence in depth. The first
click asks
POST /api/review/noncefor a 32-byte nonce bound to that kind and record, and the second, an in-page “Confirm … as” button placed after a Cancel button so a double click cannot land on it, spends it on POST /api/review. The nonce is single use, even for a refused attempt. A newer nonce on the same panel replaces it, closing the panel drops it, and it expires after two minutes. It is never part of a canvas action result. It does not stop the model: anything holding the token, which the model does, can mint and spend one exactly as the page does. It stops a replay of a captured request, and it makes each write an explicit, bound request. - Origin hardening on the review routes. On top of the URL token and the
X-Adrkit-Tokenheader every board POST needs, the two review routes require anOriginthat is exactly the panel’s own (a request with none is refused) and, whenSec-Fetch-Siteis sent,same-origin. This stops naive scripts and other pages. curl can send both headers, so it is not the boundary either. - argv only, validated first. The CLI runs as
[subcommand, id, '--by=<reviewer>', '--json', ('--dir', dir)?, …], with the summary as one--summary=<text>element and the index as'--objection', n. Nothing goes through a shell. The id, the summary (core’sobjectionSummaryProblemrules, mirrored and compared by a test), and the index are checked before the confirmation is asked. The corpus directory is the one the board shows, re-confined before the spawn. One write, including its confirmation, runs at a time per board; a second gets 409. - Fixed results only. Exit 0 with
changedis “recorded” or “nothing changed”; exit 1 is one fixed refusal message (fact 5); exit 2 asksadr --versionafresh, and below 0.18.0 gets “this adr CLI does not support review commands; upgrade @adrkit/cli to 0.18.0 or later”, otherwise a fixed usage-error message. The nonce route asks for the version first too. Any other exit, a spawn failure, and a 30-second timeout each have a fixed message. stderr and exception text never reach the page, the agent, or the log, and a refusal’s reply text is looked up by code. - After a write, re-read. The board re-reads its graph and the shared queue and returns the new snapshot with the result.
- Still no readiness verdict, and neither the page nor the write module names the ratifying command.
- Logged, not narrated. Every write attempt that reaches the
confirmation is logged through
session.log, fire-and-forget, asadrkit: decision board review <kind> on ADR-<id> as <identity>: <outcome>(not-confirmedincluded), never with the summary. The board’s factory now takes a session getter for the confirmation, the agent mode, and the log, which amends ADR-0050’s “its factory takes no session at all”. - The required CLI is
@adrkit/cli0.18.0 or later.
Options considered
Section titled “Options considered”Option A: page controls, host confirmation as the boundary (chosen)
Section titled “Option A: page controls, host confirmation as the boundary (chosen)”Meets ADR-0051’s intent where the host shows its elicitation dialog to a person. Costs one exempt module and one dialog per write.
Option B: page controls with the nonce and two clicks as the boundary (first draft, rejected by review)
Section titled “Option B: page controls with the nonce and two clicks as the boundary (first draft, rejected by review)”Measured broken: the model holds the token and can drive the routes itself.
Option C: a canvas action the model can call to record review
Section titled “Option C: a canvas action the model can call to record review”Rejected by ADR-0051: canvas actions are model-callable.
Option D: let the page supply the identity
Section titled “Option D: let the page supply the identity”Rejected: any holder of the token could claim any identity.
Option E: keep the token out of the URL (a cookie or a page-held secret)
Section titled “Option E: keep the token out of the URL (a cookie or a page-held secret)”open() must return a URL the app frames, and that URL reaches the model. A
one-shot bootstrap token exchanged for a cookie depends on cookie behavior in
a cross-site loopback iframe in the app, which is unmeasured and likely blocked;
a “first claimer wins” page secret is a race the model can win. Either would
make a forged write detectable, not impossible.
Option F: parse the CLI’s stderr to show the refusal reason
Section titled “Option F: parse the CLI’s stderr to show the refusal reason”Rejected: stderr is unstructured, can carry repository text and stack frames,
and only fixed messages reach a page (CodeQL js/stack-trace-exposure on #267).
Trade-offs
Section titled “Trade-offs”- The boundary is the host’s dialog, and the app is only partly measured.
Facts 2–4 hold for the runtime (measured) and for the CLI’s terminal UI (read
from its bundle). In the Copilot app (runtime 1.0.94-3, 2026-10-10), the
maintainer saw the board’s controls enabled, the host’s dialog shown for an
approval, a decline write nothing, and an accept write the approval. Whether
the app ever answers the dialog without a person (for example in an
autonomous mode) is still not known. If the app answered
trueon its own, the model could record review asADRKIT_REVIEWERwith no person involved. The extension’s own autopilot refusal covers the one mode it can see; it cannot see a host that auto-accepts in some other way. This residual risk is the maintainer’s to accept or to close by measuring the app before the controls are relied on. - A dialog the person did not start can look like one they did. A prompt-injected model can open its own panel and POST an approval of ADR-0042, so the host’s dialog for it is pending. The person then confirms an approval of 0002 on the board, their write is turned away because one is pending, and the one dialog on screen is the model’s, just when they expect one. Three things narrow this. The dialog’s first line names the record and the action. The person’s 409 says which confirmation is pending and to decline it unless they started it. And the board cannot keep a dialog up continuously: one per 10 s and five per 10 minutes, across every board panel, so a model that opens fresh panels gets no fresh budget. Within the budget a model can still stage a dialog now and then, and a person who answers yes without reading the first line can still approve the wrong record.
- A person can still be talked into “yes”. A prompt-injected model can start a write and the dialog will appear; a person who accepts without reading has approved it. The dialog names the record, the kind, and the identity, and says to decline unless the person just asked for it on the board. It cannot say whether the request came from the page.
- The model can start writes, so it can also make dialogs appear. That is a nuisance, not a write, and the log names every attempt and its outcome, rate-limited ones included.
- The dialog guards the board’s write path, not review state in general.
A model with shell access can still run
adr approve <id> --by=<anyone>directly, edit a record’s frontmatter, or edit the installed extension and reload it. ADR-0051’s agent boundary is about which plugin surfaces exist, and reviewing the pull request diff is the backstop for review state however it was written. - A token holder can cancel a person’s armed confirmation. Any POST to the write route spends the panel’s live nonce first, and a “busy” refusal spends it too, so another client holding the token can make the page’s Confirm fail with “expired or already used”. The person re-arms. This is denial of service only.
- A refusal says less than the terminal does, and resolve takes an index the page cannot verify (the queue reports counts, not objections).
- An older CLI shows enabled controls until a person arms one, because the version check runs at the first click.
Consequences
Section titled “Consequences”- Easier: a reviewer records an approval, an objection, or a resolution from the board, confirms it in the host’s dialog, and sees the counts change.
- Harder: one exempt module, one dialog per write, and a host requirement (elicitation) without which the controls stay off.
- How we would know this was wrong: a review is written without a
truefrom the host’s confirmation; a person approves a record they did not choose because a staged dialog looked like their own; a host answers that confirmation without a person (in the app, in any mode); the dialog shows the summary or a title; a write runs with an identity from anywhere butADRKIT_REVIEWER; stderr, exception text, or a summary appears in a reply or the log; the board shows a readiness verdict or the ratifying command; or a second module names the verbs. - Revisit if: the app is measured (either way); the runtime stops handing
canvas URLs to the model; the CLI gains a machine-readable refusal code
under
--json; or the queue starts reporting objections themselves.
Evidence rung
Section titled “Evidence rung”Rung 1 under ADR-0014. Unit and contract tests, each observed failing before it passed: before the code existed, or under a mutation of the code it covers. The first round ran 24 mutations; the review round ran 15 more and the second review round 11 more, all killed. An end-to-end test drives the routes against the repository’s built CLI on a fixture corpus.
Headless SDK-host measurements on 2026-10-09 (SDK client from the Copilot CLI
1.0.93 package, copilot 1.0.94-3 runtime, no prompt, no workflow, so no
model calls). They are recorded in
docs/reference-verification-agent-plugin.md:
- the elicitation routing and mode matrix behind facts 2 and 3;
- the review’s attack, re-run after the fix. It used only what the model sees
(
open_canvas’s result) and itsbashtool. With no elicitation handler, the controls were off and every request was refused. With a handler that declines, the forged request endednot-confirmedand the fixture was unchanged. Only a handler that accepts let it write. A request with noOrigingot 403 in all three.
In the Copilot app (maintainer, 2026-10-10, runtime 1.0.94-3, published
@adrkit/cli 0.18.0), the controls were enabled under ADRKIT_REVIEWER, and
the host’s dialog appeared for an approval. A decline wrote nothing, an accept
wrote exactly the approval, and a second click within 10 seconds was
rate-limited. Object and Resolve, the dialog’s lead-line wording, an Origin
refusal, and the app’s behavior in autonomous modes were not measured. No
reference-repository run and no external validation.
Action items
Section titled “Action items”- Add
board-review-write.mjs, route/api/review/nonceand/api/reviewto it fromboard.mjs, and add the controls to the page. - Require the host’s confirmation for every write, failing closed, and refuse in autopilot.
- Narrow the wiring guard to exactly one exempt module for the review verbs, and test the module’s reachability and the action boundary.
- Headless SDK-host measurements, recorded in
docs/reference-verification-agent-plugin.md. - Measure in a Copilot app session that the app offers elicitation to the board and shows the dialog to the person, that a decline writes nothing, and that an accept writes (runtime 1.0.94-3, 2026-10-10).
- Measure in the app that it never answers the dialog on its own, autopilot included; exercise Object and Resolve there; and record the app version.
- Ratify or reject this record.