ADR-0094.
The forge adapter boundary: one port, GitHub and GitLab at parity, the verdict forge-neutral
Date: 2026-10-13
Status: accepted (2026-10-13) — class R under ADR-0088 decision 2; the maintainer accepted the
record with the recommended answers: the forge modules live in a second package of the same repository (runward-forge), so runward keeps zero network code; the GitLab packaging reaches self-managed instances as a file pinned by integrity, published as a release asset from D1, the catalogue component following once the maintainer creates the gitlab.com project. An agent wrote this record; the maintainer decided (ADR-0088
decision 4, applied to decisions as to rows)
Deciders: the maintainer
Method: a fresh clone of stranxik/runward at main@958d068 (runward 0.43.1), read on
2026-10-13: action.yml, verify/action.yml, templates/adapters/gitlab-ci.yml,
templates/adapters/README.md, src/lib/harness.ts (UNIVERSAL_CHANNELS), src/lib/characterize.ts,
src/lib/identity.ts, src/commands/check.ts, eslint.security.config.js (SPAWN_ALLOWED),
test/unit/runtime-boundary.test.js, package.json (files, exports), ADR-0054, 0064, 0080, 0088,
0093. A GitLab gap analysis written for the maintainer on 2026-10-13, outside this repository, in
French, which read the GitLab, Atlassian and Microsoft documentation that day; the tier statements
below are that reading, not a measurement on an instance
Relates to: ADR-0093 (the umbrella, decision 2),
ADR-0054,
ADR-0080,
ADR-0088,
ADR-0095,
ADR-0096
Would amend, by name, once accepted: none now. ADR-0080's Consequences ("Part 2 is forge-specific:
GitHub first, GitLab when a user asks") is amended by the forge-neutral independence-rule record, which
lands during D4 (ADR-0093 decision 12); this record only fixes the boundary that record will use.
Context
The verdict is already forge-neutral, measured. No module of the verdict path calls a forge, opens
a socket or reads a forge variable. check.ts names a forge once, in a sentence ("forge approval (the
pull-request review on GitHub or GitLab): not verified by this command"). Canonical identities are
opaque strings (github:<id> and gitlab:<id> read the same). characterize already detects
.gitlab-ci.yml, azure-pipelines.yml, CircleCI, Jenkins and Drone. No module under src/ imports
an HTTP client or calls fetch.
What is GitHub is the envelope, and much of it by habit, not need.
| Element | Where | GitHub because |
|---|---|---|
| the gate as an Action, with a warning when the version is not pinned | action.yml |
need (the Action format) |
| the verify Action, its Markdown summary written by inline JavaScript in the YAML | verify/action.yml |
need for the format; habit for the renderer, which no other forge can reuse |
the CI channel init proposes |
src/lib/harness.ts, UNIVERSAL_CHANNELS names runward/adapters/github-actions.yml only, even when characterize saw a .gitlab-ci.yml |
habit |
| the GitLab sample | templates/adapters/gitlab-ci.yml: npx --yes runward check --strict, unpinned and without the Action's warning, no report deposited, no verify |
habit |
| ADR-0080 Part 2 | "GitHub first, GitLab when a user asks"; not shipped on any forge | habit: the independence rule is a pure function, only its facts and trigger are per forge |
What GitLab offers, and at which tier (read 2026-10-13). CI/CD components and the catalogue: Free.
include: remote with integrity: (SHA-256, the pipeline fails on a different content): from 17.9. A
merge-request note by API: Free, but CI_JOB_TOKEN only reads notes, so a project access token is
needed (Premium on GitLab.com, any licence self-managed). Code Quality widget and JUnit test summary in
the merge request: Free. Required approval rules, CODEOWNERS approval, the audit API: Premium. SARIF
ingestion, external status checks, external controls, pipeline execution policies, audit streaming:
Ultimate. No pipeline starts on an approval (no CI_PIPELINE_SOURCE value for one), and an
approval record carries approved_at, not a commit SHA. On GitLab Free, approvals are optional and do
not block a merge.
Why now. ADR-0093 makes GitLab first-class, at parity in every delivery, and names the target's most likely primary forge as GitLab, often self-managed and possibly offline. Without one written boundary, each of D1 to D5 would re-argue where forge code lives, what happens without a token, and what "parity" may promise.
Decision
The verdict and its file renderings are forge-neutral and unchanged. Everything that talks to a forge sits behind one port, outside the verdict's import closure, with one closed behaviour per failure. GitHub and GitLab ship at parity in every delivery; Azure DevOps and Bitbucket ship a minimal adapter. "Parity" describes what runward ships, never what a forge tier allows.
The layers.
Layer Content Neutral or per forge Verdict check,--json,--attest,--witness,verify,reportneutral, unchanged File renderings SARIF (exists), Markdown summary, Code Quality JSON and JUnit XML (ADR-0096) neutral, rendered from the one payload Independence rule (ADR-0080 Part 2) one pure function, decideIndependence(facts) -> { decision, reason }neutral, one implementation Fact readers GitHub: reviews, commits, timeline; GitLab: /approvals,/versions,/commits,/approval_statewhere the tier exposes itper forge Posters summary (GitHub job summary; GitLab merge-request note; Azure pull-request status; Bitbucket Code Insights), external status (GitHub check run; GitLab external control) per forge Packaging the Action; a GitLab component and an integrity-pinned file; Azure Pipelines and Bitbucket Pipelines templates per forge Change identifier git rev-list --first-parentis neutral; the message parser (Merge pull request #N,See merge request group/project!N) is per forgegit neutral, parser per forge Signature always the operator's ( actions/attest;cosignwithid_tokensor the entity's key)per forge, runward holds no key The port, written once.
readChangeFacts(change) -> Facts,postSummary(markdown),setExternalStatus(verdict, ref),identifyChange(commitMessage) -> { forge, id } | null.Factsis normalised (author, committers,Co-authored-byco-authors, the agent's requester when declared, approvals with their time, head commit and last push time, bot accounts) so the independence rule never sees a forge's vocabulary.setExternalStatuspublishes the raw verdict (exit code,cleanorgaps, digests); apassorfailmapping is a policy file the customer writes (ADR-0093 decision 9), and without it the poster refuses.Closed behaviours, one per failure, each tested on recorded API responses.
Failure postSummaryreadChangeFactsand the independence rulesetExternalStatusno token prints « summary not posted: no token » and exits 0; the summary stays an artifact refusal, named refusal, named 401, 403 « summary not posted: », exit 0 refusal refusal 404, malformed body same refusal refusal 5xx, timeout one retry, then as above refusal refusal customer policy absent not applicable not applicable refusal The summary is a convenience; the facts and the status are evidence or claims, so they fail closed. No poster turns a forge error into a green.
Where forge code lives. Outside
dist/lib/verdict.jsanddist/commands/check.jsand their import closures, asserted byruntime-boundary.test.jsin both rings; no forge module is a fifth entry ofSPAWN_ALLOWED(posters use the platform'sfetch, they spawn nothing). Whether the forge modules ship inside therunwardpackage or beside it is question 1 below.Parity, per delivery and per tier, said as it is. Each delivery of ADR-0093 ships its forge pieces for GitHub and GitLab together, or does not ship. Every surface that names approval independence on GitLab Free says « detected after merge, not prevented »; on Premium and above « the forge prevents, runward records ». Azure DevOps and Bitbucket get, in D1, a pinned pipeline template and one line in the adapters README, and in D5 a summary poster reusing the same Markdown; their fact readers come later, under the same pure rule, without changing it.
The D1 sweep this boundary implies (built in D1, recorded here so D1 does not re-argue it):
templates/adapters/gitlab-ci.ymlaligned with the Action (pinned version, the same warning when unpinned, reports deposited as artifacts);harness.tsproposes the GitLab sample whencharacterizesees.gitlab-ci.yml; the charter template namesgitlab:<id>besidegithub:<id>and adds.gitlab-ci.ymland.gitlab/**to its pre-merge path examples; the verify summary renderer moves out of the Action's inline JavaScript into the neutralreport --format md.The offline bench. A GitLab CE instance in a container, network cut, in runward's CI, plus recorded API responses for unit tests. It is part 2 of D0 and lands in its own pull request.
Alternatives discarded
- GitHub first, GitLab on request (ADR-0080's current wording). The target names its forge in the first conversation and never phrases a request in runward's terms; the request would never come.
- Forge calls inside
check. Crosses ADR-0054 crossing 5 and breaks "same working tree, same verdict": an API answer is not in the tree. - One adapter per forge with its own independence rule. Two rules drift; the rule is a pure function of normalised facts, and only reading the facts is per forge.
- "Parity" as a promise of equal controls. GitLab Free cannot block a merge on an approval; saying parity there without the tier sentence would be the overclaim ADR-0050 exists to prevent.
- A personal access token recommended for the GitLab.com Free merge-request note. It would make a person's credential the poster's identity; the summary stays an exposed artifact there instead.
Consequences
- Positive. D1 to D5 cite one boundary; GitLab gets the same deliverables in the same delivery; a missing token never reads as a green; the verdict path does not move.
- Negative, accepted. Two forges to keep in step at every delivery, each with recorded responses that age as the APIs move; a test bench to maintain; on GitLab Free the independence check stays a detection control and every surface says so.
- On other boundaries. The first module of runward that opens a network connection, wherever it lives (question 1); the network-cut CI run must keep passing with every poster absent or refusing.
What would settle it
Questions for the maintainer (each with the recommendation):
- Where the forge modules ship. (a) Inside the
runwardpackage, under a separate entry point outside the verdict closure: one version, one tarball for an offline mirror, one provenance. (b) A second package from the same repository and release chain (for examplerunward-forge), importing nothing from runward but reading its output files: the CLI package keeps zero network code, which is what a security reviewer reads first. Recommended: (b), because "no module of therunwardpackage opens a connection" is a property a reviewer can check in one grep, and (a) would end it. - How the GitLab packaging reaches a self-managed instance. (a) An integrity-pinned file published
as a release asset, used by
include: remotewithintegrity:or by importing the project once: works on Free, offline after one copy, needs no new forge account. (b) A catalogue component published from a gitlab.com project runward operates: discoverable, but a new trust root (a forge account and a release path on a second forge, both class R). Recommended: (a) in D1 as the parity channel; (b) when the maintainer creates the gitlab.com project, released from the same tag.
Evidence that would ratify or reverse it:
runtime-boundary.test.jsreddens on a planted import of a forge module into either ring.- Every port function has a GitHub and a GitLab implementation tested on recorded responses, each closed behaviour above included; a planted 403 never yields a green status.
- The same Markdown summary, byte for byte, posted on a GitHub pull request and a GitLab merge request of the bench.
- The D1 install runs in the offline GitLab CE bench through the integrity-pinned file.
- Reversal: a forge whose facts cannot be normalised into
Factswithout losing what the independence rule needs, or a poster that must run insidecheckto be useful.
Reevaluation trigger (mandatory, dated)
Reopen on 2027-01-13, or earlier when: GitLab adds a pipeline source for approvals (Free detection could become prevention); a recorded response no longer matches the live API of either forge; a target names a forge outside the four; or a poster is found inside a verdict ring.
Trigger set on: 2026-10-13 · Watched via: runtime-boundary.test.js, the recorded-response
tests, the offline GitLab CE bench.
References
- ADR-0093 decisions 1, 2, 9 and 12; ADR-0054 crossings 4 and 5; ADR-0080 Part 2; ADR-0088 decision 2.
- GitLab documentation on CI/CD components,
include: remotewithintegrity, merge request notes and approvals,CI_JOB_TOKEN, project access tokens, Code Quality, JUnit reports, external status checks and external controls (read 2026-10-13, outside this repository). - Azure DevOps pull-request status policy; Bitbucket Cloud Code Insights (read 2026-10-13).