Decision memory for human- and agent-authored plans — machine-readable ADRs
that are enforceable in CI and legible to agents, without leaving git.
Status: early — phases 0–6 landed and v0.20.0 is public. @adrkit/core,
@adrkit/evaluator, @adrkit/cli (lint, new, graph, explain,
check, queue, accept, approve, object, resolve, migrate --from madr,
evaluate) are published on npm, as is
the independently versioned @adrkit/spec-kit Spec Kit extension (0.1.5); the
repository-backed CI Action is available at mbeacom/adrkit/packages/ci@v0,
and the build-only decision-portal Action at
mbeacom/adrkit/packages/ci/portal@v0.20.0 (pin an immutable tag, never @v0).
The governing-decisions Action also has a root action.yml alias for GitHub
Marketplace beginning with v0.13.0. Root
Marketplace guidance uses immutable release tags; never advertise
mbeacom/adrkit@v0, because recovery may move that shared tag to a pre-alias
release. The queue Action remains nested because GitHub lists only root Action
metadata.
The published @adrkit/mcp server has exactly four local stdio tools and serves
both MCP protocol eras over one stdio connection — 2026-07-28 (stateless;
opened by server/discover or a 2026 _meta envelope) and the 2025 era (opened
by initialize) — via the SDK v2 serveStdio entry
(ADR-0018).
It passed real-session dogfood against the published artifact on both eras,
driven through the official MCP Inspector. The Inspector defaults to the 2025
era; select the modern one with "protocolEra": "modern" (or "auto") in the
server's entry in the Inspector's mcp.json; Inspector 2.8.0 (verified 2026-09-27) also accepts
--protocol-era legacy|auto|modern in CLI mode.
adr graph preserves agent/script compatibility while giving interactive users
a useful view under
ADR-0033.
Its default is auto: stdout attached to a TTY receives the terminal
status/relationship instrument; piped, redirected, and captured stdout still
receives deterministic DOT. Explicit
--format terminal|dot|json|mermaid always wins. --focus <id> keeps one ADR
and its direct neighborhood; repeatable
--kind supersedes|relatesTo|conflictsWith filters every format.
Three boundaries are load-bearing:
- TTY detection belongs only at the CLI boundary.
buildAdrGraph,filterAdrGraph, and every core renderer stay pure; JSON retains its existing{ nodes, edges }contract. - Full dense corpora are summarized in the terminal instead of being rendered
as an unreadable network. Use
--focusor--kindto expand a useful subgraph. Native SVG/HTML is deliberately deferred; polished DOT remains the dependency-free path to Graphviz SVG. - Terminal views have node and relationship budgets, and title truncation uses
grapheme-safe display width rather than UTF-16 length. Valid records still
produce complete DOT/JSON/Mermaid output when another corpus record is
invalid, but graph writes those error findings to stderr and exits
1.
A file can declare the decision it lives under by putting @adr 0012 on a
dedicated comment line inside its first 8192 bytes. v0.4.0 shipped that inbound
edge for adr explain <path> under ADR-0021 and
#97. v0.5.0 extended the same
resolution to adr check and the governing-decisions Action under
ADR-0022,
which supersedes ADR-0021 — read 0022, not 0021, for the current scope. No
schema change: AdrFrontmatter,
AffectsType, and schema/adr.schema.json are untouched.
Two properties are load-bearing and easy to break:
- Marker I/O stays outside the pure resolvers.
adr explain,adr check, and the CI Action scan at their filesystem boundaries;checkChangesaccepts pre-scannedmarkerScansand remains pure.declaredBylives on the sharedGoverningDecision, so bothcheck --jsonand the Action can report marker provenance. Markers add governance context and findings but never gain exit-code authority.packages/adapters/spec-kit/scripts/context.shdelegates path-aware context toadr check, so it inherits that marker scan without implementing a separate reader. - PR-authored declarations are capped before resolution. A file retains the first 64 parsed declarations in physical/source order; a batch retains the first 10,000 in code-unit path then source order after concurrent reads finish. Exact overflow is collapsed into one advisory report/finding, never one object per dropped declaration, and never gains exit-code authority.
- A marker naming history stays historical and gets an advisory warning.
superseded,rejected, anddeprecateddeclarations emitstale-marker; a resolvable supersession chain names its terminal live successor, but the resolver never silently substitutes that record. The original declaration remains underhistory, and the warning never changes an exit code. - A marker must not be able to lie. The scanner requires the comment
introducer to begin the physical line with
@adras the comment's first content, so prose discussing a decision, a string literal containing one, and a trailing} // @adr 0012are all rejected. Two further rules narrow that to lines the file's own format hides (ADR-0023, #101): a line inside a```or~~~fence is an example rather than a declaration, and in.md/.mdx/.markdownthe only introducers are<!--and{/*, because#and*are markdown's heading and bullet rather than comments. Both are line-lead rules, and both only ever remove a declaration — the one addition is{/*, which previously could not declare anywhere.pathconsequently selects the introducer set, so the pure scanner's result is no longer a function of the text alone. Truncation uses the byte countread.tsobserved rather than re-deriving it from decoded text, becauseTextDecoderdrops a BOM and expands invalid bytes, and a re-derived window can sever a reference mid-token and report a record the file never named.
Unlike the surfaces below, this is at rung 1 of ADR-0014 only — unit, contract, and purity coverage plus maintainer verification. No reference-repository run.
adr explain <path> --as-of <date|ref> answers which decisions governed a path
on a past date, under
ADR-0039
(accepted, part B of #116;
part A shipped in #187). A
valid-time window opens at a record's own date and closes at its immediate
successor's — not the terminal one, which would report a record in force for its
successor's whole tenure. No schema change.
Five things are load-bearing and easy to break:
- The as-of view has its own bucketing.
decisionBucketForsendssupersededtohistoryunconditionally, which is right in the present tense and exactly wrong here. Routing--as-ofthroughbucketDecisionsproduces a command that runs, passes its tests, and decorates rather than answers. Standings aregoverning,activeProposals,history,notYetRecorded, andundetermined, and windows are half-open so the successor owns its own start day. deprecatedisundetermined, never guessed.adr.schema.tsallowssupersededByonly onsuperseded, so adeprecatedrecord carries no close date anywhere in frontmatter. Treating it as "open fromdate" would assert it governed in a year it may not have.rejectedis history on every date, because it was in force on none. Both emit advisory findings with no exit-code authority.--as-ofre-dates the corpus, never the working tree.affectspatterns come from today's records and@adrmarkers from today's files. Reading file contents at a past ref needs rename tracking and a blob read, and is deliberately out of scope — it is the boundary #116's author named when deferring part B. The human view prints a note saying so, because the evidence lines are the one thing that is not re-dated; do not delete it as noise.- A marker accurate on that date is not stale.
resolveSourceMarkerstakes an optionalasOf, and suppressesstale-markerfor a record that was in force then. Without it, the same output tells you to fix a marker while reporting its record as governing. Absent the option, behavior is unchanged —adr checkand the CI Action never pass it. - The kernel is pure; git lives at the CLI boundary.
packages/core/src/temporal/**has no clock, no filesystem, and no subprocess — the date is always an argument.packages/cli/src/as-of.tsholds the only subprocess in@adrkit/cli, tries the date grammar before a git ref (so a tag named2026-03-01reads as a date), peels withrev-parse --verify <ref>^{commit}, and dates with the committer date (%cI, a stated choice). Inherited git config is a hazard on both sides: tests that shell out to git must setGIT_CONFIG_GLOBAL=/dev/null, because a developer withtag.gpgSign = trueglobally otherwise hangs the suite on a passphrase prompt; andgit showmust keep--no-show-signature, because a user withlog.showSignature = trueand a signed commit otherwise getsGood "git" signature for …prepended to stdout and--as-of HEADfails with a misleading "could not resolve". Both were found by running the code. - Shipped source must be Node-compatible.
packages/cli/src,packages/core/src, andpackages/evaluator/srcbuild with--target=node, andbun builddoes not shim theBunglobal — it emits the reference verbatim. ABun.spawnthere is aReferenceErrorin every published install while the whole Bun-run suite stays green.as-of.tsshipped exactly that once, behind acatchthat reported it as "git is not installed", so the subprocess wrapper usesnode:child_processand catches onlyENOENT.packages/cli/test/node-compatibility.test.tsenforces the rule; adapters underpackages/adapters/*are Bun-only and exempt, which is why copyingrunGitfrom one of them was wrong. - An inverted window is not an interval.
isInvertedWindowlives in@adrkit/coreand is used by both the kernel and the CLI renderer, so a window the kernel refuses to call governing is never printed asin force <opens> → <closes>. temporal-window-openis library-only. Asupersededrecord whose successor the corpus lacks is adangling-supersededByerror, which gatesadr explainbefore any temporal code runs. The finding is reachable throughresolveDecisionsAsOfas a library call and not through the CLI.
Additive: without the flag, stdout and --json are unchanged, and the
present-tense governing/activeProposals/history keys keep their meaning
beside the new asOf block. Rung 1–2 of ADR-0014: rung 1 is unit,
contract, purity and mutation coverage plus a Node-runtime smoke; rung 2 is
maintainer-owned isolated reference-repository validation in
adrkit-t018-dogfood
(fixtures exercising the window closing along a real supersession chain, a
deprecated and a rejected record, and a deliberately inverted-date pair;
self-verifying and fail-closed CI; see
mbeacom/adrkit-t018-dogfood#24
and its tracked evidence index). Not rung 3 — no external party has run
this.
Phase 6 ARB queue is
implemented under specs/007-arb-queue/ (see plan.md): the pure
buildQueueReport kernel and canonical JSON/Markdown formatters live in
@adrkit/core, the adr queue CLI subcommand ships in @adrkit/cli, and a
managed-issue queue Action lives in the private @adrkit/ci
(packages/ci/queue/action.yml, bundled to packages/ci/dist/queue-action.js).
Phase 6 is landed / reference-verified on rungs 1–2 of the
ADR-0014
evidence ladder — unit/contract/conformance plus maintainer-owned isolated
reference-repository validation (adrkit-t018-dogfood,
queue Action pinned at efef89b). It is not yet externally validated; the
rung-3 external/community signal is tracked honestly as open.
@adrkit/spec-kit is the first package under packages/adapters/* and the
second distribution surface
(ADR-0003). It adds three
namespaced commands to a Spec Kit project
— /speckit.adrkit.context, /speckit.adrkit.check, /speckit.adrkit.draft —
plus one optional after_plan hook that offers to run the check. Pinned to
Spec Kit >=0.13.0,<1.2.0, verified against 0.13.0, 0.14.4, 0.15.1, 0.16.5,
1.0.0, 1.0.4–1.0.6, 1.0.13, and 1.1.0–1.1.3.
Authorized by
ADR-0019.
Landed / reference-verified on ADR-0014 rungs 1–2; rung 3 open.
Things that are load-bearing and easy to break:
- It is versioned independently of the lockstep surface, per
ADR-0007 — its
semver contract is with Spec Kit, not with
@adrkit/core. It releases on its ownspec-kit-v<semver>tag (seedocs/RELEASING.md), currently 0.1.5, and does not move with the repository version. - Two version fields must agree:
package.json(npm) andextension.yml(Spec Kit). A test asserts they match — 0.1.1 shipped with them diverged and told every user the wrong version. - It ships no
distand declares no dependencies, not even dev ones.specify extension add --devcopies the directory verbatim, and a single declared dependency is enough for Bun's isolated linker to create anode_modules/here that then lands in someone else's repo or aborts their install.LICENSEandNOTICEare committed rather than generated for the same reason. Enforced bytest/packaging.test.ts. - The community catalog is discovery-only, so docs install from the archive.
specify extension add adrkitexits 1 on every Spec Kit version measured (0.13.0 to 1.1.3). The docs give--from .../releases/download/spec-kit-v<version>/adrkit.zip, which promptsContinue with installation? [y/N](no--yes;--forcedoes not skip it).releases/latest/downloadfollows the lockstep release and 404s, so the URL carries the version, andtest/manifest.test.tsfails if it disagrees withextension.yml. Bump the README and site URLs with the version. - The dotted ids are what the manifest declares, not always what users type.
Under Claude Code, and under Copilot from Spec Kit 0.16.5, Spec Kit registers
the commands as skills (
/speckit-adrkit-check). Theafter_planhook keepsspeckit.adrkit.check; the generated plan skill tells the agent to swap dots for hyphens. Do not rename the commands inextension.ymlto match. - Hooks can only reach commands that do not write.
draftis the only writing command and is unreachable from any hook, by test. packages/core,packages/cli, andschema/import nothing frompackages/adapters/*; CI enforces this, and the rule has been observed failing against a deliberately introduced violation (ADR-0016).
Emit the ARB operations queue — a read-only, deterministic projection of the local ADR corpus — to stdout:
adr queue [--dir docs/adr] [--as-of YYYY-MM-DD] [--format auto|terminal|markdown|json]--dir(defaultdocs/adr): ADR corpus directory.--as-of(default: today, UTC): UTC calendar date used for SLA state computation. Accepts a bareYYYY-MM-DDor an ISO datetime with an explicit timezone (e.g.2026-01-08T00:00:00Z); timezone-less datetimes are rejected.--format(defaultauto):terminalon a TTY, otherwisemarkdown; or an explicitterminal,markdown, orjson(QueueReport v1). Only a TTY ever gets the terminal view — under ADR-0044 (accepted), following ADR-0033 — so piped output is unchanged.
Exit codes: 0 = report with no corpus error findings; 1 = report emitted
(complete, to stdout) with one or more error-severity corpus findings; 2 =
usage error (invalid flag/value or unreachable corpus directory). Identical
inputs produce byte-for-byte identical output (SC-001).
Records corpus discovery cannot see — misnamed, or nested below the corpus root —
are reported as corpus.file-skipped corpus findings at warn severity, so a
proposed record never disappears from the queue silently. Being warn, they do
not change the exit code and do not fail the managed-issue Action.
adr accept <id> --by <identity> ratifies a proposed record, under
ADR-0044
(accepted). It is the third writing command, after new and migrate.
- It splices three fields and nothing else:
status,provenance.ratifiedBy, andreview.decidedAt. Ayamlround trip would reformat 39 of this corpus's 44 records, soacceptAdrSourceinpackages/core/src/transition/edits lines. It then re-parses the result and refuses unless every other field is semantically unchanged. That re-check caught a real ordering bug during development, so do not replace it with a serializer. --byis mandatory and never inferred. The clock is read inpackages/cli/src/accept.ts; the core transition is pure.- It refuses rather than overrides review state: a record that is not
proposed, has an unresolved objection, has fewer approvals than itsreview.quorum, or would fail validation is left untouched, with exit1. - No agent surface runs it. The agent plugin's wiring test fails if any
command, skill, or agent mentions
adr accept. The queue's terminal view may print the command for a human. It does so only when a dry run of the sameacceptAdrSourcetransition, made inqueue.ts, would succeed; otherwise it prints that refusal. Review state alone misses refusals such as an emptydeciders, so do not replace the dry run with a field check.
adr approve <id> --by <identity>, adr object <id> --by <identity> --summary <text>, and adr resolve <id> --objection <n> --by <identity> record review
state on a proposed record, under
ADR-0051
(proposed). They follow adr accept's design, and adr queue's approval
and objection counts change with them.
- They share accept's splice-and-verify kernel; do not fork it.
packages/core/src/transition/splice.tsholds the line-splice helpers andfinishSplice, the re-parse guard that refuses unless the new frontmatter equals exactly the intended data and still validates.review.tsandaccept.tsboth end in it. A copy of the guard in a fourth transition is how one of them would quietly lose it. - A flow list stays flow and a block list stays block.
approvals: []becomesapprovals: ["@bob"];objections: []grows a flow mapping. Turning either into a block would be a second, unowned layout change. A layout that cannot be spliced, such asreview: {…}, is refused with exit1. - Identities compare by ASCII casefold of the whole identity
(
packages/core/src/schema/identity.ts):@boband@Bobare one person. That governs the approve and object no-ops and the objector check, and it is whyacceptAdrSource's quorum check and the queue kernel'sapprovalCountcount distinct identities, a fix to ADR-0044's entry count. Before it, one reviewer approving under two spellings met a quorum of two. Do not go back toapprovals.lengthorincludesanywhere approvals are counted or matched. - Only the objector may resolve.
--bymust be the objection'sbyunder that fold. There is noresolvedByfield and no chair override; both need a schema change and are future work in ADR-0051. Do not add either here. - Repeats are no-ops, exit
0with no write: an identity that already approved, the same open objection (samebyand summary), an objection that is already resolved. The core result sayschanged: falseand the CLI skipswriteFile. - A summary is one line, written double-quoted. Trimmed, non-empty, at most
500 code points after trimming, no control character (
\p{Cc}), no invisible format character (\p{Cf}: bidi overrides, zero-width characters, the BOM), no U+2028 or U+2029.--bygets the same control and format check in all four ratifying and review commands, because the schema's email branch admits both, except ZWNJ and ZWJ inside an email. The CLI checks these as usage errors (exit2) and core refuses them independently; a repeated--byis a usage error too, inadr acceptas well. - An insertion never backs up into the value above it.
backUpOverTriviatakes a floor at the end of that value, so a literal block's# …line and a keep-chomped scalar's trailing blank lines stay inside their scalar, andresolvescans back to an item's-when it stands alone on its line. - The record must be
proposed, for all three.--byis mandatory and never inferred. A BOM-prefixed record is already invalid toadr lint(parseFrontmatterrequires a leading---), so these refuse it, asadr acceptdoes. adr acceptand the three review commands find their record throughpackages/cli/src/record-target.ts, which accept's lookup moved into with its messages unchanged.- No agent surface runs them, and the guard checks code, not only prose.
The agent plugin's wiring test fails if a command, skill, agent, or any
extension module, the canvas included, names
accept,approve,object, orresolveas a CLI call: as any quoted array element or call argument ([cli, 'approve', id],args.push('approve')), as a quoted value assigned to a name, as a tool name (adr_approve), or afteradr,$ADRKIT_CLI,@adrkit/cli, or a${…}placeholder, in any case and across invisible characters. JSON-schema type lists such as['object', 'null']are dropped first, so they never match. A prose-only pattern was green while an extension spawning['approve', …]passed it. The Spec Kit extension (plus itsadrkit_cliwrapper) and the MCP server (plus any import of a writing transition) have the same guard. The canvas'sactionsare model-callable, so they stay guarded. The one exception is the decision board's page-POST module,board-review-write.mjs(ADR-0052, proposed), which takes the identity fromADRKIT_REVIEWERand writes only after the host's confirmation, because the model can reach its routes over HTTP; see "Recording review from the board" below. The extension tools'redactWritingCommandsscrubs these verbs from tool results too, because a record title can carryadr approve …to the model. - The read, transition, write window is not locked. Two of these commands,
or one and
adr accept, run at the same instant on the same file can lose an update. That is accepted in ADR-0051: they are human-run edits to a working tree, and the pull request diff is where the result is checked.
Normal lockstep releases publish npm and create a draft GitHub release. A human
publishes that draft with the Marketplace selection; only the resulting
release: published finalization moves the lightweight major Action tag (v0)
and starts container publication.
.github/workflows/action-tag-recovery.yml is the explicit backward path. Run it
from main with an existing stable vX.Y.Z release tag. It requires an annotated
tag that peels to a commit on main, an exact successful Release run, matching
root version, and both committed nested Action bundles. It deliberately permits
pre-Marketplace releases so the first compatible release does not remove the
last known-good target for nested @v0 consumers. For the same reason the
third bundle, dist/portal-action.js, is required only where
packages/ci/portal/action.yml exists at the target, so pre-portal releases
stay eligible; never make that check unconditional. It shares the release
concurrency group, holds only actions: read and contents: write, and pushes
with a lease against the observed remote tag object.
Recovery also records a durable action-recovery-block/<commit> tag for the
commit removed from v0; the normal release workflow rejects a rerun of that
commit before npm publication. A context-validation job fails dispatches from
another repository or ref instead of leaving a skipped workflow green.
Moving v0 stops future jobs from resolving a bad release; it does not undo an
already-edited PR comment or change a job that already resolved the old SHA.
Restore comment content from GitHub's edit history or rerun the known-good Action.
The full preferred and manual fallback runbook is in docs/RELEASING.md.
It also does not contain an immutable root ref copied from Marketplace: unpublish
the bad listing, warn pinned consumers, and publish a higher hotfix.
The adrkit plugin is the fourth distribution surface and the one that reaches
GitHub Copilot CLI, Claude Code, opencode, and Agent Package Manager. It ships
two skills (decision-memory, decision-backfill), one read-only subagent
(decision-checker), and five commands (/adr-context, /adr-check,
/adr-draft, /adr-queue, /adr-backfill), all of which drive or reconcile
through the adr CLI. Since 0.4.0 it also ships one GitHub Copilot CLI dynamic
workflow, adr-review (extensions/adrkit/), authorized by
ADR-0045
(accepted). claude plugin validate passes with it present; APM 0.33.0
installs it into apm_modules and deploys it to no target (its one warning,
Unrecognized plugin manifest $schema, predates the workflow); a
native opencode load is unmeasured. Independently versioned per ADR-0007, not
published to npm, and catalogued from the repository root's
.claude-plugin/marketplace.json. Authorized by
ADR-0028
and its accepted backfill amendment,
ADR-0034.
Rung 1 only — unit and contract coverage plus maintainer verification
against the installed hosts, including a functional exercise in an ephemeral
consumer repository. The v0.4.0 adr-review workflow adds unit and contract
tests and a maintainer live smoke on Copilot CLI 1.0.92 (two subagents, both
planted conflicts found, worktree clean). It also has a repeat through the
published GitHub install on 1.0.93, plus a deletion-only run. Since 0.5.0 it
also ships one read-only Copilot app canvas, decision-review, in the same
extension (a second, decision-board, is described under its own heading
below), authorized by
ADR-0046
(accepted). The canvas is rung 1: unit and contract tests plus a
headless Copilot CLI 1.0.93 SDK-host smoke (open, state, refresh, and one
run_review at about 0.16 AI credits), plus maintainer sessions in Copilot app
1.1.27 where the panel rendered in the app's theme and a review started from it
came back as findings. Earlier
Copilot CLI versions and a native opencode load are unverified.
No persistent reference-repository run, no external validation. Scope and
limitations:
docs/reference-verification-agent-plugin.md.
That functional evidence covers the v0.1.0 context/check/draft/queue baseline.
The v0.2.0 backfill skill and command are contract- and static-host-validated.
A fresh Copilot synthetic-consumer run produced the expected covered/history/new
classification and a complete handoff without changing the worktree. For the
v0.3.1 bootstrap-record offer, detection is measured against synthetic corpora
(missing, empty, source-only, process-record-present, unmigrated MADR, and — added
after 0.3.0 review — proposed, rejected, and mixed-validity); host surfacing
behavior and the offer's write path end to end are both unverified. No persistent reference-repository or
external run exists.
Things that are load-bearing and easy to break — each measured against the real hosts rather than read off their docs, so a change that "looks more correct" will usually be a regression:
- The manifest declares no component paths.
agents,skills, andcommandsare documented Copilot CLI fields, butclaude plugin validaterejects the string form outright (commands: Invalid input). Both hosts discover the conventional directories without them.categorybelongs to the marketplace entry, not the plugin manifest. - The manifest declares no
extensionskey either. Measured on Copilot CLI 1.0.93: a non-stringextensionsvalue (three objects and an array were tried, notnull, booleans or numbers; the string form loads) in.claude-plugin/plugin.jsonstops the plugin's extension from loading, so the workflow and canvas vanish whileplugins.liststill shows the plugin enabled.claude plugin validateonly warns and APM accepts it, so both validators pass a change that removes the shipped surface. That rules out the Agent Plugins 1.0extensions["com.github.copilot"].logolisting key; seedocs/reference-verification-agent-plugin.md. - No component declares a
toolslist. Claude Code takes a comma-separated string of capitalized names, Copilot CLI an array of lowercase ones, and opencode requires a name-to-boolean mapping and rejects the agent at load time when handed a list. The read-only contract lives in the agent body instead. - The plugin deliberately ships no
.mcp.json. Copilot CLI spawns a plugin's MCP servers with a working directory that is neither the workspace nor any Git repository, and exports nothing naming the repository. The adrkit server requires a Git worktree root, so it exits duringinitializeand logsFailed to start MCP client for adrkitevery session. MCP is wired per project, where the working directory is correct. Do not "fix" this by adding the file back. - The subagent must resolve the CLI properly.
@adrkit/cliis normally a dev dependency, so a bareadris not onPATH. An agent that tries only that concludes "no CLI available" and falls back to reading ADR frontmatter by hand — which cannot expand glob matchers, cannot read inbound@adrmarkers, and has no exit code, so it produces an answer that looks complete and is not. Measured, then fixed: both the skill and the agent now state$ADRKIT_CLI→./node_modules/.bin/adr→PATH, and a test enforces it. - Every version-bearing surface must agree:
.claude-plugin/plugin.json,apm.yml,package.json,bun.lock, marketplace metadata and entry, and every skill's metadata. Claude Code keys its plugin cache onversion. - Backfill discovery is read-only evidence triage. Code proves current
state, not intent or ratification.
/adr-backfillproduces a coverage ledger and candidates; only/adr-draftmay create oneproposedrecord after a human selects it. Statusless evidence never becomesacceptedautomatically. - Repository content and local executables are trust boundaries. Backfill treats source text as untrusted data, stays inside the worktree, enforces explicit scan caps, and requires confirmation before running a CLI resolved inside an inherited repository.
- The bootstrap record is an offer, not a candidate. A repository with no
corpus is missing the process decision (keep decisions in git) and the tooling
decision (enforce them with adrkit). Nothing proves a human ratified either,
so it stays out of the candidates table and out of every
backfillHandoff— it governs the corpus directory, a glob, and could never supply concretecandidatePaths— and routes to plain/adr-draft. Adopting adrkit is never a supersession of the decision to record decisions;supersedesis reserved for a prior tooling record, and a MADR corpus is migrated, not superseded. Detection reads the exit code before the buckets, and reads all three buckets —governingholdsacceptedalone, soactiveProposals(draft/proposed) andhistory(rejected/superseded/deprecated) are where a record already settling the question actually sits. Readinggoverningby itself makes the offer re-propose its own output, because/adr-draftwritesproposed. The corpus-wide gate isadr lint, notadr check, whose exit code is path-scoped: a malformed record elsewhere leavesadr checkat exit0with an empty result. Both the skill andcommands/adr-backfill.mdcarry this, because/adr-backfillloads the command (ADR-0038). - The offered record must bind the corpus directory.
adr newscaffoldsaffects: [], and a record that binds nothing is invisible to the detection above however many buckets are read — so the offer states the matcher. Found by the first functional run of the write path, not by review; it is the failure ADR-0038 already listed under "how we would know this was wrong". /adr-draftcan write into a repository with no corpus. Itsadr lintgate stops on exit2except when the corpus directory does not exist yet:adr newcreates it and allocates0001, so the bootstrap offer's own headline case is writable. A corpus that exists and does not parse is still a hard stop.- The
adr-reviewworkflow lives at the plugin rootextensions/. For a.claude-plugin/plugin.jsonplugin Copilot reads<plugin-root>/extensions/<dir>/extension.mjs;com.github.copilot/extensions/is for Agent Plugins 1.0 manifests and was not found. Exactly one copy. - The workflow's exit code is always 0, so
result.statusis the gate.copilot workflow runexits 0 on success, a thrown error, invalid arguments, and an unknown name. The workflow is advisory, and the single gating rule is: the run's status iscompletedandresult.statusis"ok".statusisok|findings|incomplete|usage-error, with precedence usage-error > findings > incomplete > ok;checkExitCode,lintExitCode,verdicts, andunverifiedare detail, not the gate. A missing judgment makes the runincomplete, and an unresolvable explicitbase, or an unresolvedorigin/mainover a clean working tree, is ausage-error— neverok. A non-zeroadrexit is data, never a throw, or the run settles with no result and still exits 0. - The agent name is namespaced.
ctx.agentmust useadrkit:decision-checker; the bare name resolves tonullwithout throwing, so every null is reported underunverified, never dropped. - The CLI is chosen by the environment only, and a repo-local one is gated.
$ADRKIT_CLI, then./node_modules/.bin/adronly whenADRKIT_ALLOW_REPO_CLI=1, thenPATH.cliandallowRepoCliare deliberately not workflow arguments: a model that read untrusted repository content can choose arguments, and extension code runs outside Copilot's permission prompts. Unknown arguments are ausage-error. Do not add them back. - One extension registers both the workflow and the canvas, and each
registration is guarded.
register.mjsbuilds each in its owntryand logs a failure throughsession.log. Measured: one invalid workflow definition (nometa.phases) throws at import and took the whole extension down, canvas included. A throwing canvas must likewise not stop the workflow. - The canvas server starts lazily inside
open(). The app launched 181 extension loads within minutes of starting, one process per restored session; a server bound at load would bind a port in every one. A test imports the modules under Node and asserts no socket opens. - The panel is hardened because ADR titles are untrusted. Every route
checks a per-panel token before routing (403 otherwise), a POST needs the
token as a header and a same-origin
Origin, the CSP isdefault-src 'none'with'self'scripts and styles, and the page builds its DOM withtextContentonly. Do not addinnerHTML, an inline script or style, or a looser CSP; tests fail on each. refreshcosts nothing andrun_reviewspends credits.refreshrunsgit diff,adr check, andadr lint;run_reviewstartsadr-review, onedecision-checkercall per governing decision. Invalidrun_reviewarguments throwinvalid_inputbefore anything is spent. The explain affordance is an HTTP route the page uses, not an agent action.- The canvas takes its directory from
ctx.session.workingDirectory. That is the documented source. The app's runtime process runs from/, but each extension process it forks starts in its session's directory: a probe in an app session measuredprocess.cwd()equal tosessionWorkingDirectory, both the app's session worktree. The workflow starts from thatprocess.cwd()and then followssession.context_changed(session-dir.mjs, below), which is whyrun_reviewreviews the right repository in the app. If a later runtime breaks that equality at fork, the workflow must take the session directory too. - The default change is a union, not the committed range. Through 0.9.0
it was
git diff <base>...HEADalone whenever that resolved, so an app session (a fresh worktree off the default branch) or any checkout onmainshowed0 changed file(s)and statusokuntil its edits were committed: measured on a copy of the dogfood repository, whereadr checkon the same three edited paths found 3 governing records. Since 0.9.1collectChangedFilesunions the branch range,git diff HEAD(staged, unstaged, deletions), andgit ls-files --others --exclude-standard --full-name -- :/(untracked, not ignored). Keep--full-name -- :/: plainls-filesis cwd-relative and cwd-scoped whilegit diff --name-onlyis neither, so a subdirectory session would mix two path conventions. The workflow, both review surfaces, theadr_checktool, and the session-start hook all go through it. - Read
sourcethroughreview.mjs's helpers, never by string. Labels areargs,git:<base>...HEAD+worktree, andgit:worktree(the fallback when origin/main does not resolve, and the only partial one).isPartialSource,listCommandsFor, anddescribeSourcekeep the workflow, the canvas, the hook summary, and the Judge prompt in agreement; asource.replace(...)in the hook printedgit diff origin/main...HEAD+worktreeas prose before the helper existed. The Judge is told to read an uncommitted edit withgit diff HEAD -- <path>and an untracked file whole, because both have an empty range diff. git rev-parse --is-inside-work-treeruns before any listing. A directory git does not treat as a work tree makesgit diffprint its--no-indexusage (the Windows report), and so does git's ownership check (measured withGIT_TEST_ASSUME_DIFFERENT_OWNER=1). Exit non-zero or output other thantrue(.gitprintsfalse, exit 0) isnot-work-tree; "dubious ownership" in stderr isgit-unsafe-directory. stderr is compared, never shown. A queue that exits 2 where the corpus directory does not exist says so instead of "exited 2". The Windows cause is unconfirmed.show_reviewnever replaces a run the panel started. In the app, a finished panel run is surfaced to the agent, which then handed the same result back and relabelled it "supplied by the agent". Measured, then fixed; do not loosen the guard. It also refuses a result whose status is cleaner than its own payload, or whose files or governing records are not the panel's, and a refresh drops a shown review once any changed file's size or modification time moves. File names alone miss an edit inside the set.- Canvases render only in the Copilot app. A CLI terminal session has no canvas renderer, so the agent gets no canvas tools there. Do not describe the canvas as available in the CLI.
- Canvas provenance shows only what
adr checkreports (ADR-0047, proposed). Measured on a fixture:declaredBynames the changed file and line of an inbound marker, but anaffectsmatch carries only{ type, pattern }, with no file. The page says so instead of matching globs itself; the extension cannot import core, and a homemade matcher could disagree with the CLI and present that as provenance. If core gains per-file attribution, read it. judgeCallsfollows the Judge, which isctx.pipeline(governing, … ctx.agent(…))inreview.mjs: at most onedecision-checkercall per governing decision. It is the governing count whenadr checkandadr lintexit 0 or 1, and 0 otherwise, because the workflow then skips the Judge. Change the workflow's Judge shape and the button label andrun_reviewdescription go stale. The button is disabled wheneverjudgeCallsis 0, and says why.- The canvas queue is an allowlist, and that is what keeps a ratifying
field out.
adr queue's terminal view prints that command for a human; the canvas reads only the JSON and keeps nine named item fields, so a field added later is dropped unnamed. That selects fields, not text: a kept string such astitleis untrusted repository text shown as data, and is not sanitized for command-like wording. A test plants such fields and checks the snapshot,/api/state, the rendered page, and the shipped page strings. Queue rows have no control or explain, and their ids are not explainable. A queue failure is a fixed note, never CLI stderr, and never changes the panel's status, governing list, or notes. The queue starts beside the check with its own timeout (30 s, then its process is signalled). The check commits and broadcasts when it settles and the queue follows in a second update, so a hungadr queuecannot hold the governing view; opening a panel and the page's routes (GET and POST) do not wait for it, nor doesrun_review's pre-run refresh; only agentget_state/refreshdo. Its strings, anddeclaredBypaths, are clipped like CLI messages, and the rows share a 256 KiB (UTF-8 bytes) serialized budget, before every broadcast. - The extension also registers two advisory session hooks
(
hooks.mjs, ADR-0049, proposed):onSessionStartadds a governing-decisions summary;onPostToolUse, after an edit, names the accepted decision(s) governing the file just edited and refreshes opendecision-reviewpanels. Their registration is guarded like the other two, andADRKIT_HOOKS=0makes the factory returnundefined, so nohookskey is joined at all. - There is no
onPreToolUse, and do not add one. Measured on Copilot CLI 1.0.93 throughsession.rpc.tools.execute: a pre-tool hook that never answers holds the tool call unexecuted (still pending at 90 s), while a post-tool hook that hangs lets the edit land and only holds the result. The CLI changelog also records versions where a pre-tool hook error denies the call. A pre-tool hook makes extension liveness a gate, which ADR-0022 denies to an advisory. A crashed extension failed open in both hooks there. - A hook returns
additionalContextand nothing else. NopermissionDecision(the SDK's own example returns"allow", which would override a person'sask), nomodifiedArgs,modifiedResult, orsuppressOutput. A test asserts the key set and was observed failing against an"allow"mutation. - Hook context carries ids, never text. Record ids are checked against a
record's own id grammar (
adr.schema.tsid: 4+ digits or a ULID, no namespace, because a namespace segment is free text) and statuses against a fixed set; anything else is skipped silently. Titles, paths, and error messages never reach it, because the model reads hook context as instructions. session.logis fire-and-forget in hooks; neverawait fail(...). It is an RPC with no deadline: awaiting it let a never-answering log holdonSessionStartpast its 5 s deadline indefinitely (found in review), pin a failed cached check, and wedge the single-flight refresh.failguards both a synchronous throw and a rejected log; removing either guard fails a test (one of them a Node child-process test for an unhandled rejection that would kill the extension).onSessionStartfires with the first prompt, not at load. Measured on 1.0.93 (SDK host): a plugin extension joins aftersession.start; with no prompt no hook fires, even on resume. With a prompt it fires afteronUserPromptSubmittedwithsource: "new". It races a 5 s deadline, because its sequential calls (four git calls since 0.9.1, then oneadr checkper batch of a wide diff) could otherwise hold the first prompt for 15 s or more. When the deadline wins, the hook aborts the call in flight and starts no further batch: nobody reads that summary, and a 40,000-path diff would otherwise keep one hook slot busy for minutes (found in review; a counting test pins it).- The edit tools are the ones the runtime classifies as edits:
editandcreate({ path }, absolute in session logs),str_replace(edit's shape),str_replace_editoronly whencommandiscreate,str_replace, orinsert(itsviewreads; both from the bundle, unobserved in logs), andapply_patch, whosetoolArgsis the raw patch string (measured withgpt-6-luna), parsed for*** Add/Update/Delete File:and*** Move to:and cut at 20 paths while parsing. A renamed tool turns the note off silently. Paths are made relative to the hook input'sworkingDirectory, neverprocess.cwd(). - Hook cost is capped, and the caps are tested.
- Non-edit tools return before any I/O.
- At most two hook-spawned processes run at once, each with
AbortSignal.timeout(5000). - There is one
adr checkper distinct path per process, shared by concurrent edits and cached even when it fails, except when a signal, a timeout, or an abort ended it: that says nothing about the file, so the next edit checks again (a CLI that cannot start stays cached, so it is not retried on every edit). The 500-check budget is a monotonic count, not the cache size, because a corpus edit clears the cache and must not re-arm the budget. - The post-edit note gives up after 2 s and the check keeps filling the cache.
- The hook-triggered canvas refresh is single-flight: one in flight, at most
one queued, under one 15 s abort signal passed through
refreshOpeninto the canvas'srefresh. That signal bounds theadr queueread too, combined with the queue's own timeout rather than replacing it, and the hook's refresh waits for the queue so single-flight stays true. When the signal fires, the panel keeps its previous result and queue under fixed timeout notes; an aborted refresh never commits the abort's exception text as a usage error. - The debounce timer is
unref'd. - A timeout or abort ends the whole process tree on POSIX. The shared
runCommandinreview.mjs(the one runner the workflow, canvas, tools, and hooks all use; there are no copies) spawns with stdin ignored anddetached, then signals the group once: SIGTERM, then SIGKILL after an unref'd 1 s grace, skipped if a signal-0 probe already shows the group gone. Signalling only the child left a grandchild behind a version-manager shim running (shown against the 0.8.0 runner with a shell wrapper and with a node shim; Node-run tests now assert the grandchild is gone). A descendant that callssetsiditself leaves the group and is out of reach. - Detached groups must not outlive the extension, and
detachedalone made them do so: measured on 1.0.93 in a headless SDK host, both a SIGTERM to the extension process and a plaindisconnect+client.stop()left the child and its grandchild running before the fix, and ended both after it. So a group stays tracked until a probe says it is gone (not merely until its leader closes; at most 10 probes, 1 s apart, then it is dropped so a lingering member or an EPERM probe cannot keep the listeners forever), anexitlistener SIGKILLs tracked groups, and while any group is tracked, listeners for SIGTERM, SIGINT, and SIGHUP do the same, remove themselves, and re-raise the signal so its default action still ends the process. With no group tracked no listener exists, so the extension's own signal behavior is unchanged; if the host added its own listener for that signal, the signal is not re-raised. - Every command has a 120 s ceiling (
COMMAND_CEILING_MS). The callers' own limits are shorter, so it only bounds calls that had none: the workflow, the page's own refresh, and the tools. It ends the group and rejects with aTimeoutError. - On Windows the signal goes to
spawn, which ends the direct child only: a stated limit, not fixed.
- One join retry ladder serves the hooks and the tools: without
hooks, withouttools, without both, withoutcanvasesalone, then the workflow alone (at most six joins;onEventis never dropped). It rethrows the original error if every rung fails, and logs exactly the fields the successful join dropped. A runtime that refuseshooksmust not cost the workflow, the canvas, or the tools, and one that refuses the tools must not cost the hooks. Becausehooksgoes first, a one-off unrelated join error turns the hooks off for the session; that is accepted, and the log line says so instead of blaming them. ADRKIT_*variables reach the extension withoutrequestedEnvironmentVariables. Measured on 1.0.93 through the SDK host, including a variable whose name ends in_SECRET_TOKEN. Its arrival in the app, and hook firing in the app, an interactive CLI session, and subagent child sessions, are unmeasured.copilot plugin installprints only a skill count. Version 0.11.0 should report two skills; that does not inventory the agent or commands — verify them in a fresh session.
The same extension registers three read-only tools through
joinSession({ tools }), proposed in
ADR-0048
(proposed). They amend ADR-0028's inventory without reversing it: the plugin
still ships no .mcp.json. Rung 1: unit and contract tests plus headless
Copilot CLI 1.0.93 measurements with no model calls; unmeasured in the Copilot
app. Measured, and easy to break:
- A tool invocation carries no directory, and
process.cwd()goes stale. Aftermetadata.setWorkingDirectory(what/cduses) the extension is not restarted and itsprocess.cwd()does not move, but it receivessession.context_changedwith the newcwd. The tools track that event, throughjoinSession'sonEventso a change during the join is kept; do not "simplify" them back toprocess.cwd(). The workflow reads the same tracker (session-dir.mjs) when each run starts, throughcreateReviewWorkflow, so a review after/cdreviews the new directory; a tracker value captured at load would be stale too. - A bad tool definition refuses the whole join. A name outside
/^[a-zA-Z0-9_-]+$/made the runtime rejectjoinSession, workflow and canvas included, soregister.mjsretries without the tools on the shared ladder described under the hooks above, keeping the workflow always. A name that collides with a built-in joins but breakstools.initializeAndValidate()for the whole session, so never name a tool after a built-in. - The executable is chosen by the environment only, through the workflow's
resolveCli. No tool argument selects it, and unknown keys are refused. - Arguments are validated in the extension, because the host does not enforce
the schema: relative paths only, no
.., no leading-, no control characters, at most 200 paths of 1024 characters, and a conservativebase(isSafeBaseRef:^[A-Za-z0-9._/@{}~^-]+$, no leading-,..only in a...range), shared with the workflow'svalidateArgsbecausebaseis repeated in the Judge prompt's commands. The corpus directory taken from the argument or the defaultdocs/adrisrealpath-checked against the session root before spawning, so a committed symlink out of the worktree is refused (symlink-escape); a user-setADRKIT_DIRis trusted, and the checked value is the value passed as--dir. - Every
adr checkis batched, and every echoed file list is capped. The CLI takes paths as arguments only (no stdin or file list), so a wide diff can exceed Windows' ~32 KiB command line.checkInBatchessplits paths at about 24 KiB of argv and merges the reports (decisions by record id, sorted; findings deduplicated and sorted with core'ssortFindingskey, restated and pinned against core by a test; highest exit;markerScandropped, because per-call scan counts do not add up;batches: Nadded). One batch returns the CLI's result untouched, sobatchesmarks the merged shape. ADR-0022's declaration caps apply per call. Echoed lists stop at 200 paths with afilesOmittedcount and, in the workflow result, afilesDigest(SHA-256 of the full sorted list). The canvas keeps the full list in memory and compares a capped result withsameFileSet, by digest;sanitizeReviewResultrefuses a capped result with no digest or with other than exactly 200 paths, andsameFileSetrequires the canonical capped prefix even when the digest matches; comparing a capped result with the full list would drop every wide-change review as stale (pinned intest/batching.test.ts). - A capped Judge prompt must say how to see the rest. A bare
git diff --name-onlyprints nothing for committed work, so a Judge shown 200 unrelated paths answered from what it could see (found in review). The prompt lists the paths whose markers declared the decision first, then names the range the run collected (git diff --name-only <base>...HEAD, orHEADin the fallback); for explicitfilesit says the caller supplied the list, with its count, and the run isincompleteat best, because no git command reproduces an explicit list and a clean verdict would cover files nobody inspected (found by Copilot review on #272). One test per mode pins the hint. - Results never carry exception text or a writing command. Rejections and
spawn failures return fixed messages chosen by code (CodeQL
js/stack-trace-exposure). CLI stderr is returned only on exit2, capped and without stack-frame lines, because a crash's stderr is a stack. The workflow result, the canvas's notes,/api/state, and its agent results follow the same rule since 0.8.1: every error is aReviewErroror is mapped bypublicMessagefrom itscodeandsignalalone,validateArgsno longer echoes the value it refused, and a run's ownerror/reasonis never shown.test/error-text.test.tsplants a sentinel in stderr and in thrown errors and asserts it reaches none of those outputs. Every string is scrubbed before serialization, matching any whitespace or format character betweenadrand the subcommand: scrubbing the JSON text missedadr\naccept(found in review). A non-zeroadrexit with a report issuccesswith itsexitCode. - Commands and skills do not mention the tools. Claude Code and opencode never load extensions; the CLI path stays the portable one.
A second canvas in the same extension maps the whole corpus from
adr graph --format json and adr queue --format json, proposed in
ADR-0050 (proposed; amends 0046 and 0047). Code in
board.mjs, board-page.mjs, and board-layout.mjs; the HTTP hardening both
canvases share is in panel-http.mjs. Rung 1: unit and contract tests plus
a headless Copilot CLI 1.0.93 SDK-host smoke with no model calls; unmeasured
in the Copilot app. Load-bearing:
- Its actions are read-only; writes need the host's confirmation. No
action writes, starts a workflow, sends a prompt, or spends anything. The
factory uses the session only for the review controls below (the host
confirmation, the agent mode, and the log), and a test fails on any
rpc.workflowor.send(. The review routes are reachable by anything holding the panel token, the model included, so every write waits for the host's confirmation (ADR-0052). There is no ratify control. - It shows no readiness verdict. A queue row shows approvals against
quorum, objection counts, SLA state, deadline, routing, and a finding count,
and nothing derived from them. Review state misses refusals the
acceptAdrSourcedry run catches (an emptydeciders), so a "ready" label would be a claim the board cannot back. A test fails onreadyin the page. - Filters are the CLI's. A focus or kind filter re-runs
adr graph --focus <id> --kind <kind>; do not filter or walk the graph in JS, or the board can disagree withadr graph. The id is checked against a record's own grammar (and refused past 64 characters, never clipped) and each kind against the three, before any spawn. The action schemas carry the same id pattern and the kinds enum, and the runtime was measured enforcing both before the handler runs; the extension's check stays the authority. - State is per panel; the queue is shared. Filter, corpus directory, and
graph are keyed by panel id, so a focus in one board never moves another
(review M2, measured both ways). The queue is keyed by working directory and
corpus directory. Do not move the filter back to the working directory. A
new
diris committed with its graph (graphDir), and the queue shown is the graph's directory's, so a faster queue never pairs with the old graph. - A snapshot reports the filter its graph was read with.
computeGraphstampsgraph.filter, andsnapshotOfreads it, never the pending request (review M1). Only the newest graph read is applied; an overtaken focus returnssuperseded: truewith the current, consistent state. Tests with a gated fake CLI fail if either sequence guard (graph or queue) is removed. - A model-chosen
diris confined. It must resolve inside the panel's own repository (on a re-open, the panel's, not the session's current directory) lexically and throughstaysInside(tools.mjs), or the action is refused with a fixed message before any spawn. A remembereddiris re-checked right before every graph and queue spawn; one that now escapes gets a fixed note and nothing runs.ADRKIT_DIRis trusted. decision-review'sdiris not confined; that predates the board. - Allowlists and budgets, as in ADR-0047. Node
id/title/status, edgefrom/to/kind, and the queue row's nine fields plusresolvedObjectionCountanditemFindingCount; everything else is dropped unnamed. Titles clip at 200 characters. At most 300 records (past that, counts by status), 1000 relationships, 200 queue rows, and 512 KiB per snapshot byBuffer.byteLength(graph to summary first, then queue rows), on every route, action result, and event-stream frame (a test reads the frames). A drawing past 20,000 px a side also becomes the summary. Each CLI call has a 30 s timeout. A failure is a fixed note fromBOARD_NOTES, never stderr or exception text. - The layout runs on the server, in a pure module.
board-layout.mjshas no clock, file, or process; columns come fromsupersedesalone (from = successor, to = replaced, asbuildAdrGraphemits them), ties break by id, and a cycle terminates. The page only draws coordinates. - SVG classes go through
setAttribute('class', …). An SVG element'sclassNameis anSVGAnimatedString; assigning a string does nothing in a browser while a fake DOM accepts it, so a test checks the fake'sclassNamestays empty. No inline style either: status and edge styles are CSS classes. - It is a second entry in
canvases, in its own guardedtry. A throwing board, or acreateCanvasthat refuses it, costs neither decision-review, the workflow, the tools, nor the hooks.LABELS.canvasesstays "decision-review canvas" when the board is absent, because existing tests assert that text; with both present, the failure log names both. Known limit: the join ladder has no board-only rung, so a join the runtime refuses because of the board drops decision-review too (ADR-0050). - The post-edit hook does not refresh boards. Adding a graph and queue read under the hooks' 15 s single-flight signal was left out on purpose.
Each open-proposal row has approve, object, and resolve controls, proposed in
ADR-0052
(proposed; amends ADR-0050, implements the plugin half of ADR-0051). They
run adr approve, adr object, and adr resolve and need @adrkit/cli
0.18.0 or later. Rung 1: unit, contract, and mutation tests, an
end-to-end test against the built CLI, and headless SDK-host measurements with
no model calls. In the Copilot app the maintainer ran Approve once (runtime
1.0.94-3): the dialog appeared, a decline wrote nothing, an accept wrote, and a
click within 10 s was rate-limited. Object, Resolve, and the app's autonomous
modes are unmeasured there. Load-bearing:
- The model holds the board's URL and token. The runtime's
open_canvastool returns the instance URL, token included, to the model, and the model can open its own instance. With a shell it can call every route the page calls, with any headers (measured by review: an approval with no click). So nothing on the page or in the HTTP layer is the boundary. - The boundary is the host's confirmation. The write route calls
session.ui.confirm(...)before spawning and writes only on a literaltruewithin two minutes; a throw, a timeout, or any other answer writes nothing. Withoutcapabilities.ui.elicitation === truethe controls are off with a fixed note and both routes refuse. In agent modeautopilot, or whenrpc.mode.getthrows or does not answer within 5 s, the route refuses before asking (a never-answering RPC must not hold the single-flight flag); a session with no mode method is still asked. The shown corpus directory is confined again after the confirmation, right before the spawn, because the dialog can take two minutes. Measured: the runtime routes an extension'sconfirmto the host's handler in every permission and agent mode, and answers nothing on its own; the CLI's terminal UI declines elicitations in autopilot (read from its bundle). The Copilot app offered elicitation and showed the dialog for an approval (measured once). Whether it ever answers without a person is unmeasured; ADR-0052 names that as a residual risk the maintainer must accept or measure. Do not remove the confirmation, accept a truthy non-trueanswer, or put the summary or a title in the dialog: the dialog text is fixed text, the kind, the validated id, the identity, and numbers only. The confirmation guards the board's write path only: a model with a shell can still runadr approve --by=…itself, edit frontmatter, or edit the extension, and the pull request diff is the backstop. - A staged dialog must not pass for the person's own (re-review R1-M1).
The dialog's first line is the record and the action
(
ADR-0042 · approve as @x). A write that arrives while one is pending gets a 409 naming the pending kind and record and saying to decline it unless the person started it, shown on the page as a warning. The board may ask at most once per 10 s and five times per sliding 10 minutes, counted across every board panel in the extension process; over that it is a 429 with nothing asked or spawned, and the refusal is logged. Do not make the budget per panel again: the model can open panels, and each would get a fresh budget (a test opens a second panel and reopens one). - The identity is
ADRKIT_REVIEWER, read on every request, never taken from the page, the model, or an argument, and checked by a mirror of core'sisWritableIdentitythat a test compares with core. A body with any extra key (byamong them) is a 400. It goes to the CLI as one--by=<identity>element, because an email identity may start with-. Action results carryreview: { enabled, note }only; the identity appears only in the page's own HTTP state. - The nonce and the two clicks are defence in depth, not the boundary. The
first click asks
POST /api/review/noncefor a nonce bound to the kind and record; the second, a Confirm button placed after Cancel so a double click cannot hit it, spends it. Single use, two minutes, dropped on close, never in an action result. A token holder (the model included) can mint and spend one as the page does, and can also spend the panel's live nonce to cancel a person's armed Confirm (denial of service only; the person re-arms). - The review routes need an exact same-origin
Origin(none is refused) andSec-Fetch-Site: same-originwhen sent, throughsameOriginPostinpanel-http.mjs. That stops naive scripts and other pages only; curl can send both. - Exactly one module is exempt from the verb guard.
board-review-write.mjsmay name the three review verbs, and neveraccept.wiring.test.tspins the exemption list to that one path. Onlyboard.mjsnames the module, once, as a static import it does not re-export; a test fails on any other mention (static, dynamicimport(),require, a path variant) and on a re-export, each planted. The actions never reference the writer. That keeps the code structure honest; the HTTP routes are still the model's path, which is why the confirmation exists. - Exit codes map to fixed messages, and stderr is never read. Measured on
0.18.0: a refusal (exit 1) prints nothing on stdout even with
--json, so exit 1 is one fixed message. Measured on 0.17.0: an unknown subcommand also exits 2, so an exit 2 asksadr --versionafresh, and the nonce route asks first. A refusal's reply text is looked up by code, never read from an error. - A write re-reads the board and the queue, never adds a readiness verdict, and neither the page nor the write module names the ratifying command.
- Each attempt that reaches the confirmation is logged fire-and-forget
through
session.logwith the id, kind, identity, and outcome (not-confirmedincluded), never the summary.
Both canvases share one stylesheet and the board pans and zooms. Rung 1: unit tests, mutation plants, and headless SDK-host renders in Chrome; unmeasured in the Copilot app. Load-bearing:
- One shared stylesheet, generated from its tokens.
canvas-theme.mjsexportsTHEME_CSS, and each page's/app.cssis that block followed by the page's own rules (no new route). Every colour is an alias that reads the app's injected token first,var(--background-color-default, var(--ak-bg)), so theming stays the app's. The--ak-*fallbacks come fromTHEME_TOKENSand followprefers-color-scheme; before 0.11.0 a dark render outside the app kept a white page and dark text, and only native form controls followed the system theme. The contrast test readsTHEME_TOKENS, so a hue changed there is checked at 4.5:1 on the background, the surface, and its own tint, in both themes. - No inline style on either page, tested.
test/visual-pass.test.tsfails on astyleattribute or element in the HTML and on.style,'style',cssText,setProperty(,insertRule(, orCSSStyleSheetin either script. Sizes are class steps (the graph frame'sh-stoh-xl), never computed styles. - Pan and zoom are pure helpers shipped twice.
board-view.mjsexports them for the tests, andboard-page.mjsembeds the same functions withFunction.prototype.toString. Each must stay self-contained ES2017 (no imports, no module constants, no closures); a test runs the embedded copies. The decision portal also carries a generated copy ofboard-view.mjs,board-layout.mjs, andcanvas-theme.mjs(see "The decision portal" below), so an edit to any of the three needsbun run emit:portal-vendor. A view is aviewBox, so zooming sets one attribute and rebuilds nothing. Fit means the whole graph, sofitViewmay go below the 0.2 zoom minimum and zooming out stops at that fit, not at 0.2 (review on #282). - Dim the box, never the label. A dimmed record keeps its text and focus ring at full opacity (0.28 on the whole group puts a muted label at about 1.5:1 on the light surface, computed from the fallback tokens), and a clipped title is cut by grapheme, never mid-character.
- A drag must not select. A pointer drag past 4 px sets a flag the record's
click handler consumes, and the graph's
+ - 0and arrow keys ignore events from an input or button. - The confirm step is an attribute, not a class.
data-step="confirm"styles step 2 of the review controls, because the review tests find the box byclassName === 'review-controls'. With recording off, the fixed note is shown once above the queue and each disabled control carries it as its description. - Objector names are not shown, because
adr queuedoes not report them. QueueReport v1 carries only the two objection counts; showing who raised each needs a CLI field, not a page change.
A static, read-only decision portal for GitHub Pages, under
ADR-0054
(accepted). @adrkit/portal is private and depends on @adrkit/sdk
alone; it is the SDK's first consumer under
ADR-0053.
The nested build-only Action (packages/ci/portal, dist/portal-action.js)
is described below.
Rung 1: unit, contract, purity, and mutation tests, a Node run of the
bundled generator, and a maintainer browser check on this repository's corpus.
Since #310, site.yml also builds this repository's corpus with the shipped
portal Action and publishes it at https://adrkit.dev/decisions/. That is
supplemental dogfood, not rung 2: ADR-0014's rung 2 needs a run in a separate,
isolated reference repository, and an in-repository consumer stays at rung 1
(ADR-0053). Load-bearing:
- It lives outside the CLI. ADR-0033 decision 11 keeps HTML out of
@adrkit/cli; do not add anadr siteor--format html. - The builder is pure, and the file is deterministic.
buildPortalDatatakes the handle, commit,asOf, and a resolved path table, and reads no clock, file, or environment, because ADR-0055's server will call it. All I/O is ingenerate.ts.portal-data.jsonhas nogeneratedAt; identical inputs give identical bytes. A field is added additively; changing one isversion: "2". - Governance by path is a precomputed table, never a matcher. States are
available(possibly capped),unavailable(the corpus has an error, so the SDK withheld resolution), andabsent(a host with no source tree). The page never says "ungoverned"; a miss in a capped table says "not in this table". - Untrusted text stays text. Titles, bodies, tags, and paths come from
the consumer's repository. The Markdown subset parser turns raw HTML and
any link that is not http(s), a fragment, or another record into text; the
page rebuilds links from checked parts and builds its DOM with
textContentonly, under adefault-src 'none'meta CSP. Tests fail oninnerHTML, inline style, and a looser policy. - No verdict, no verbs. The queue view shows the raw review facts only, and the portal's own strings never name a review or ratifying command or a readiness word; a test enforces both.
- The graph opens on supersession alone. The data file holds all three edge kinds; the other two are opt-in checkboxes. Edges are drawn beneath opaque record boxes.
- Three modules are generated copies.
src/vendor/holdsboard-layout.mjs,board-view.mjs, andcanvas-theme.mjs, copied from the agent plugin bybun run emit:portal-vendor, andclean-clone-buildsfails on any diff. The plugin owns them; never edit the copies, and never copyboard-page.mjs,board.mjs,panel-http.mjs, orboard-review-write.mjs. - Page assets are text imports.
src/page/assets.tsimports the page files (the icon among them) and two vendor moduleswith { type: 'text' }, sobun build --target=nodeembeds them.canvas-theme.mjsis imported as a module (forTHEME_CSS) and cannot also be a text import in the same bundle, which is why it is not written into the output.
The third nested Action in @adrkit/ci (ADR-0054 clauses 2, 9, 10), running
../dist/portal-action.js on node24. It builds the bundle into out and
nothing else: no token, no API call, contents: read. The consumer workflow
deploys with upload-pages-artifact and deploy-pages in a separate job; the
documented workflow is in site/src/content/docs/ci.mdx. Rung 1: unit,
contract, and mutation tests, and a Node run of the committed bundle against a
git fixture. site.yml builds this repository's corpus with it into
/decisions/ on adrkit.dev and checks the bundle (scripts/check-portal-bundle.ts);
that deploy is supplemental dogfood; rung 2 needs a run in an isolated reference
repository (ADR-0014). Load-bearing:
- Logic is
src/portal-action.ts, wiring is the entrypoint. Onlyportal-action-entrypoint.tsimports@actions/*, as for the queue Action, so the exit mapping and the visibility gate are tested with stubs. - A private repository fails closed, and so does an unknown one. The
Action publishes only when the event payload says the repository is public
(
private: falseand novisibilityorpublic), orpublish-privateis exactly'true'. Private, internal, and a payload with no repository (or neither field) are refused with fixed messages, before the generator runs. - Exit 1 publishes.
0succeeds,1succeeds with a warning annotation,2fails with the generator's own fixed message and nothing written. An absent optional input is passed asundefined, never'': the generator treatsasOf: ''as a usage error. A throw fails with a fixed message, never the error's text. - Nested only, immutable pins. No root alias, never
mbeacom/adrkit@v0. Docs pinpackages/ci/portal@vX.Y.Z, because recoveringv0to a pre-portal release removes the path from@v0. - The container build stage copies
packages/portal/package.json. Bun 1.4.2's frozen install refuses a pruned workspace set in which a copied workspace (@adrkit/ci) depends on one not on disk; measured withpodman build --target build.scripts/container-contract.test.tschecks every copied manifest's workspace dependencies are copied too.
The OCI image is the fifth distribution surface, authorized by
ADR-0032.
It is versioned with the lockstep release, not independently. A successful
Release workflow creates the lockstep draft only after npm succeeds. Publishing
that stable draft triggers .github/workflows/container-release.yml, whose
narrow write-capable job moves the Action's v0 tag before a separate
lower-privilege job publishes one multi-architecture all-in-one image under
immutable vX.Y.Z, moving vX, and latest tags with a registry provenance
attestation. A failed or adapter-only Release run publishes no container. Manual
recovery runs only from main and accepts only an existing successful stable
GitHub release.
Things that are load-bearing:
- Only the all-in-one
adrkittarget is published. Thecli,mcp,server,ci, andqueue-actiontargets exist for local isolation, SBOM, and policy checks; each final stage contains only its own executable. Publishing five packages would multiply visibility, retention, and rollback operations without adding behavior. - Bun builds; Node runs. The build stage uses the repository-pinned Bun version and bundles CLI/MCP source for Node. Final stages use Node 24, matching the Action runtime, and both base image indexes are pinned by digest.
- MCP stays read-only and networkless. Run it with
--read-only,--network none, and a:rorepository mount. The CI smoke speaks both MCP protocol eras through the dedicated image rather than treating clean EOF as a protocol test. - A container publish follows the coordinated release. It does not race the
npm publication workflow and does not run for the independently versioned
Spec Kit adapter. Container failure is recoverable by manually dispatching
the workflow for the already-created release tag. All promotions are
serialized; recovery may restore an immutable historical tag but moves
vXorlatestonly when that release is still newest, and never changes an immutable tag to a different digest.
This surface is at rung 1 of ADR-0014: contract tests, local Docker/Podman build and runtime smoke, and CI construction. No reference-repository or external/community validation has been recorded.
@adrkit/server (private) is the image's server selector, under
ADR-0055
(accepted). It authenticates no one. node:http over @adrkit/sdk
and @adrkit/portal's pure builder, and nothing else (check-deps admits
exactly those two). Implemented, rung 1 (#314): unit, contract, and
side-effect-denial tests, a local Podman smoke, and a CI container smoke of the
server selector under --read-only with a :ro corpus mount; the APIM path
is unmeasured. Load-bearing:
- The route table is the server.
src/routes.tsis data; the dispatcher matches it and/openapi.jsonis generated from it. Every row isGET; any other method is 405 before routing and no body is read.operationIds are APIM tool names, so renaming one breaks remote agents. - The commit comes from the sidecar, never from git.
.adrkit-corpus.jsonis the ADR-0055/0056 contract. Missing or invalid with the directory present is 503; a directory briefly absent (the two-move swap) or a failed or moved load keeps the last handle. A load is kept only if the sidecar names the same commit after it. governingpassesreadMarkers: false, always. Removing it turns a query parameter into a file read; a test plants a marker file to catch it.- Errors come from
messages.tsonly. The catch-all answersinternalwith fixed text; a sentinel test covers thrown errors, sidecars, and directory names. No access log is written, because a request line carries caller-supplied paths. - The portal is per corpus at
/portal/{corpus}/./redirects relatively when one corpus is configured and lists them otherwise; links stay relative so a gateway base path survives. Portal files carry the page's CSP plusframe-ancestors 'none'. Itsportal-data.jsonsetspathLookupto this server's owngoverningroute (#315); the page prints it as text and never requests it, so do not add a fetch or loosenconnect-srcfor it. - Configuration is
ADRKIT_SERVER_CORPORA(key[=dir],...or JSON), confined underADRKIT_SERVER_ROOTbyrealpath, at start and before every load. As PID 1 the binary handles SIGTERM itself.
ADR-0040 (accepted) splits documentation drift into three classes and refuses to pretend one mechanism covers them. Conflating them is what produces a gate that reports green while checking nothing.
- Derived inventory —
MANIFEST.md's record table is a pure function ofdocs/adr/.bun run emit:manifestgenerates the block between its markers;clean-clone-buildsasserts no diff. Never hand-edit it. - Referential integrity —
bun run check:stale-refsfails when a document speaking in the present tense cites asuperseded,rejected, ordeprecatedrecord without saying so. Acknowledgement is per paragraph or list item, and for a superseded record it must be the successor's id, not the word "superseded" — the word does not tell the reader where to go next. The@adrmarker half of the same class is the separate, already-shippedstale-markerfinding; the two share one definition of successor and nothing else, and prose is never treated as an inbound governance declaration. - Implementation claims — ADR action-item checkboxes are neither derivable nor checkable. Verify each claim against the tree before ticking, and leave an item unchecked when the evidence does not support it. This is process, and the record says so rather than faking automation.
Two boundaries are load-bearing:
- Scan scope is the whole decision, and it is hand-maintained on purpose.
docs/adr/,CHANGELOG.md,specs/,plan.md, and source are excluded because they narrate history correctly — rewriting them would falsify the past. That exclusion is the difference between one finding and 149 mentions. A new top-level prose document is unguarded until someone adds it toSCANNEDinscripts/check-stale-adr-references.ts. - Neither rule gains exit-code authority over a consumer's corpus. The
prose guard fails this repository's own CI step. It does not change
adr lint,adr check, or the governing-decisions Action, and ADR-0022's deliberate denial of exit-code authority to marker findings is untouched.
Both guards are repo-local scripts, not CLI surface: adr graph --format json
already emits every node's status and every supersedes edge, and the public
CLI is a semver commitment (ADR-0031) whose write surface is deliberately
small: new, migrate, accept (ADR-0044, accepted), and the review
commands approve, object, and resolve (ADR-0051, proposed). A public
Markdown inventory formatter waits for adopter demand.
This project uses Bun as its runtime, package manager, test runner, and bundler. Default to Bun instead of Node.js, npm, pnpm, or Vite:
bun install/bun add, notnpm/yarn/pnpmbun run <script>,bunx <pkg>bun test, notjest/vitestbun build, notwebpack/esbuild
See ADR-0010 for the rationale (Bun for development; Node-targeted published artifacts).
Full, editor-scoped Bun conventions live in the subcontext rule files — keep them in sync when the tooling guidance changes:
- Cursor:
.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc - GitHub Copilot / VS Code:
.github/instructions/use-bun.instructions.md
This file is the canonical, host-neutral project memory. The per-host files are thin pointers to it and carry only what is genuinely specific to their host — duplicating content into them costs every agent context on every session, and the copies drift:
CLAUDE.md— Claude Code.github/copilot-instructions.md— GitHub Copilot- opencode reads this file directly.
Do not write to the maintainer's main checkout. Agent runs work in a git worktree on their own branch, never in place on the primary clone.
Before the first write in a session, check where you are:
git rev-parse --show-toplevel # is this the main checkout?
git worktree list # the first entry is the main checkout
git status --porcelain # is someone else's work already here?If the toplevel is the main checkout, stop and create a worktree instead:
git worktree add -b <branch> ../copilot-worktrees/adrkit/<name> origin/mainTwo rules follow from this, and both have been violated in practice:
- A dirty main checkout is a hard stop, not a state to work around. Staged
or modified files there are someone else's uncommitted work. Do not commit,
stash, reset, checkout, or "clean up" around them — the owning session may
still be running, and its work is invisible to
git worktree list. - Never
git worktree removeorgit branch -Don the strength of commit ancestry alone. Squash merges rewrite history, so a fully-merged branch is never an ancestor ofmainandgit log main..<branch>is not empty. Compare content instead —git diff <branch> origin/main -- <paths>— and gate the destructive command on that check actually passing, not merely on having printed a warning.