ADR-0047: Show provenance, review cost, and a read-only proposal queue in the decision-review canvas
| Field | Value |
|---|---|
| Status | proposed |
| Date | 2026-10-08 |
| 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 | 0014, 0022, 0044, 0045, 0046 |
| Affects | path:packages/adapters/agent-plugin/extensions/**, path:docs/reference-verification-agent-plugin.md |
| Source | docs/adr/0047-show-provenance-review-cost-and-a-read-only-proposal-queue-in-the-decision-revie.md |
Status: proposed. Agent-drafted and not ratified. This record amends ADR-0046; it supersedes nothing. ADR-0046’s read-only, advisory, security, and process-count boundaries remain binding and are unchanged here.
Amended by ADR-0049 (proposed). One more caller waits for the queue: the advisory hooks’ background refresh (
refreshOpen), so that its single-flight covers theadr queueprocess too. It is not a page route or a tool result, so no person or agent waits on a slow queue through it. It runs under a 15 s signal combined with the queue’s own timeout. When that signal fires, the panel keeps its previous governing result and queue, each with a fixed “automatic refresh timed out” note, and never shows the abort’s exception text.
Amended by ADR-0050 (proposed). A second, corpus-wide canvas,
decision-board, would renderadr graphandadr queueread-only, with no readiness verdict and no control that writes. It shares this canvas’s HTTP hardening, now inpanel-http.mjs, and registers in its own guardedtry. This record’s read-only, advisory, and security boundaries are unchanged and apply to the board too.
Context
Section titled “Context”ADR-0046 shipped the decision-review canvas: a panel in the GitHub Copilot app
that lists the decisions governing the current change and, once adr-review
has run, its verdicts. Three gaps showed up once it was in use:
- The panel said what governs, not why. Each governing decision had an
evidence section listing its
firedMatchers, but a record that governs only because a changed file names it in an inbound marker has emptyfiredMatchers, so the panel printed “No evidence recorded.” for a decision whose evidence is the most specific there is.adr check --jsonalready carries that evidence asdeclaredBy; the canvas’s projection dropped it. - Run review did not say what it would spend before spending it. The
button said “uses AI credits” and nothing more. The cost is not a mystery:
the workflow’s Judge phase is
ctx.pipeline(governing, … ctx.agent(…))inreview.mjs, onedecision-checkercall per governing decision, and zero when there are none. With changed files but no governing decision, the button was enabled and a run would judge nothing. - Open proposals were invisible unless the change touched them. The panel’s
“Active proposals” list is change-scoped: it shows
proposedrecords whoseaffectsfire on the changed files. A reviewer in the app had no view of the corpus-wide queue thatadr queuealready computes, so a proposal waiting on review elsewhere stayed out of sight.
The third gap has a trap in it. adr queue’s terminal view prints the
ratifying command for a human when a dry run says it would succeed
(ADR-0044). The plugin’s contract is that no component names that command
(ADR-0028, ADR-0034, ADR-0045’s wiring test), because a host model reads an
example as an instruction. A queue view in the canvas must not become a route
for that string into a page or into the agent’s tool results.
What was measured
Section titled “What was measured”Measured on 2026-10-08 on a four-record fixture repository (two accepted
records, one governing through an affects pattern and one only through an
inbound marker in a changed file, and two proposed records), with the CLI
built from this branch.
adr check --jsonattributes markers to files, and patterns to nothing finer than the pattern. A marker-governed record came back asfiredMatchers: []withdeclaredBy: [{ path: "src/net/client.ts", line: 1, ref: "0002" }]. A pattern-governed record came back asfiredMatchers: [{ type: "path", pattern: "src/net/**" }]with no file list, although two changed files sat under that pattern. The core type agrees:FiredMatcheris{ type, pattern }andresolveAffectsrecords the matcher, not the path that fired it.adr queue --format jsonis QueueReport v1 withversion,asOf,totalItems,totalCorpusFindings,items[], andcorpusFindings[]. Each item carriesid,title,sourcePath, tier and SLA fields,routingTargets,quorum, approval and objection counts, anditemFindings. The JSON carries no ratifying command; the terminal view adds it. An unreadable corpus directory exits2with a usage message on stderr.- A headless SDK-host smoke of this canvas (Copilot CLI 1.0.93,
pluginDirectoriespointed at this branch, no prompt sent,run_reviewnot invoked) opened the panel with status2 governing · incomplete;get_statecarrieddeclaredByon the marker-governed record,judgeCalls: 2, and both proposals underqueue; neither/api/statenor/app.jscontained the ratifying command; arefreshwith only an ungoverned file gavejudgeCalls: 0; arefreshagainst a missing corpus directory gavequeue.available: falsewith the fixed note “adr queue exited 2” while the check reported its ownusage-error; and the 403s, the CSP, and the port release on close were unchanged. Details are in the evidence index. ADRKIT_CLIset in the runtime’s environment reaches the extension in that headless host: pointing it at a missing file made both the check and the queue report that it could not be started. This is the SDK host, not the app; ADR-0046’s open question about the app is not closed by it.
Decision
Section titled “Decision”We will make three additions to the decision-review canvas in
packages/adapters/agent-plugin/extensions/adrkit/ (canvas.mjs and
canvas-page.mjs). No new registration, action, route, or extension directory
is added, and review.mjs and register.mjs do not change.
1. Provenance. The canvas’s decision projection keeps declaredBy beside
firedMatchers, by allowlist: each entry keeps path (string), line
(integer), and ref (string), and anything else is dropped. The same
projection serves adr check output and a result handed to show_review, so
both carry it. The page’s evidence section renders, as text:
- “Matched by affects pattern:” and the pattern, followed by “adr check does not report which changed file matched a pattern.”
- “Declared by an inbound marker in a changed file:” and each
path:line names <ref>. - “No evidence recorded.” only when there is no verdict, no matcher, and no declaration.
The canvas does not compute which changed file matched a pattern. Doing so
would mean reimplementing @adrkit/core’s matcher semantics (glob dialect,
negate, snapshots, scoped matchers) in a dependency-free extension that cannot
import core, or spawning one adr check per changed file on the free refresh
path. Either would produce a provenance that could disagree with the CLI’s own
answer. The page says what the CLI does not report instead.
2. Cost before spend. The panel snapshot gains judgeCalls, the number of
governing decisions in the current check. As review.mjs is written,
adr-review makes at most that many decision-checker calls, and exactly that
many when adr check and adr lint exit 0 or 1; otherwise it skips the
Judge and makes none, so judgeCalls is 0 in that case. Runtime retries are
not counted. The Run review button reads “Run review: N decision-checker
call(s) (uses AI credits)”, and is disabled with a stated reason when N is 0:
“No governing decision, so there is nothing to judge”, or, when governing
decisions exist but adr check or adr lint failed, that the review would make
no decision-checker calls. It was already disabled with no changed files. The
run_review action description says at most one call per governing decision
and none when check or lint exit 2 or more, points to judgeCalls, says that new input
re-checks first so the count can change, and cites the one measured run (about
0.16 AI credits for two decisions on Copilot CLI 1.0.93) as a measurement, not
a price. The server does not refuse a zero-governing run from the agent: such a
run reaches no Judge call, and the description says so.
3. Read-only queue. Each refresh, concurrently with the check and
independent of it, runs adr queue --format json with the same --dir the check used, in the
session directory, with the CLI resolved exactly as the check resolves it. It is
free: one CLI call, no model. The result is a queue block on the snapshot:
{ available, asOf, exitCode, totalItems, corpusFindings, items, note }, with
each item reduced by allowlist to id, title, sourcePath, slaState,
deadlineDate, approvalCount, quorum, unresolvedObjectionCount, and
routingTargets, and at most 200 items with a note saying how many were left
out. Each string in an item (and each declaredBy path and ref) is clipped to
4000 characters, as CLI messages already are, and at most 50 routing targets are
kept. Per-value caps alone still allow tens of megabytes (200 items of 50
targets of 4000 characters), so the rows also share a budget of 256 KiB
serialized (UTF-8 bytes); the same “first N of M” note covers either cut. The snapshot is
broadcast to every panel and returned to the agent on each refresh. The queue has its own timeout (30 seconds); past it, its
process is signalled and the panel shows a note, so a hung adr queue never
holds the governing view: the check is committed and broadcast when it
settles, and the queue follows in a second update under the same sequence
guard. Opening a panel, the page’s GET and POST routes (refresh, run-review, explain),
and run_review’s own pre-run refresh do not wait for the queue; only an
agent’s get_state and refresh actions do, so their results are complete. The page shows the items in a section headed “Open
proposals, corpus-wide”, labelled “Listed, not judged”.
- No ratify control and no ratifying field. Queue rows have no button, no
action, and no explain. Their ids are not added to the set explain accepts,
so the prompt surface is unchanged. The allowlist is the mechanism that keeps
any ratifying field out: a field the JSON gains later is dropped without
anyone having to name it. It selects fields, not text. A kept string such as
titleis untrusted repository text, shown as data and not vetted for what it says, so a record titled like a command shows that title (as the governing records’ titles already do). A test plants such fields and asserts that neither the snapshot, the served/api/state, the rendered page, nor the shipped HTML, JavaScript, and CSS contain the command. - Failures are notes, never breakage. Exit
0and1both carry a complete report (1means corpus findings, counted on the page). Exit2, a CLI that cannot be resolved or started, a report larger than the output buffer, a timeout, invalid panel arguments, unreadable output, and a report version other than1each becomeavailable: falsewith a fixed note chosen by an explicit check. The CLI’s stderr and an exception’s text are never copied into the note, following the rule CodeQL’sjs/stack-trace-exposurefinding set for the first canvas. The queue never changes the panel’s status, its governing list, or its notes. - Titles and paths are untrusted text, rendered with
textContentlike everything else on the page.
Options considered
Section titled “Options considered”Option A: Three additions inside the existing canvas, by allowlist (chosen)
Section titled “Option A: Three additions inside the existing canvas, by allowlist (chosen)”| Dimension | Assessment |
|---|---|
| Provenance | What the CLI reports, and a stated limit where it reports less |
| Cost | Stated before the click, from the code that spends it |
| Queue | Visible, free, read-only, and unable to carry a ratifying field |
| Surface | No new action, route, registration, process, or dependency |
| Cost to us | One more CLI call per refresh, and two more snapshot fields to keep in step |
Option B: Compute per-file pattern matches in the extension
Section titled “Option B: Compute per-file pattern matches in the extension”Pros: The panel could say “src/net/client.ts matched src/net/**”.
Cons: It reimplements core’s matcher semantics without core, so it can
disagree with adr check and present that disagreement as provenance. Rejected.
If per-file attribution is wanted, it belongs in @adrkit/core and
adr check --json, where the panel would then read it.
Option C: One adr check per changed file on refresh
Section titled “Option C: One adr check per changed file on refresh”Pros: Exact attribution from the CLI itself.
Cons: N process spawns on the free path, for a wide change. Rejected for now.
Option D: A queue with a ratify button, or the ratifying command shown for copying
Section titled “Option D: A queue with a ratify button, or the ratifying command shown for copying”Rejected, as ADR-0046 rejected a ratifying canvas (its option F). Ratification is a human act (ADR-0044), and a model-reachable page that names the command is the instruction the plugin’s wiring rule exists to prevent.
Option E: Quote a credit price
Section titled “Option E: Quote a credit price”Rejected. Pricing is the host’s and changes; the panel states the call count it can derive from code, and the docs cite the one measurement as a measurement.
Option F: Do nothing
Section titled “Option F: Do nothing”The panel keeps saying “No evidence recorded.” over marker-governed records, keeps offering a run that would judge nothing, and keeps the queue out of view.
Trade-offs
Section titled “Trade-offs”A pattern-governed record still does not say which file fired it. The page says that the CLI does not report it, which is honest and less useful than an answer.
judgeCalls is the count for the panel’s current check. A run started with new
input re-checks first, and the repository can change between the check and the
run, so the count is a statement about now, not a quote. Runtime retries are not
counted.
Each refresh runs one more CLI process. It runs alongside the check and never holds it: a slow queue delays only the
second update, and never by more than its timeout;
on a large corpus adr queue reads every record.
The queue is corpus-wide while the rest of the panel is change-scoped. The heading says so, but a reader can still mistake a listed proposal for one the change touches; the change-scoped “Active proposals” list remains the one that does.
Consequences
Section titled “Consequences”- Easier: a reviewer sees why each decision governs when the CLI knows, sees what a review will cost before starting one, and sees what is waiting for review across the corpus, without leaving the panel and without spending.
- Harder: two more snapshot fields (
judgeCalls,queue) and one more CLI output shape (QueueReport v1) to track, and one more test that must keep the ratifying field out of a surface that now reads queue data. - How we would know this was wrong: the panel shows a file for a pattern
match that
adr checkwould not attribute to it; a marker-governed record reads “No evidence recorded.”;judgeCallsdiffers from the number ofdecision-checkercalls a completed run made (the workflow changed shape, or the runtime retries in a way users pay for); the Run review button is enabled with nothing to judge; a ratifying field (or text the panel itself generates) carries the command into the snapshot, the served state, the page, or an action result (a record whose own title reads like the command is data, not a failure); a queue row gains a control or an explain; a queue failure changes the panel’s status, its governing list, or its notes, or puts CLI stderr or exception text on the page; a slow or hungadr queuedelays the governing view; refresh starts a model call; or a reviewer reads the corpus-wide queue as the change’s own proposals. Revisit ifadr check --jsongains per-file attribution for pattern matches, in which case the panel should read it and drop the stated limit.
Evidence rung
Section titled “Evidence rung”Rung 1 under ADR-0014: unit and contract tests (each new one observed failing before it passed), and the headless Copilot CLI 1.0.93 SDK-host smoke above. The new UI is unmeasured in the Copilot app: the provenance lines, the call-count label, the disabled state at zero governing decisions, and the queue section have not been rendered in an app session. No reference-repository run and no external validation.
Action items
Section titled “Action items”- Keep
declaredByin the canvas projection by allowlist and render it, with the stated limit for pattern matches. - Add
judgeCallsto the snapshot; state the count on the button and in therun_reviewdescription; disable the button at zero governing decisions. - Compute the queue beside the check on refresh, by allowlist, with fixed notes for every failure, and render it without controls.
- Tests for each, observed failing first, including the planted ratifying-field test.
- Headless SDK-host smoke of open, state, refresh, and the page’s headers,
recorded in
docs/reference-verification-agent-plugin.md. - Render the new UI in a Copilot app session and record it with the app version.
- Ratify or reject this record.