ADR-0051: Record review state with adr approve, adr object, and adr resolve
| Field | Value |
|---|---|
| Status | proposed |
| Date | 2026-10-09 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | org |
| Scope | org |
| Tags | cli, queue, governance, compatibility |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Relates to | 0016, 0028, 0031, 0044, 0046, 0048 |
| Affects | path:packages/core/src/transition/**, path:packages/core/src/schema/identity.ts, path:packages/core/src/queue/kernel.ts, path:packages/cli/src/review-commands.ts, path:packages/cli/src/accept.ts, path:packages/cli/src/index.ts, path:packages/cli/src/record-target.ts, path:packages/cli/src/command-registry.ts |
| Source | docs/adr/0051-record-review-state-with-adr-approve-adr-object-and-adr-resolve.md |
Status: proposed. Agent-drafted and not ratified. This record grows the public write surface that ADR-0044 set at three commands. It supersedes nothing; ADR-0044’s design for
adr acceptstays binding and is reused here. It amends one ADR-0044 semantic: approvals towardreview.quorumnow count distinct identities (see “Shared rules”).
Context
Section titled “Context”adr accept (ADR-0044) refuses a proposed record that has an unresolved
objection, or fewer approvals than its review.quorum. adr queue shows both
counts. Nothing records them. Approving, objecting, and withdrawing an
objection all mean editing review.approvals and review.objections by hand,
so the command that enforces review state has no counterpart that writes it.
The hand edit is where mistakes happen. review.objections is a list of
mappings, and its resolved flag defaults to false when absent. A reviewer
who adds resolved: true at the wrong indent ends up with either a YAML error
or a new key on a different objection, and neither is obvious in a diff.
ADR-0044 named this case under “Revisit if”: a second transition asked for
would argue for a shared transition kernel. The splice-and-verify machinery
behind acceptAdrSource is that kernel; this record reuses it.
The same constraints bind these commands as bound adr accept: the write
surface is a semver commitment (ADR-0031), machine writes go through pull
requests, and review is a human act.
Decision
Section titled “Decision”We will add three writing commands that record review state on a proposed
record under a named identity, built on the same pure splice-and-verify
transition kernel as adr accept.
adr approve <id> --by <identity>adds the identity toreview.approvals. Approving twice is a no-op: exit0, a message, and no write.adr object <id> --by <identity> --summary <text>appends{ by, summary, resolved: false }toreview.objections. Raising the same open objection twice (sameby, same summary, still unresolved) is a no-op.adr resolve <id> --objection <n> --by <identity>setsresolved: trueon objectionn, counted from 1 in file order. Resolving a resolved objection is a no-op.
Shared rules
Section titled “Shared rules”- The record must be
proposed. Review state changes only while a record is under review. Any other status, and any record with lint errors, is refused with exit1and the file left untouched. --byis required, is never inferred, and must be a schemaIdentitywith no control or invisible characters. It names the person whose review this is. Likeadr accept, the command does not read git config or the environment for it, and given twice it is a usage error.--byis a claim, not an authentication; the pull request remains the control. The schema’s email branch admits control and format characters (\p{Cc},\p{Cf}: an escape, a bidi override, a zero-width space); no writing command records one,adr acceptincluded. The one exception is ZWNJ and ZWJ (U+200C, U+200D) inside the email form, which an internationalized local part may need; a handle or team slug cannot hold them anyway.adr acceptalso refuses a repeated--bynow, as the review commands do. TighteningIdentityitself is a schema change this record does not make.- Identities compare case-insensitively. Two identities are the same person
when they are equal after an ASCII casefold of the whole identity, so
@boband@Bob, andEve@Example.comandeve@example.com, are one person. GitHub handles and team slugs are case-insensitive, and so in practice is an email address. This applies to the approve and object no-ops and to the objector check. Non-ASCII letters are not folded. - Quorum counts people, not entries. One reviewer approving as
@boband again as@Bobmust not meet a quorum of two. Soadr approvewrites nothing for a case variant, andacceptAdrSource’s quorum check andadr queue’sapprovalCountnow count distinct identities under the same fold. That is a behavior fix to ADR-0044’s semantics, which counted entries: a record that hand-listed two spellings of one person met quorum before and does not now. - Only the objector may resolve an objection.
--bymust be the same identity as that objection’sby, under the fold above. The schema has noresolvedByfield, so the record of who resolved it is that it was the objector. A chair override, and aresolvedByfield that would make one auditable, are future work and need a schema change this record does not make. - A summary is one safely quoted line. It is trimmed, must be non-empty, is
at most 500 code points after trimming, and may contain no control character
(C0, DEL, C1), no invisible format character (
\p{Cf}: bidi overrides and isolates, zero-width spaces and joiners, the BOM), and no U+2028 or U+2029 line separator. A summary is shown in the queue, in diffs, and on the canvas, where a bidi override would render reversed text. Refusing every format character also refuses an emoji ZWJ sequence; that is accepted. It is always written as a YAML double-quoted scalar, so#,:, quotes, and leading indicators survive. - The transitions are pure.
approveAdrSource,objectAdrSource, andresolveObjectionAdrSourcelive inpackages/core/src/transition/with no clock and no filesystem. The CLI owns the I/O. - They splice lines, never round-trip YAML, for ADR-0044’s reason. They
handle
review:absent,review:present without the list, a block list, and a flow list, empty or not. A flow list stays a flow list and a block list stays a block list; anobjections: []therefore grows as a flow list of flow mappings rather than being rewritten as a block. An insertion never backs up into the value above it, so a literal block whose content has a# …line and a keep-chomped (|+) scalar with trailing blank lines are spliced around, andresolvefinds an item whose-stands alone on its line. A layout they cannot splice safely (for examplereview: {…}, or areviewthat is not a block mapping) is refused with a message to edit by hand. - The re-parse guard is shared, not copied. The splice helpers and the
“parse again, compare to the intended data, validate” tail moved from
accept.tsinto an internaltransition/splice.ts, so every transition goes through the same check. A splice that would change any other field is a refusal, never a write. - Exit codes match
adr accept:0written or a no-op,1refused with the record unchanged,2usage error. A malformed--by, a missing, empty, multi-line, or overlong--summary, and an--objectionthat is not a positive whole number are usage errors, checked before the corpus is read; the core transitions refuse the same inputs independently. All three take--json, becauseadr acceptdoes. adr acceptchanges in three ways only. It shares the CLI’s record lookup (record-target.ts), with its messages kept byte for byte; its quorum check counts distinct identities; and it refuses a ratifier with control or invisible characters.
Agent boundary
Section titled “Agent boundary”These commands write review state under a person’s identity, so no agent surface runs them on the model’s initiative: no skill, subagent, command, MCP tool, or extension tool. Three tests enforce it, each observed failing against planted mentions (ADR-0016):
- The agent plugin’s wiring test fails if any command, skill, agent, or
extension module, the canvas included, names
adr approve,adr object, oradr resolve. The same test now guardsadr acceptthe same way. Its patterns catch the forms an extension would really use, not only prose: the verb as any quoted array element or call argument (['approve'],[cli, 'approve', id],args.push('approve'), but not a JSON-schema type list), a quoted verb assigned to a name (const verb = 'approve'), a tool name (adr_approve), and a call throughadr,$ADRKIT_CLI,@adrkit/cli, or a template placeholder (`${cli} approve`), in any case and across invisible characters. A verb built at run time from parts is beyond any static scan; the record, not the test, is the boundary there. - The Spec Kit extension’s test applies the same patterns, plus its
adrkit_cliwrapper, to its commands, scripts, and manifest. - The MCP server’s test applies them to its source and also refuses an import of any writing transition. The server stays at four read-only tools.
The only planned plugin path is a later track: a person pressing a button
in the Copilot app’s canvas, with the identity taken from the environment
(ADRKIT_REVIEWER), never from the model and never from a tool argument. The
canvas’s actions are model-callable, so the canvas stays under the guard. The
track that builds the button will add one narrowly scoped, tested exception for
a single module that handles the page’s POST, under its own record, and a test
that no canvas action can reach the verbs.
Options considered
Section titled “Options considered”Option A (chosen): three narrow verbs on a shared transition kernel
Section titled “Option A (chosen): three narrow verbs on a shared transition kernel”| Dimension | Assessment |
|---|---|
| Write surface | Grows from three commands to six; each edits one list on one record |
| Human act preserved | --by is mandatory and never inferred; no agent surface runs them |
| Formatting risk | The same splice and semantic re-check as adr accept; refuses what it cannot splice |
| Authority | Only the objector resolves; nobody can approve on another’s behalf except by typing their handle, which the PR shows |
| Cost | Three semver-committed verbs |
Option B: one adr review <id> approve|object|resolve verb
Section titled “Option B: one adr review <id> approve|object|resolve verb”Pros: one entry in the command list. Cons: each sub-action takes
different required flags, and the help, completion, and refusal messages all
branch on the sub-action. Three flat verbs read better in adr queue’s
next-step hint and in a shell history.
Option C: let adr accept take --approve and --resolve flags
Section titled “Option C: let adr accept take --approve and --resolve flags”Pros: no new verbs. Cons: it collapses review into ratification. The
ratifier would be recording other people’s approvals, which is exactly the
authority ADR-0044 denied adr accept (“it does not count --by as an
approval”).
Option D: keep editing review state by hand
Section titled “Option D: keep editing review state by hand”Pros: no new write surface. Cons: the hand edit is error-prone in
the nested list, and adr accept then refuses on state nobody can write
safely.
Trade-offs
Section titled “Trade-offs”- The write surface doubles. Removing or reshaping any of the three verbs
is a breaking change under ADR-0031. The record is
two-way-dooronly while minor releases may break before1.0.0(ADR-0002). - No chair override. A departed objector’s objection can only be resolved
by hand-editing the record. That is deliberate until
resolvedByexists. - Flow lists stay flow. An objection appended to
objections: []is a one-line flow mapping, which is valid and minimal but less readable than the block layout the corpus otherwise uses. - A BOM-prefixed record is refused.
parseFrontmatterrequires the file to begin with---, soadr lintalready reports such a record as invalid, and these commands, likeadr accept, refuse it rather than strip the BOM. - The extension tools’ scrub names these verbs.
redactWritingCommandsin the plugin’stools.mjsnow redactsadr approve,adr object, andadr resolvefrom tool results, alongsideaccept,new, andmigrate, because a record title is repository text and can carry one to the model. - Concurrent runs are not serialized. Each command reads the record,
computes the new text, and writes it, with no cross-process lock, and
adr accepthas the same window. Two commands run at the same instant on the same file can lose one update: an objection written betweenadr accept’s read and write would be overwritten. I accept that rather than add lock files to a CLI that has none. These are commands a person runs in a working tree, nothing is committed, and the result reachesmainonly as a reviewed pull request diff, where a lost objection is visible. If concurrent automation ever drives them, that would argue for a compare-and-swap re-read before the write.
Consequences
Section titled “Consequences”- Easier: review state is recorded by command, and
adr queue’s approval and objection counts change with it. A record can go from objection to acceptance without a hand edit. - Harder: six writing commands to keep agent-proof, and every document that lists the write surface must name them.
- How we would know this was wrong:
- A review command’s diff touches a line beyond its list: the splice has a hole the re-parse guard missed.
- An agent surface is found running one of them.
- Adopters keep hand-editing objections because “only the objector” blocks
real workflows; that would argue for
resolvedByand a chair override.
- Revisit if: a chair or quorum-owner override is asked for twice, or the canvas button track lands and needs the boundary above restated.
Evidence rung
Section titled “Evidence rung”Rung 1 of ADR-0014: unit coverage of every splice shape for every command
(review absent, review with other keys, block list, empty flow list, flow list
with items, CRLF, comments near the block, a lone -, a literal block with a
# line, a keep-chomped scalar), case-variant identities, every refusal leaving the source
untouched, idempotence, the re-parse guard against a deliberately broken
splice, a whole-corpus test over this repository’s records comparing the whole
file, and CLI tests including an end-to-end approve, object, resolve, queue,
and accept sequence and a @bob/@Bob quorum pin.
No reference-repository or external run.
Action items
Section titled “Action items”- Ship
approveAdrSource,objectAdrSource, andresolveObjectionAdrSourcein@adrkit/coreon the shared splice kernel, with unit coverage of every shape and refusal. - Ship
adr approve,adr object, andadr resolvein@adrkit/cli, registered for help and all three completion shells, Node-compatible, with exit codes0/1/2and--json. - Enforce in the agent plugin’s, Spec Kit extension’s, and MCP server’s tests that no component mentions the three verbs, the canvas included.
- Count distinct identities, case-insensitively, in
adr accept’s quorum check andadr queue’sapprovalCount. - Update
AGENTS.md, the CLI README,site/src/content/docs/commands.mdx, andCHANGELOG.md, including every statement of the write surface. - Extend the extension tools’ writing-command scrub to the three verbs.
- Ratify this record with
adr accept 0051 --by <maintainer>.