ADR-0058: Project the decision corpus into a GitHub Project as a one-way derived view
| Field | Value |
|---|---|
| Status | accepted |
| Date | 2026-10-10 |
| Schema version | 0.2.0 |
| Reversibility | two-way-door |
| Blast radius | org |
| Scope | org |
| Tags | ci, distribution, governance, projection, github-projects |
| Deciders | @mbeacom |
| Authored by | agent-drafted |
| Ratified by | @mbeacom |
| Decided at | 2026-10-10T20:12:46Z |
| Relates to | 0004, 0014, 0016, 0025, 0026, 0035, 0036, 0037, 0042, 0044, 0050, 0051, 0052, 0054 |
| Affects | path:packages/ci/project/**, path:packages/ci/src/project-*.ts |
| Source | docs/adr/0058-project-the-decision-corpus-into-a-github-project-as-a-one-way-derived-view.md |
Status: accepted. Agent-drafted, ratified by
@mbeacomon 2026-10-10. This record amends no record and supersedes none. It adds a projection that ADR-0004 already allows: git holds the decisions, and anything else is derived from them with no way to write back. It applies ADR-0037’s rules for a downstream read model to a GitHub Project. The managed-issue queue Action has no governing record of its own. It shipped underspecs/007-arb-queue/, whose plan cites ADR-0004 for the queue projection. This record leaves that Action unchanged.
Context
Section titled “Context”Teams that run architecture review in GitHub Projects want their ADRs on the same board. Today adrkit can put the review queue in exactly one GitHub place: the queue Action.
- The queue Action writes one issue and sets no labels.
packages/ci/queue/action.ymltakesdir,token(default${{ github.token }}), andissue-title, and needs onlyissues: write.packages/ci/src/queue-issue.tsfinds its issue by a hidden marker that must be the body’s exact first line (<!-- adrkit-managed-queue-issue -->). It creates the issue, updates it, or reopens and updates it. It fails without writing when two issues carry the marker, or when an unmarked issue already has the title.publishQueueReportwrites first and then fails the run if the report has error-severity corpus findings. ADR-0026’s CI comment uses a stricter variant: first-line matching plus a bot-author check in itsapp-installationmode, and the marker anywhere inloginmode. - The queue kernel works without a network.
lintCorpusreturns{ checked, findings, records }, with every valid record and itsstatus.buildQueueReportturns that into QueueReport v1, whose items carrytier,slaState,deadlineDate,approvalCount,quorum,unresolvedObjectionCount,resolvedObjectionCount, androutingTargetsforproposedrecords (packages/core/src/queue/types.ts).@adrkit/cialready depends on@adrkit/corefor both. - What the Projects API offers (from GitHub’s documentation, researched
for this record and not measured here). The GraphQL API has
addProjectV2ItemById(idempotent),addProjectV2DraftIssue,deleteProjectV2Item, andupdateProjectV2ItemFieldValue, which sets text, number, date, single-select (by option id), and iteration values. No single call adds an item and sets its fields. Field updates can be aliased in one request. The API cannot set labels, assignees, or milestones on an item. REST endpoints for project items, fields, and drafts now exist (API version 2026-03-10) for organization and user projects. Limits: 5,000 points an hour (up to 12,500 for apps), each mutation request costs 5 secondary points of 2,000 a minute, and content creation is capped at 80 a minute and 500 an hour. The docs ask for at least one second between mutations and no concurrency. GITHUB_TOKENcannot reach a project. GitHub documents it as “scoped to the repository level and cannot access projects”. An organization-owned project takes a GitHub App with the Organization Projects read and write permission (recommended, withactions/create-github-app-token) or a fine-grained PAT with that organization permission. A user-owned project takes a classic PAT with theprojectscope: fine-grained PATs list user projects as a known gap, and we found no user Projects permission for Apps. We are fairly but not fully certain of that last point. Runs from forked pull requests get no secrets.- Built-in project automation cannot see paths. Auto-add filters accept
only
is:,label:,reason:,assignee:, andno:, act only on items created or updated after the workflow is enabled, and are limited per project (Free 1, Pro and Team 5, Enterprise 20). The other built-ins move an added item to Todo and a closed or merged one to Done, and archive automatically. A zero-code setup therefore needs a label, such as oneactions/labelerapplies todocs/adr/**changes, and it tracks pull requests, not decisions. - Review lives in git. Ratifying (ADR-0044) and recording review state (ADR-0051) are human CLI edits that land through a pull request. The board’s write path (ADR-0052) asks the host to confirm every write. No agent surface runs any of these commands. The board (ADR-0050) and the portal (ADR-0054) show approvals, quorum, objections, SLA state, deadline, and routing as raw facts, with no readiness verdict and no ratifying command.
packages/ci/**is a gate path. ADR-0035 requires thegate-change-acknowledgedlabel on any pull request that changes it, counted only when an admin or maintainer applied it (ADR-0042).
Decision
Section titled “Decision”We will ship a separate, one-way projection Action that keeps a GitHub Project in step with the corpus, and document a zero-code recipe beside it.
-
A new nested Action,
packages/ci/project/action.yml. It runs../dist/project-action.jsonnode24, as the queue Action does, and stays nested for the same reason (ADR-0036: GitHub lists only root metadata). The queue Action is unchanged. -
Inputs.
project-url(required);token(required, with nogithub.tokendefault, unlike the queue Action, because that token cannot reach a project);dir(defaultdocs/adr);portal-url(optional, see clause 8); andcreate-fields(defaultfalse, see clause 6). -
One read of the corpus, through core. The entrypoint calls
lintCorpusonce. Every valid record’s status comes from itsrecords, and the review facts forproposedrecords come frombuildQueueReportover the same result. It reuses the queue kernel and the queue Action’s pattern of a pure module with an injected client port. The client is new: the queue’sGitHubQueueClienthandles issues only. A corpus that cannot be loaded fails the run before any GitHub client is built, as in the queue entrypoint. -
One draft item per record, keyed by an adrkit-owned text field. Each record becomes a draft item whose
ADR IDtext field holds its id. The Action creates no repository issue and needs no issues permission. An item with an emptyADR IDwas added by hand and is ignored. The item title isADR-<id>: <title>; the title is repository text shown as data. -
Owned fields are overwritten on every run, and nothing else is touched. The owned fields, their Project types, and their sources are exact, so
create-fieldscan create them and validation can check an existing project deterministically:Field Type Options or format Source ADR IDtext the record id record idADR statussingle select draft,proposed,accepted,rejected,superseded,deprecatedrecord statusTiersingle select auto,async,arbQueueReport v1 tier; empty when nullSLA statesingle select decided,escalated,overdue,due,within-sla,missing-sla,not-queuedQueueReport v1 slaStateDeadlinedate YYYY-MM-DDQueueReport v1 deadlineDate; empty when nullApprovalsnumber distinct identities QueueReport v1 approvalCountQuorumnumber QueueReport v1 quorum; empty when nullOpen objectionsnumber unresolved only QueueReport v1 unresolvedObjectionCountResolved objectionsnumber QueueReport v1 resolvedObjectionCountRoutingtext comma-separated, in source order QueueReport v1 routingTargetsA single-select option missing from an existing field is a validation failure, never a silent skip. Each run writes every owned value from the corpus. A record that is not
proposedhas its queue fields emptied. The item title and body are owned too: both are rewritten on every run, not only when the item is created, so a renamed record, a changedportal-url, or a new synced commit never leaves a stale card. The Action never writes the built-inStatusfield, which belongs to the board’s own workflows: mapping adrkit status onto it would conflate two states, in the spirit of what ADR-0037 clause 3 forbids for documentation formats. It never touches a field it does not own. It reads the Project only to resolve field and option ids and to find items byADR ID. Field updates are aliased per request, and mutations run one at a time at least one second apart. Draft creations stay under 80 a minute and 500 an hour; a corpus that needs more stops at the budget, reports how many records remain, and continues on the next run. -
Missing fields fail clearly. A missing owned field, or a missing option on a single-select field, fails the run with a fixed message naming it. With
create-fields: true, the Action creates what is missing and then continues. That is meant for the first run only, and the docs say so. -
A record that leaves the corpus is archived, not deleted. Its item is archived, so the card’s history stays. A record that comes back is restored or recreated under the same
ADR ID. -
Item bodies link to the record and carry no command. A body is built from fixed strings, the id, and one link. With
portal-url, the link is the record’s portal route (<portal-url>#/adr/<id>, ADR-0054 clause 8). Without it, the link is the record’s source path in the repository at the synced commit. The body never repeats the record’s prose, never contains a review or ratifying command, and never says a record is ready, eligible, or ratifiable. That is stricter than the terminal queue (ADR-0044), as the board and the portal already are. Typed relationships are not shown on the card; the link is where ADR-0037 clause 4 asks a lossy projection to send the reader. -
Failure modes are fixed and named.
- Two items with one
ADR ID: fail without writing, naming both, as the queue Action fails on two markers. - A person edits an owned field: the next run overwrites it. The docs say so plainly.
- The token cannot see the project: a fixed error naming the two token paths of clause 13, and a non-zero exit. API error text and stack traces are not echoed.
- Error-severity corpus findings: the run syncs every valid record first,
then fails, matching
publishQueueReport.
- Two items with one
-
The board records nothing. Moving a card or editing
Approvalschanges no decision. Review stays in git through the human CLI and a pull request (ADR-0044, ADR-0051, ADR-0052), and the next sync reverts the edit. The Action never writesdocs/adr/**(ADR-0037 clause 6), and no agent surface runs a review command because of anything on the board. -
Triggers and tokens. The documented workflow runs on push to the default branch (filtered to the corpus directory), on a schedule, and on
workflow_dispatch, never onpull_request, because forked pull requests get no secrets. The schedule matters:slaStatedepends on the date, so a card goes stale between pushes without it (the reason ADR-0025 refused to badge SLA state). The workflow takes a concurrency group so two runs never create the same item. Token paths are clause 13. -
A zero-code recipe ships as documentation regardless.
actions/labelerlabels pull requests that touchdocs/adr/**, and the project’s auto-add workflow picks them up by that label. The recipe says plainly that it tracks pull requests, not records, and carries no SLA, quorum, or deadline. It is a recipe in ADR-0025’s sense, not code. -
Both owners are supported, and a user-owned project is the default. The maintainer settled this: user-owned projects are the primary case today, and organization-owned projects are supported too.
- User-owned (the documented default). A classic PAT with the
projectscope, stored as a secret. The docs say plainly that this scope is owner-wide: it reaches every project the token’s owner can, not only this one. They also say why nothing narrower is offered. Fine-grained PATs list user projects as a known gap, and we found no user Projects permission for GitHub Apps. We are fairly but not fully certain of that last point, and the docs keep that caveat. If GitHub offers a narrower token for user projects, the docs switch to it. - Organization-owned. A GitHub App with Organization Projects read
and write, minted per run with
actions/create-github-app-token(recommended), or a fine-grained PAT with that organization permission.
- User-owned (the documented default). A classic PAT with the
Options considered
Section titled “Options considered”- Option A: a documented recipe only (labeler plus auto-add). No code and no secret. It tracks pull requests rather than records, cannot show SLA state, quorum, or deadlines, and auto-add has no path filter. It ships anyway as clause 12, but it does not answer “what is under review and how close is it to its deadline”.
- Option B: a separate one-way projection Action (chosen). It shows every record and the review facts the queue already computes, and keeps the owner-wide secret and the Projects API’s failure modes out of every other Action. It costs another nested bundle (the fourth, after the portal’s) and a Projects client to maintain.
- Option C: a
projectinput on the queue Action. Rejected. The queue Action needs onlyissues: writeandgithub.token. Adding an owner-wide secret and a second API to it would let a Projects outage turn the queue run red, and a queue adopter would carry a credential path they never use. - Option D: a two-way sync that reads card moves back into frontmatter. Rejected by ADR-0004 (no write path back) and ADR-0037 clause 1: a card move would become an unreviewed decision change.
Trade-offs
Section titled “Trade-offs”- Owned fields lose human edits. A person who types into
Approvalssees the value reverted on the next run. That is the price of a projection that can always be rebuilt. An advisory drift warning would need the Action to read owned values back, which clause 5 does not allow in this version; it is left for later and would have no exit-code authority. - The default token is broad. The default path is a user-owned project,
and its classic PAT with
projectscope reaches every project its owner can. Clause 13 states that instead of hiding it. - The card is as fresh as the last run. Between scheduled runs, SLA state and deadlines can be a day behind.
- Another nested bundle (the fourth, after ADR-0054’s portal Action) joins the lockstep release and the moving
v0tag.packages/ci/package.jsongains abun buildline andprojectinfiles(todayaction.yml,queue,dist).action-tag-recovery.ymlchecks exactlydist/index.jsanddist/queue-action.js, ADR-0054 item 6 already adds the portal bundle, and this addsdist/project-action.js.docs/RELEASING.mdgains a row. As with every nested Action, the docs givembeacom/adrkit/packages/ci/project@<tag>and nevermbeacom/adrkit@v0. - Large corpora sync over several runs on the first pass, because of the content-creation limit.
Consequences
Section titled “Consequences”- Easier: a review team sees every decision, its status, and the queue’s review facts on the board it already uses, kept current by a schedule.
- Easier: the projection can be deleted and rebuilt at any time; the project holds nothing the corpus does not.
- Harder: every pull request that builds this changes
packages/ci/**and needsgate-change-acknowledgedfrom an admin or maintainer (ADR-0035, ADR-0042). - Harder: a second GitHub API client in
@adrkit/ci, with tests for pacing, aliasing, duplicate keys, missing fields, and fixed error text, each observed failing first (ADR-0016). - Risks. A leaked project token can rewrite or archive any card the token reaches, not only adrkit’s. Record titles are untrusted text on the card; GitHub renders them, and the body holds no command for a reader to copy.
- How we would know this was wrong: the Action writes the built-in
Statusfield, a field it does not own, or anything underdocs/adr/**; a card edit changes a record; an item body contains a review or ratifying command or a readiness word; the queue Action gains a Projects dependency; the Action runs withgithub.tokenby default; two items share anADR IDand the run succeeds; a Projects failure changes any other Action’s result. - Revisit if: GitHub gives
GITHUB_TOKENproject access, adds a user Projects permission for Apps, or adds path filters to auto-add; or adopters ask for the projection in a form other than draft items.
Evidence rung
Section titled “Evidence rung”None yet; rung 1 at best when built under ADR-0014. Rung 1 would be unit
and contract tests of the projection plan and the client port against a fake,
each observed failing first. Rung 2 would be a run in
adrkit-t018-dogfood
against a user-owned project, the default path of clause 13, which exercises
the classic-PAT path only. The organization path (App or fine-grained PAT)
stays unmeasured until an organization project is used. No external
party has run it.
Action items
Section titled “Action items”- Confirm against GitHub’s API how an item is archived and restored, and how a field value is emptied, before relying on clauses 5 and 7; correct this record if either differs.
- Add
packages/ci/src/project-*.ts: a pure projection plan fromlintCorpusandbuildQueueReport, an injected Projects client port, and the entrypoint, with tests for duplicate keys, missing fields,create-fields, pacing, the creation budget, archiving, the no-Status-write rule, and the no-command body. - Add
packages/ci/project/action.yml, thedist/project-action.jsbuild, andprojectinfiles. - Teach
action-tag-recovery.ymlanddocs/RELEASING.mdthe new nested bundle. - Document the workflow (triggers, concurrency, both token paths) and the zero-code labeler recipe.
- Run it in
adrkit-t018-dogfoodagainst a user-owned project as the rung-2 evidence. - Decide later, in its own change, whether to link a record’s open pull request as a second, unkeyed item, which would need a pull-request read permission this version does not ask for.
- Ratify or reject this record. Ratified by
@mbeacomon 2026-10-10.