ADR-0054: Publish a static decision portal for GitHub Pages, built outside the CLI from the SDK
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-10-10 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | org |
| Scope | org |
| Tags | distribution, portal, ci, visualization, governance |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Decided at | 2026-10-10T16:17:54Z |
| Relates to | 0007, 0014, 0016, 0025, 0030, 0031, 0033, 0040, 0044, 0050, 0052, 0053, 0055, 0056 |
| Affects | path:packages/portal/**, path:packages/ci/portal/** |
| Source | docs/adr/0054-publish-a-static-decision-portal-for-github-pages-built-outside-the-cli-from-the.md |
Status: accepted. Agent-drafted, ratified by
@mbeacomon 2026-10-10. This record amends no record and supersedes none. It places an HTML renderer where ADR-0033 decision 11 leaves room for one: that clause keeps native SVG/HTML out of “this surface”, the CLI, and stays binding on@adrkit/cli. It reuses three pure modules from the decision board (ADR-0050) as generated copies, and none of the review-write path (ADR-0052). It is the in-repo consumer that ADR-0053 names for ADR-0031 clause 8.
Context
Section titled “Context”A team on GitHub Pages has no way to browse its decisions in a browser. The pieces exist but do not compose:
- The CLI may not render HTML. ADR-0033 decision 11 refuses to spawn
Graphviz or ship native SVG/HTML in
adr graph. Its Option D was rejected for its dependency cost: system Graphviz, a layout/WASM package, or an in-house engine inside the CLI. - Pages already serves CLI projections.
.github/workflows/site.ymlwritesadr queue --format jsonandadr lint --jsoninto the site build, publishes on exit1(“the report is complete and publishable”), and skips a report on exit2or above (ADR-0025). Its build job holdscontents: read;pages: writeandid-token: writelive only in the deploy job.site/itself is adrkit.dev’s Astro/Starlight site and is not reusable by adopters. - The CLI’s JSON is too thin for a portal.
adr graph --format jsonnodes carryid,title, andstatusonly (packages/core/src/graph/build.ts), andadr queuecoversproposedrecords only. - The SDK is the intended source, and it has gaps.
packages/sdk/src/index.tsis types only. Measured against what a portal shows:QueueEntrycarries id, title, path, tier, SLA state, and deadline, but no approvals, quorum, objection counts, routing targets, or finding count (core’sQueueItemhas all of them);DecisionGraphedges are supersession only, with norelatesToorconflictsWith;DecisionRecordhas no body, deliberately, because rendering prose is “the document layer”. ADR-0053 decision 11 closes all three gaps additively, as part of implementing the SDK and before anything is published, and reverses the body exclusion for this portal and ADR-0055. - The board already solved layout and theme. In
packages/adapters/agent-plugin/extensions/adrkit/,board-layout.mjs(deterministic supersession columns, a summary past 300 records or 20,000 px),board-view.mjs(pan and zoom helpers), andcanvas-theme.mjs(tokens with light and dark fallbacks and a contrast test) contain no import statement.board-page.mjsis bound to a token-gated local server, andboard.mjsroutes toboard-review-write.mjs, which records review. - The plugin cannot import a shared module. Every host installs it by
copying its directory from git. Its
package.jsondeclares no dependencies, andtest/packaging.test.tsallows an extension module to import onlynode:builtins and a named list of siblings, with no dynamic import. check-depsreads declarations, not source.scripts/check-deps.tsflags a non-adapter workspace that declares an adapter dependency. A relative source import frompackages/portalintopackages/adapters/**would not be seen. A package with noallowedDependenciesForentry is, in that file’s words, “silently unconstrained”.- ADR-0030 clause 1 admits a surface to this repository only if it declares no third-party dependency beyond the vetted set. That rules out a Markdown library.
Decision
Section titled “Decision”We will publish a static, read-only decision portal that a consumer’s own workflow deploys to GitHub Pages:
-
A private generator,
packages/portal, built on@adrkit/sdkalone. Its only runtime dependency is@adrkit/sdk; it never imports@adrkit/coreor@adrkit/cli, and spawns noadrprocess. The fields its views need beyond the original sketch (the review counts and routing on queue entries, therelatesToandconflictsWithedges, and the record body) are in ADR-0053’s implementation scope (its decision 11), with the surface count re-measured as ADR-0031 item 3 did. The portal does not reach around the SDK, and ships the views those fields feed only once they exist. That constraint is what makes the portal ADR-0031 clause 8’s real consumer rather than a second caller of core. The body comes through the SDK rather than a file read in the portal so that both hosts of clause 12 get it the same way, which ADR-0055 also requires; ADR-0053 decision 11 records that this reverses the SDK sketch’s exclusion of the body. -
A nested, build-only Action,
packages/ci/portal.action.ymlwith inputsdir(defaultdocs/adr),out(default_site), andbase-url(optional), running../dist/portal-action.jsonnode24, as the queue Action does. It builds the bundle intooutand nothing else. It needscontents: readonly. The consumer’s workflow uploadsoutwithactions/upload-pages-artifactand deploys it withactions/deploy-pagesin a separate job that alone holdspages: writeandid-token: write, assite.ymldoes. A short workflow doing exactly that is documented with the Action. The rootaction.ymlstays the governing-decisions alias; GitHub lists only root metadata, so this Action stays nested. -
A versioned data contract,
portal-data.json. One file withversion: "1", the corpus directory, the commit SHA, and the queue’sasOf; records (id, title, status, standing, date, path, tags, supersession, and the body as a restricted token tree); corpus issues; graph nodes and edges of all three kinds; queue entries; and the path table of clause 6. One pure function inpackages/portalbuilds it from an SDK handle, so a host that produces the file per request (ADR-0055) produces the same shape. That builder takes everything as arguments: the handle, the commit,asOf, and the already-resolved path table. It reads no clock, file, or environment and spawns nothing, and it must stay that way, because ADR-0055’s server calls it directly; a test asserts its purity. The build-time generator does the I/O around it: it resolvesasOfto today (UTC) when none is given, and resolves the path table. The file has nogeneratedAt: identical corpus, commit,asOf, and path table give byte-identical output, the queue’s SC-001 discipline. A field is added additively; removing or reinterpreting one isversion: "2". -
Five views, all read-only: the record list and each record; status counts; the supersession graph, laid out by the generated copy of
board-layout.mjs, with its summary past the board’s limits; the review queue; and governance by path. The page has no form, no POST, and no control that changes anything. -
The queue shows raw review facts and no verdict. Approvals against quorum, unresolved and resolved objection counts, SLA state, deadline, routing, and finding count, as ADR-0050 clause 2 shows them. It never says ready, eligible, or ratifiable, and the portal’s own strings never contain the ratifying command or a review verb as a command. A record’s title or body may still contain one, as ADR-0044’s title does; that is repository text shown as data. A test fails on either word set in the shipped page strings.
-
Governance by path is a precomputed table, not a matcher. At build time the generator calls the SDK’s
governing(path)for every tracked file and keeps the paths at least one decision reaches, with the governing, active-proposal, and history ids and the evidence kind. The page looks a path up; it ships no glob engine that could disagree with the CLI. The table is capped (count and bytes); a capped table says so, and a lookup that misses in a capped table says “not in this table”, never “ungoverned”. When the corpus has any error finding, the SDK’sgoverningreturns all three groups empty with the corpus issues, asadr explaindoes. The generator then marks the table unavailable with that reason, and the page says resolution was withheld because the corpus has errors; it never shows an empty or “ungoverned” result (see clause 9). -
Security, because titles and bodies are untrusted text on the consumer’s origin. A
<meta>Content-Security-Policy ofdefault-src 'none'with'self'scripts, styles, images, andconnect-src, plusbase-uri 'none'andform-action 'none'. Pages cannot set headers, andframe-ancestorsis ignored in a meta policy; the container (ADR-0055) sets the policy as a header and addsframe-ancestors 'none'. The page builds its DOM withcreateElementandtextContentonly: noinnerHTML, no inline script, no inline style, nostyleattribute, enforced by a test like the canvases’visual-pass.test.ts. The body is parsed by the builder (clause 3) with an in-house, pure Markdown-subset parser (paragraphs, headings, lists, emphasis, code, tables, links) into tokens; raw HTML and anything unsupported is rendered as literal text. Links are kept only forhttp:,https:, fragments, and relative links that resolve to another record (rewritten to its route); images render as their alt text. -
Position-independent output. Assets are referenced relatively and routes are hash routes (
#/adr/0031), so the bundle works at a Pages project path (/<repo>/), a custom domain root, or the container’s mount without a rebuild or a server rewrite.base-urlfeeds absolute links only. -
An exit-
1corpus still publishes. Exit codes followsite.yml’spublish ()step:0writes the bundle;1writes the complete bundle with every error finding shown in a banner on every page and in the data file, the path table marked unavailable as clause 6 says, and the Action succeeds with a warning annotation and anexit-codeoutput;2or above (usage, unreadable corpus) writes nothing and fails the Action. -
A private repository fails closed. When the event payload marks the repository private or internal, the Action refuses with a fixed message unless
publish-private: trueis set, because the bundle carries every record body and the governed file paths. The Action cannot read the Pages site’s visibility, so the input is the person’s statement that they checked it. -
The layout, view, and theme modules are generated copies, owned by the plugin.
board-layout.mjs,board-view.mjs, andcanvas-theme.mjsare copied byte for byte intopackages/portalby anemitscript, with a header naming the source.clean-clone-buildsasserts no diff, the MANIFEST mechanism of ADR-0040, observed failing first (ADR-0016). The copied set is pinned by name. A portal test fails if a copied file imports anything or names a review verb.board-page.mjs,board.mjs,panel-http.mjs, andboard-review-write.mjsare never copied. The portal writes its own page module. The theme’s app-token-first aliases fall back to its--ak-*tokens outside the app, which is exactly the path its contrast test covers. -
One bundle, two hosts. The REST server in the OCI image (ADR-0055), deployed to Azure (ADR-0056), serves this same bundle. It produces
portal-data.jsonby calling this package’s pure builder (clause 3), so the private@adrkit/serverdepends on the private@adrkit/portal, both bundled into the same image; the details are that record’s decision, and the contract here is the file’s shape. The server holdsdocs/adrand no source tree, so it passes an empty path table and the governance-by-path view there says it has no table rather than “ungoverned”. In ADR-0056’s first version the gateway publishes/in private mode only.
Options considered
Section titled “Options considered”The renderer’s home
Section titled “The renderer’s home”- Option A: a private package outside the CLI, fed by the SDK (chosen). Keeps ADR-0033 decision 11 intact and gives the SDK a consumer.
- Option B:
adr portaloradr graph --format html. Exactly what decision 11 refuses, and new lockstep CLI surface (ADR-0031’s semver commitment) for a build artifact. - Option C: feed the portal from CLI JSON, as
site.ymlfeeds badges. No new contract, but the graph JSON is thin, the queue lacks non-proposedrecords, and three commands’ JSON is still assembled in the CLI (ADR-0031 clause 6). The portal would rebuild per-record detail from frontmatter, which is the reimplementation ADR-0031 exists to stop. - Option D: a separate repository. ADR-0030 sends a surface out only when it carries a dependency tree. This one declares none beyond the SDK.
The shared renderer
Section titled “The shared renderer”- Option 1: generated copies, the plugin as owner (chosen). No import
edge in either direction, so the plugin’s install-by-copy and its sibling
allowlist hold, and
check-depsis not bypassed. Drift is a CI failure, as forMANIFEST.md. AGENTS.md already accepts “pure helpers shipped twice” forboard-view.mjs, whose functions are embedded in the page byFunction.prototype.toStringand tested as embedded. - Option 2: a shared pure module both import. The plugin cannot import
it: it declares no dependencies, its packaging test admits only named
siblings, and its install copies one directory. Putting the module in the
plugin and importing it from
packages/portalby relative path is an adapter edgecheck-depscannot see, because it readspackage.json. That defeats ADR-0007’s guard rather than satisfying it, and would carry adapter code into@adrkit/ci’s bundle with no declaration. - Option 3: a portal-only renderer. No coupling, but a second layout algorithm and a second token set, two pictures of one corpus that can disagree, and a contrast test to duplicate.
Markdown
Section titled “Markdown”- In-house subset parser (chosen). Required by ADR-0030 clause 1.
- A Markdown library with HTML disabled. Better fidelity, but a third-party dependency in a surface admitted for having none.
- Show the body as preformatted text. Safe and simple, but tables and headings in records become unreadable.
Trade-offs
Section titled “Trade-offs”- Fidelity. The subset parser renders less than GitHub does. Each record links to its source when a repository URL is known.
- A copy is still a copy. A layout fix lands in the plugin, then reaches
the portal through the emit script and a regenerated
@adrkit/cibundle. The no-diff check makes forgetting a red build, not drift. - The SDK must grow first. The queue, full-graph, and record-body views wait on the SDK fields ADR-0053 decision 11 adds, and each one widens the surface ADR-0031 commits to keep.
- A third nested Action joins the lockstep draft-and-
v0release, andaction-tag-recovery.yml(which checksdist/index.jsanddist/queue-action.js) must learn its bundle. - The table is as of one commit. It cannot answer for a branch, an uncommitted file, or a path added since the build.
- Pages cannot carry
frame-ancestors. The page has no control worth framing, so the residual clickjacking risk is accepted on Pages only.
Consequences
Section titled “Consequences”- Easier: a Pages team gets records, status, the supersession graph, the queue, and path lookup in a browser with one Action and a documented workflow, and no server.
- Easier: the SDK gets the consumer ADR-0031 clause 8 asked for, exercised on every build.
- Harder:
check-depsneeds an@adrkit/portalentry (@adrkit/sdkonly) with a negative case, and@adrkit/ci’s entry must admit the portal. Without the first, the package is silently unconstrained. ADR-0055’s@adrkit/serverentry admits@adrkit/portaltoo, which is why the builder’s purity (clause 3) is a contract, not a convenience. - Harder:
packages/ci/package.jsongains a thirdbun buildline andportalinfiles;docs/RELEASING.mdgains a row. - Risks. A private repository’s Pages site may be readable by anyone, depending on the plan and the site’s visibility setting; clause 10 makes that a stated choice, not a default. A record title or body that carries script, HTML, or a hostile link is shown as text or dropped; the CSP is a second wall, not the first. The governed-path table discloses file names to whoever can read the site.
- How we would know this was wrong: the portal imports
@adrkit/coreor spawnsadr; a page says a record is ready, or shows a ratifying or review command outside repository text; any element is built from an HTML string, or a record body runs script; the copied modules differ from the plugin’s without a red build; a write route or form appears; an exit-1corpus fails to publish; the table andadr explaindisagree for the same commit; a private repository publishes withoutpublish-private: true. - Revisit if: the SDK cannot take the queue and edge fields additively; the plugin gains a way to import a module outside its directory; Pages gains response headers; or ADR-0033’s revisit condition (a dependency-appropriate engine producing self-contained, accessible SVG) is met. That would change the CLI’s options, not this package’s.
Evidence rung
Section titled “Evidence rung”Rung 1 of ADR-0014. Rung 1 landed with action items 1 to 7
(#292 and
#302): unit, contract, and purity tests, each observed failing
first (ADR-0016), and a byte-identity check on the copies. Item 8 is done as
written: since #310 (2026-10-10),
site.yml builds this repository’s own corpus with the shipped portal Action
and publishes it at https://adrkit.dev/decisions/. That is supplemental,
maintainer-owned dogfood evidence, not rung 2: ADR-0014 defines rung 2 as a
run in a separate, isolated reference repository with reproducible,
self-verifying, fail-closed evidence, and ADR-0053 records that an
in-repository consumer stays at rung 1. Item 8’s own wording (“the rung-2
dogfood”) predates that reading and overstates it. Rung 2 needs the portal
Action run in an isolated reference repository such as
adrkit-t018-dogfood; rung 3
(an external party) is not claimed.
Action items
Section titled “Action items”- Land ADR-0053 decision 11 (the queue review fields, the two missing edge kinds, and the record body, added to the SDK additively, with the surface re-measured) before the views that read them.
- Create
packages/portal(private): generator,portal-data.jsonv1, subset Markdown parser, page module, and path table, with determinism, purity, sanitizer, and no-innerHTML/no-inline-style tests. - Add the emit script and no-diff check for the three copied modules, and the copied-set and no-review-verb tests.
- Add
check-depsentries for@adrkit/portaland the widened@adrkit/ci, each with a negative case. - Add
packages/ci/portal/action.yml, thedist/portal-action.jsbuild, the exit-code andpublish-privatebehavior, and the documented consumer workflow. - Teach
action-tag-recovery.ymlanddocs/RELEASING.mdthe third nested bundle. - Add a test that the portal’s shipped strings contain no ratifying or review command and no readiness word.
- Build this repository’s corpus from
site.ymland publish it under adrkit.dev as the rung-2 dogfood. Done 2026-10-10 in #310:site.ymlrunsuses: ./packages/ci/portal. Checked 2026-10-10:/decisions/and/decisions/page/app.mjsanswer 200 astext/htmlandtext/javascript, and/decisions/portal-data.jsonhasversion: "1", 59 records (the countadr lintchecks), and commit e491a78 ofmain. Recorded in #294. - Ratify or reject this record.