GitHub addressing & representation model (E10-S00)¶
Status: written, awaiting maintainer LGTM (E10-S00 is
[autonomous · design · LGTM]). Governing ADR: ADR-0021 items 5–8 — this document executes them. Nothing here contradicts items 5–8, so ADR-0021 is not amended. Inputs: forge-dossier-github.md (the spec input, not guesswork), forge-dossier-gitlab.md, ADR-0015 §1/§2/§4/§8, ADR-0017 §1/§3, ADR-0020, GUIDELINES "Safety invariants". Raises: OQ-33 —protected-pipeline-sourcehas no decidable predicate on either forge today; OQ-34 — whether an adapter-computed CODEOWNERS eligible set can ever befullunder ADR-0017 §3's "typed eligible principals".Revision (post-review): the first version graded GitHub
eligible-approval-evidenceassupported{full}. That contradicted this document's own named spec input (dossier §2 grades the propertypartial) and was the exact "papered over with a heuristic" outcomeREQ-E10-S00-02forbids. It is nowunknown→ OQ-34. Q2's row-9 section keeps the wrong answer visible rather than editing it away, because how it was wrong is the reusable part.
This is E10 story zero: four questions answered on paper before the port signature freezes.
Each answer names the conformance case that will prove it. Case IDs are minted here and
implemented in S01/S07/S14 — internal/ is not this story's to touch, so no row is added to
internal/forge/conformance/catalog.yaml by this change.
Evidence classes used throughout, so a reader can tell a fact from a plan:
| Tag | Meaning |
|---|---|
code |
read from this tree at the cited line, this change's HEAD |
dossier |
cited endpoint/field in forge-dossier-github.md (docs snapshot 2026-07-21) |
unverified |
plausible but not confirmed against a live forge — resolves to unknown |
Q1 — How does the port name the head content of a fork PR?¶
Decision: confirm ADR-0021 item 5. The governed subject is addressed relative to the
merge request, never by (project, branch-name). forge.RunPort gains
FileAtBase(mr, path string) ([]byte, error)
FileAtHead(mr, path string) ([]byte, error)
and FileAtRef(project, path, ref string) survives unchanged for ref-addressed decision
inputs. The two accessors are not alternatives: item 5 decides which is legal where, and the
split is the trust boundary of GUIDELINES Safety 3 / ADR-0015 §1.
The concrete failure this prevents¶
code: cmd/assent/run.go:281 reads the governed base at info.TargetSHA and :285
reads the governed head at info.SourceSHA (both pinned by REV1-S01 / D-183; the refs were
branch names when this model was written), both through fileAtRefOrAbsent
(run.go:476, calls at :281/:285), which maps forge.ErrNotFound to nil bytes. forge.MRInfo
(internal/forge/port.go:40) carries SourceBranch but no source-repository identifier.
On GitHub a fork PR's head branch lives in the fork, not the base repository, so a ref
inside the base project does not address it, and that read 404s →
nil → change.OneSidedLifecycle(base, nil) → (KindDelete, true) → a fabricated
whole-file DELETE the contributor never made: a spurious BLOCK, or an APPROVE reached on
invented change semantics.
Verified call-site inventory (this is the load-bearing part)¶
ADR-0021 warns "verify against the tree before relying on either list", and the check was worth running — two of its line anchors have drifted. As of this change's HEAD:
| Call site | Reads | Ref | Disposition |
|---|---|---|---|
run.go:281 |
governed subject, base side | info.TargetSHA |
migrates → FileAtBase(mr, path) |
run.go:285 |
governed subject, head side | info.SourceSHA |
migrates → FileAtHead(mr, path) |
run.go:208 |
MergePolicy | info.TargetSHA |
stays FileAtRef |
run.go:216 |
RulesetBinding | info.TargetSHA |
stays FileAtRef |
run.go:235 |
.assent/config.yaml (provider-host declarations) |
info.TargetSHA |
stays FileAtRef |
run.go:254 |
policy pack | info.TargetSHA |
stays FileAtRef |
provider_host.go:82 |
provider host declaration | targetRef |
stays FileAtRef |
provider_host.go:292 |
resource-owner registry | targetRef |
stays FileAtRef — D-130 |
Exactly two call sites migrate; six must not.
Anchor drift, attributed to the document that actually carries it — this matters because S02 reads the epic spec as its own DoD, so a correction filed against the wrong document leaves the stale anchor exactly where an implementer will trip on it:
| Stale anchor | Carried by | Correct value |
|---|---|---|
provider_host.go:275 (the D-130 resource-owner registry read) |
ADR-0021 — Decision items 1 and 5 | provider_host.go:292; :275 now falls inside that function's doc comment |
Describe(project, mr string) |
ADR-0021 — Decision item 1 | the tree's method is GetMR(project, mr) (run.go:68) |
provider_host.go:246 refFilePort |
openspec/specs/p5-e10-github-forge/spec.md:257 (E10-S02's DoD) — the string 246 appears nowhere in ADR-0021 |
provider_host.go:263; fixed in this change, since the epic spec is in this story's fence |
So ADR-0021's genuine drifts are two, not three, and the third belongs to the epic spec.
run.go:64 forgePort (spec.md:256) is correct and needs no change.
run.go:235 and provider_host.go:292 deserve the specific mention ADR-0021 gives them:
the first carries provider-host declarations, so migrating it would let a fork's head
redefine its own fact semantics; the second decides who may approve, and moving it onto
an MR-relative accessor re-opens D-130 verbatim. Migrating any of the six is a trust-boundary
regression, not a cleanup.
Out of scope for the migration, noted so S02 does not trip over it: when --checkout is set,
run.go:293-296 overrides both sides from the local tree (EFE-S03 / ADR-0008 §4). That path
never touches the forge and is unaffected.
How each adapter reaches the head (adapter-internal freedom)¶
Both forges expose an MR-relative head ref inside the target project, which is why this shape is neutral rather than GitHub-shaped:
- GitHub —
GET /repos/{o}/{r}/contents/{path}?ref={pr.head.sha}; the fork's head commit is reachable in the base repo asrefs/pull/{n}/head(source: ADR-0021 item 5, not the dossier — C13 namesrefs/pull/N/merge, a semantically different ref carrying merged content, not head content;unverifieduntil S06 confirms the head ref live). Addressing by the head SHA rather than the ref name is preferred: it is the same value pinned intopins.sourceSha, so the record and the read cannot disagree. - GitLab — the source project id from the MR object, or the target project's
refs/merge-requests/{iid}/head; either way the adapter already holds the MR object.
Two implementations are explicitly forbidden, because both re-mint the defect:
FileAtBase/FileAtHead delegating to FileAtRef(project, path, info.SourceBranch), and
smuggling refs/pull/N/head into MRInfo.SourceBranch (it corrupts a documented field and
leaks into rendering — ADR-0021 item 5).
Conformance cases¶
| Case id | Test name | Proves |
|---|---|---|
fork-head-unchanged-file-no-lifecycle |
TestConformanceForkHeadUnchangedFileNoLifecycle |
a fork MR whose governed file is unchanged yields no lifecycle event — the fixture's head branch name must not exist in the base repo, or the case cannot fail |
fork-head-genuine-delete-detected |
TestConformanceForkHeadGenuineDeleteDetected |
positive control: a fork MR that really deletes the governed file still mints KindDelete |
The positive control is not optional. Without it, "never mint a DELETE from a fork" is satisfiable by never minting a DELETE at all — the unfailable-assertion shape this repo's review gate finds more often than any other defect, and the same pairing E10-S12 already mandates for capability gaps.
Q2 — What operationally decidable predicate makes each capability supported, per forge?¶
Decision. Every one of the dossier §4 flags gets a predicate of exactly one of two kinds.
Anything that is neither is unknown, and unknown is treated as absent for arming
(ADR-0021 §3).
P— probed. A named read-only endpoint, a named response field, and a comparison, evaluated per run. A read that fails for permission reasons yieldsunknown, neverabsent(see Q4 — this is where Q4's mapping earns its keep).C— contract-proven. The adapter returns a constant, and that constant is licensed by a named passing conformance case for that adapter. No case, no constant: the flag isunknown. This is the rule that makes "we assume the API supports it" legal exactly once — when someone wrote the case — and it is what today's adapter is missing (below).
Scope of unknown ⇒ never arm. It binds per capability, at its consultation point, not
"all eleven must be supported". code: internal/forge/precondition.go:47-82 consults
exactly three today — protected-pipeline-source, threads-block-merge and
eligible-approval-evidence. S04 must state the arming set explicitly; each capability
promoted into it that is currently assumed-true becomes an honest gap and owes a decision row
(epic judgment call (e)).
The eleven flags¶
| # | Capability | GitLab predicate | GitHub predicate | Kind |
|---|---|---|---|---|
| 1 | resolvable-threads |
constant supported, licensed by the existing p3e5-* reconciliation cases |
constant supported, licensed by threads-resolvable-graphql / TestConformanceThreadResolveRoundTrip (reviewThreads.isResolved / resolveReviewThread, dossier C1/C2) |
C |
| 2 | threads-block-merge |
GET /projects/{id} → only_allow_merge_if_all_discussions_are_resolved == true (code: snapshot.go:290) |
GET /repos/{o}/{r}/branches/{base}/protection → required_conversation_resolution.enabled == true, or the equivalent ruleset row from GET /repos/{o}/{r}/rules/branches/{base}; both must be consulted (endpoints dossier C3 + §2 step (a); field name unverified) |
P |
| 3 | blocking-review |
absent — GitLab has no REQUEST_CHANGES primitive; ADR-0017 §3 uses threads (dossier C4) |
required_pull_request_reviews.required_approving_review_count >= 1 on the base branch; without required reviews a REQUEST_CHANGES review does not block (dossier C4) |
P |
| 4 | review-dismissal-restrictions |
absent — no analogue |
required_pull_request_reviews.dismissal_restrictions non-empty and users[].id ∪ expand(teams[]) ∌ pr.user.id; team expansion via GET /orgs/{org}/teams/{slug}/members (dossier C8′) |
P |
| 5 | sha-guarded-merge |
constant supported, licensed by sha-guard-source-moved / sha-guard-target-advanced (already in catalog.yaml) |
constant supported, licensed by a GitHub-factory run of the same two cases — PUT /pulls/{n}/merge with sha, 409 on mismatch (dossier C10) |
C |
| 6 | deferred-merge-arming |
constant supported (MWPS is tier-independent, dossier C11) |
GET /repos/{o}/{r} → allow_auto_merge == true; enablePullRequestAutoMerge fails without the repo setting (setting dossier C11; field name unverified) |
C / P |
| 7 | arming-revoked-on-push |
constant supported — any new commit cancels MWPS (dossier C11) |
supported only when the substitute is configured — stale-approval dismissal and at least one required status check; a write-access push does not auto-disarm GitHub auto-merge (dossier §3 delta 2). Otherwise absent. (Field names unverified — C19 names the setting, not the API field) |
P |
| 8 | merge-result-pinning |
GET /projects/{id} → merge_trains_enabled == true (code: snapshot.go:291) |
base-branch ruleset from GET /repos/{o}/{r}/rules/branches/{base} contains the merge-queue rule (endpoint dossier C14; rule-type string unverified) |
P |
| 9 | eligible-approval-evidence{full\|aggregate} |
full iff GET /projects/{id}/merge_requests/{iid}/approval_rules returns rules with eligible_approvers[] (code: gitlab/snapshot.go hasApprovalRulesAPI); else absent |
unknown — see below. The CODEOWNERS route is legitimate (ADR-0017 §3 names it) and its preconditions are probeable (require_pull_request_reviews.require_code_owner_reviews == true, CODEOWNERS readable at the base ref, teams expandable via GET /orgs/{org}/teams/{slug}/members), but none of them decides the property full denotes — that the adapter-computed eligible set equals the forge's. No forge-readable predicate exists → unknown, promoted only by the fidelity case named below (OQ-34) |
P → unknown |
| 10 | approval-reset-on-push{default\|opt-in} |
GET /projects/{id}/approvals → reset_approvals_on_push == true — currently unprobed (audit RELI-03; unverified — the field is named by RELI-03, not read from this tree) |
stale-approval dismissal enabled on the base branch; the stronger variant is "require approval of the most recent reviewable push" (dossier C19/C8; field names unverified) |
P |
| 11 | protected-pipeline-source |
no predicate today — see below | no predicate today — see below | — |
On the unverified tags. The dossier is an endpoint study: for several rows it cites the
GitHub UI setting and the endpoint that carries it, but not the JSON field name. Those
field names are marked unverified rather than dressed up as dossier, and under this
document's own rule they resolve to unknown — hence non-arming — until S09 confirms each
against a live response. That is the fail-closed direction and it costs nothing: a cell that
turns out to be right is promoted at S09; a cell that turns out to be wrong never armed
anything. Rows 3, 4 and 9's GitHub field names are genuinely enumerated in dossier §2 and are
not tagged.
Row 9 decides whether GitHub can ever gate — and the honest answer is unknown¶
A first draft of this document declared GitHub row 9 supported{full} on three conjuncts:
require_code_owner_reviews == true, CODEOWNERS readable at the base ref, and every referenced
team expandable. That was wrong, and the way it was wrong is worth recording, because it is
the failure mode REQ-E10-S00-02 names: each conjunct establishes that a CODEOWNERS file
exists and that GitHub cares about code owners. None of them establishes what full
denotes — that the adapter's computed eligible set equals the set GitHub would accept.
That property has no forge-readable predicate, which is exactly the situation row 11 handles
correctly. Two identical gaps had opposite treatments; the asymmetry, not the CODEOWNERS route,
was the defect.
Decision: GitHub row 9 is unknown for v1, promoted only by a named fidelity case (below).
What is and is not in dispute¶
CODEOWNERS is a legitimate route — this is not the objection. ADR-0017 §3 names it
explicitly: require-review is "satisfied only by forge-proven eligible approval (approval
rules / CODEOWNERS evidence, typed eligible principals)". code: the frozen schema agrees —
schemas/approval/v1alpha1/approval-evidence.schema.json:40-42 enumerates
verifyingCapability as exactly ["approval-rules-api", "codeowners", "none"], and
internal/core/aggregate/approval.go:126-133 admits "codeowners" as a real capability,
distinct from none. So no schema change and no ADR amendment is implied by the route itself.
The objection is §3's other clause — "typed eligible principals". GitLab's
eligible_approvers[] is forge-computed; a GitHub CODEOWNERS set is adapter-computed
from forge-supplied bytes. What the adapter contributes is a matcher, and a matcher can be
wrong in a direction that is not fail-safe.
The concrete slip-through, spelled out because a vaguer statement would not have caught the
first draft. An over-permissive matcher — first-match-wins instead of last-match-wins, an
unhandled section or negation syntax, case-folding, or a team-membership read returning a
superset of actual write-holders — puts a principal into ev.Eligibility that GitHub would not
accept. approvalSatisfies then finds the approver's id in the eligible set
(approval.go:127, :150-158) and records the obligation satisfied. No arming is needed
for the harm: the DecisionRecord — the product's whole thesis — asserts that a governance
obligation was met by a principal the forge does not recognise as eligible. GitHub's own
require_code_owner_reviews is not a backstop, because it enforces over the PR's changed
files while assent's eligible set is scoped to the governed subject; the two sets need
not coincide.
Reconciling the spec input, which graded this partial and was right¶
docs/planning/forge-dossier-github.md §2 closes: "Verdict for ADR-0017 §3: GitHub can prove
that eligible approval exists (aggregate reviewDecision + protection config) — partial
on proving which typed principal satisfied which rule." The capability's sub-values
are {full|aggregate}, and full is precisely the "which typed principal" property the
dossier grades partial. The first draft's full contradicted its own named spec input.
unknown is the grading that agrees with it: not absent (the route exists and the evidence is
partly forge-supplied), not full (unproven), and non-arming under unknown ⇒ never arm.
This also confirms rather than refines ADR-0021's Consequences prediction. The ADR predicted
eligible-approval-evidence would plausibly report unknown on GitHub forever, and its stated
reason was dossier §2 step (b) — the CODEOWNERS step, not the aggregate route. That reason
stands.
The aggregate sub-value satisfies nothing, on either forge¶
GraphQL reviewDecision: APPROVED proves the forge enforced its rule but names no principal.
Fed to the engine it is VerifyingCapability: "none" → a recorded capability gap that never
satisfies (approval.go:129-130). Recording aggregate as supported would be the
paper-over REQ-E10-S00-02 forbids by name. This is symmetric with GitLab Free, which is
absent for the same reason — no named eligible set — and already refuses to arm
(precondition.go:70-78).
What would promote row 9 to full — named, not hand-waved¶
unknown is a state with an exit, and S08 owns it. Two candidate routes, neither specifiable
as a proof today, which is why this is OQ-34 rather than a decision:
- Fixture-corpus fidelity case —
codeowners-eligible-set-matches-forge/TestConformanceCodeownersEligibleSetMatchesForge, over a corpus covering last-match-wins, sections, negation, case sensitivity and team expansion, paired with a positive control proving the case reddens on a deliberately over-permissive matcher (this document's own E10-S12 pairing rule). Limit, stated: it proves the matcher against fixtures, not against GitHub's live computation — fidelity to the spec, not equality with the forge. - Live cross-check against GitHub's own code-owner determination for the PR
(
unverified). Limit, stated: any such signal is scoped to the PR's changed files and is mutable after PR open, so it is corroboration, not authority.
Until one is accepted and discharged, row 9 stays unknown and GitHub does not satisfy
require-review — the same standing as GitLab Free, which ships today. Consequence: with
rows 9 and 11 both unknown, v1 GitHub comments and does not gate. That is the fail-closed
outcome the project's thesis prefers over gating on an unproven set.
One asymmetry owned rather than left to be found: "codeowners" is accepted by the engine
and legal in the frozen enum, but no adapter produces it today — code: the only writer is
internal/forge/gitlab/resolve.go:85, which emits "approval-rules-api". GitHub would be its
first producer, which is part of why the fidelity case is owed before, not after.
Row 11 is the honest failure, and it is worse than "GitHub lacks it"¶
code: internal/forge/gitlab/snapshot.go:292 computes GitLab's arming prerequisite as
caps.ProtectedPipelineExternal = strings.Contains(proj.CIConfigPath, "@")
A substring test for @ is a heuristic, not a predicate: it shows the CI config names
another project, and proves neither that the referenced file sits on a protected branch nor
that the MR author cannot push to it. That is the SEC-04 shape ADR-0021 forbids by name — and
it is currently the single load-bearing arming prerequisite (precondition.go:54-62,
RefusalInsecureTopology). Under this model's rules, protected-pipeline-source is
unknown on GitLab today, not supported.
A real GitLab predicate exists but costs several reads on a second project (ci_config_path
resolves to project X → GET /projects/X/protected_branches covers the file's branch →
the MR author lacks push access there), and degrades to unknown whenever the token is not
scoped to X.
On GitHub there is no single readable field — but "no readable analogue at all" would
overstate it against this document's own cited input. ADR-0015 §4 already frames the GitHub
condition, and in composite terms: the assent job must come from a protected source —
"GitHub: workflows from the target branch (pull_request runs the base-ref workflow for
forks; same-repo branches need branch protection on workflow paths)" — and §4 states it is
"verified by assent doctor". Three candidate predicates follow, and OQ-33 asks which the
operator accepts:
- Same-repo route (§4's own second clause, largely forge-readable). Branch protection or a
path-restricted ruleset covering
.github/workflows/**on the base branch, so a same-repo PR author cannot alter the definition that gates them. This is the closest thing to a GitLab-style forge-only predicate and §4 already names it; its readability limits (path-scoped push rulesets are org/Enterprise-shaped) areunverified. - Fork route (§4's first clause). Under
pull_requestfrom a fork GitHub runs the base-ref workflow with a read-only, secretless token (dossierC17). Safe, but advisory-only per ADR-0015 §8 — a non-arming state, not a gap, and therefore not a route tosupported. - Composite env+forge route (
unverified): the trigger event is one whose workflow definition GitHub loads from the base repository's default branch (pull_request_target/workflow_run/merge_group) and that branch requires reviews. This spanscmd/assent's CI-env adapter andinternal/forge, which is the part §4 did not anticipate being asked to probe rather than document.
Consequences, stated rather than discovered:
- Under
unknown ⇒ never arm, v1 GitHub comments but does not arm unless OQ-33 is answered. ADR-0021 named this as a plausible shipped outcome; this document confirms it. - Retiring the
@heuristic makes GitLab arming paths that pass today stop passing. That is the intended surfacing (judgment call (e)) and it owes its own decision row at S04/S09 — not here, because S00 changes no behaviour. - Route 1 may be forge-readable enough to stand alone; routes 3 spans
cmd/assent's CI-env adapter andinternal/forge. Which of these ADR-0015 §4 accepts as a probe — as opposed to documentation verified by hand — is not this story's to decide → OQ-33.
What GitLab actually probes today (judgment call (e), made concrete)¶
code: forge.CapabilityFlags (internal/forge/snapshot.go:83-92) is five booleans plus a
tier. Mapped onto the eleven:
| Probed honestly (3) | Heuristic (1) | Hardcoded true (1) |
Unprobed / implicitly assumed (7) |
|---|---|---|---|
| 2, 8, 9 | 11 | MergeResultDigestRecordable (the record-only axis of 8, snapshot.go:268) |
1, 3, 4, 5, 6, 7, 10 |
Seven flags become explicit unknown at S04 unless a conformance case licenses a C
constant. Rows 1 and 5 already have licensing cases; rows 3 and 4 are honestly absent on
GitLab; rows 6, 7 and 10 need either a case or a probe. This is the "surfacing is the point"
consequence ADR-0021 promised, with a count attached.
Conformance cases¶
| Case id | Test name | Proves |
|---|---|---|
threads-resolvable-graphql |
TestConformanceThreadResolveRoundTrip |
licenses row 1's GitHub C constant — post a thread, resolve it via GraphQL, read isResolved. No case, no constant: the flag reports unknown |
capability-report-exhaustive |
TestConformanceCapabilityReportExhaustive |
every enum member has an entry; a new member without one fails to compile or fails the case |
capability-unknown-never-arms |
TestConformanceUnknownCapabilityNeverArms |
unknown at a consultation point refuses arming, merges == 0 |
capability-supported-does-arm |
TestConformanceSupportedCapabilityArms |
positive control, merges == 1 — without it the previous case is vacuous (E10-S12's rule, applied one story early) |
Q3 — Where does an eleven-capability report live in the record?¶
Decision: confirm ADR-0021 item 8, option (ii) — the multi-capability report is doctor
output and arming-refusal reasons only, never the DecisionRecord. git diff schemas/ == 0
holds; no schema change is required or proposed by this story.
The schema, read rather than quoted¶
code: schemas/decision/v1alpha1/decision-record.schema.json $defs.pins (lines 65-101) is
additionalProperties: false; capabilityGap is {"type": "string", "minLength": 1} — no
enum, no pattern — and the allOf at 94-100 requires it iff mergeResultDigest is null and
forbids it otherwise. The top-level object is additionalProperties: true.
Three consequences follow directly, and the second is the one a reviewer will reach for:
capabilityGapmodels one capability, merge-result pinning. (A prior draft's claim that it "already models the absent-capability case" was False and stays retired.)- Comma-joining eleven gaps into that string must be refused — but the schema does not
refuse it today, and saying otherwise would be the same over-claim this document is trying
to avoid. Such a string passes validation; it is a semantic lie against the field's own
description; and the
allOfat:94-100forbids the field only whenmergeResultDigestis pinned.code:cmd/assent/run.go:368callsdecision.MergeResultGap(...)unconditionally, somergeResultDigestis null and the channel is open in 100% of runs today. The schema clause is therefore a latent guard that goes live only once an adapter pins a real merge-result digest (E10-S03/S07 on a merge train or merge queue). What closes the loophole in the meantime is not the schema — it is the conformance case named below, which is why that case had to be rewritten. - There is therefore no gap-selection problem to solve. The field is not a general gap channel that eleven candidates compete for; it is reserved for one capability, and the remaining ten have a different home. No priority order is needed, which is the desirable answer under AGENTS.md rule 7 — a total, deterministic selection over eleven contenders would have been one more thing to get wrong.
Recording the report in the open top-level object is rejected, per item 8: a safety-bearing field that no schema validates and no consumer must read is a fail-closed guarantee in name only.
The audit-trail cost, stated plainly — and already visible¶
The cost is real: a capability gap that blocks a merge leaves no trace in the DecisionRecord
beyond the existing single capabilityGap string. It is also pre-existing, not introduced
by E10, and the tree shows exactly where:
code:internal/core/aggregate/aggregate.go:193already computesResult.CapabilityGaps map[string]string(per governed subject,capabilityGapNone = "approval-capability-none"), and its own comment says S10 would thread it intopins.capabilityGap. It never did —cmd/assentreadsCapabilityGapsindoctor.go/doctor_forge.goonly, never in the record path.code:cmd/assent/run.go:368sets the record'scapabilityGapto one hardcoded merge-result string, unconditionally.
So an approval-capability gap already fails closed and already leaves no record trace. E10
does not create that hole; it inherits it, bounds it, and names where it lives. Revisiting
option (i) — widening pins — is a v1alpha2 conversation, unchanged.
Where the report does live¶
forge.CapabilityReport (S04) → doctor's typed report (ADR-0017 §9, the existing
PreconditionProbe.CapabilityGaps / Refusals shape in internal/forge/precondition.go) and
the arming-refusal reason surfaced to the operator. Both are unfrozen internal surfaces.
Conformance case¶
| Case id | Test name | Proves |
|---|---|---|
capability-gap-string-is-merge-result-only |
TestConformanceCapabilityGapCarriesOnlyMergeResult |
a run with N additional absent/unknown capabilities emits a pins.capabilityGap byte-identical to the same run with none of them, while the doctor report carries all N. This is the case that reddens on the comma-join adapter |
capability-gap-positive-control |
TestConformanceCapabilityGapStillRecordsMergeResult |
positive control: the merge-result gap itself is still recorded, so the case above is not satisfiable by emitting an empty string |
The case this replaces — "the record still validates against the frozen schema" — was
unfailable, and the reason is worth keeping: code, cmd/assent/run.go:394 already
validates every record against the schema before any write, so that clause is enforced
unconditionally by production code, and a comma-joining adapter would have passed it.
Q4 — What is each adapter's HTTP-status → port-sentinel mapping?¶
Decision: confirm ADR-0021 item 6, with the disambiguation made concrete. ErrNotFound
means absent, never forbidden. forge.ErrUnauthorized is lifted to the port at S02
(code: it exists today only as internal/forge/gitlab/gitlab.go:238, adapter-private, while
forge.ErrNotFound is already neutral at internal/forge/port.go:34).
The precedent to stay consistent with is AUD2-S02 / REL-03: code:
cmd/assent/provider_host.go:84 and :299 (:276-283 is that function's doc comment, which
explains the gate; :299 is the gate) — the fallback is gated on
errors.Is(err, forge.ErrNotFound) alone, so a 503, a throttle or a token scoped away
from the repo can no longer masquerade as an absent file. This story extends the same
discrimination one layer down, into the adapter that mints the sentinel.
GitLab (verified against the tree)¶
| Status | Sentinel | Note |
|---|---|---|
| 200 | content | code: gitlab.go:484 (the 404 → ErrNotFound branch is :486) |
| 401 / 403 | ErrUnauthorized |
code: gitlab.go:238, :792 |
| 404 | forge.ErrNotFound (absent) |
safe: GitLab distinguishes 403 from 404 for files |
| 429 / 5xx | transport error | retry/backoff per AUD-S11; never a sentinel |
| other | error | never absence |
GitHub (the trap)¶
GitHub returns 404 for permission-denied resources to avoid leaking existence, so a bare
404 is not evidence of absence. The adapter must disambiguate before it may return
ErrNotFound:
| Status | Context | Sentinel |
|---|---|---|
404 on /contents/{path} |
the content-scope probe below returned 200 for this token at this ref | forge.ErrNotFound — absent |
404 on /contents/{path} |
the content-scope probe did not return 200 | ErrUnauthorized. Never ErrNotFound |
404 / 403 on GET /repos/{o}/{r} |
— | ErrUnauthorized. No read below it may claim absence |
404 on /branches/{b}/protection or /rules/** |
token lacks admin read (GET /repos/{o}/{r} → permissions.admin != true) |
not a sentinel — the capability probe yields unknown (Q2). GitHub 404s an unprotected branch and an unauthorized read with the same status |
403 with x-ratelimit-remaining: 0 or Retry-After |
primary/secondary rate limit | transport error, retryable; never a sentinel (unverified on exact header set) |
| 403 otherwise | — | ErrUnauthorized |
| 401 | — | ErrUnauthorized |
409 on PUT /pulls/{n}/merge |
sha mismatch |
typed SHA-guard precondition failure (dossier C10), not absence |
405 on PUT /pulls/{n}/merge |
not mergeable | typed precondition failure, not absence |
| 422 | validation | error, never absence |
| 429 / 5xx | — | transport error |
The content-scope probe, and why it is not a repo-metadata probe. The obvious
disambiguation — "GET /repos/{o}/{r} returned 200, so the token can read this repo, so a
content 404 is path absence" — is wrong for a token shape E10-S06 explicitly supports. A
fine-grained PAT with metadata: read and without contents: read gets 200 on the repo
object and 404 on every content read: under that rule, forbidden renders as absent and the
P0 this question exists to close re-opens through the front door.
The probe must therefore exercise the same permission as the read it is licensing: a
sibling content read in the same repo at the same ref — the root listing,
GET /repos/{o}/{r}/contents?ref={sha} — returning 200 proves contents: read is granted
there, and only then is a 404 on the specific path genuine absence. The probe is cacheable
per (repo, ref) for the run, so it costs one request, not one per governed read. It also
fails in the right direction on an unresolvable ref: a bad ref 404s the root listing too, and
"the ref does not exist" is correctly not path absence. Diagnostic caveat for S06: that
case is safe but its operator-facing message would be wrong — an unresolvable ref is not a
permission failure, so the adapter must word the error as "content unreadable at ref X:
absent and forbidden could not be distinguished", never as a bare authorization message. The
sentinel choice is fail-closed either way; only the wording is at stake. (unverified — the exact Contents API
behaviour for the root-listing form is an E10-S06 live check; until confirmed, the residual
rule below applies and the adapter errors.)
Residual rule (ADR-0021 item 6, restated as the fallback): for any endpoint where the
adapter cannot distinguish absent from forbidden, it returns an error, not absence. The
content-scope probe is what converts "cannot distinguish" into "can" for governed-file reads,
and the permissions.admin probe does the same for the protection/ruleset reads; anything not
covered by a row in this table takes the residual rule.
Conformance cases¶
| Case id | Test name | Proves |
|---|---|---|
forbidden-never-renders-as-absent |
TestConformanceForbiddenNotAbsent |
a governed-file read the forge refuses on permission grounds aborts the run with zero forge writes — never nil content, never a lifecycle event |
absent-file-still-renders-as-absent |
TestConformanceAbsentFileIsAbsent |
positive control: a genuine 404 inside a readable repo still yields forge.ErrNotFound, so a real whole-file ADD/DELETE is still detected (EFE-S03 preserved). Without this, "always error" would pass the case above |
ratelimit-403-is-transport-error |
TestConformanceRateLimit403NotAbsent |
a rate-limited 403 is retried/errored, never mapped to a sentinel |
metadata-only-token-is-not-absence |
TestConformanceMetadataOnlyTokenNotAbsent |
the fixture answers GET /repos/{o}/{r} 200 and every /contents/** read 404 (the fine-grained-PAT shape). The run must abort, not mint a lifecycle event — this is the case that refuses the repo-metadata probe |
Forward obligations this document creates¶
| # | Owed by | Obligation |
|---|---|---|
| 1 | S01 | the twelve case ids above appear in catalog.yaml with an adapter disposition (S01-03's strict-decode adapter list). Now enforced, not merely requested: this obligation is written into E10-S01's DoD in spec.md, and S07/S14's DoDs name the cases they must run — the Verify: lines otherwise depend on an implementer voluntarily reading a planning doc. codeowners-eligible-set-matches-forge is a candidate, not minted: it is OQ-34's promotion route |
| 2 | S02 | two call sites migrate, six do not; forge.ErrUnauthorized lifted to the port; ADR-0021's two stale anchors absorbed (provider_host.go:275→:292, Describe→GetMR). The third, provider_host.go:246→:263, lived in E10-S02's own DoD (spec.md:257) and is already fixed by this change |
| 2b | S02 | resolve FileAtBase(mr, path)'s missing project binding before freezing the signature. FileAtRef takes project as a parameter and today's client binds none, so ADR-0021's two-argument shape is under-specified: either the client binds the target project at construction (and the accessor is genuinely MR-relative) or mr becomes composite. Flagged here because a signature that freezes wrong is precisely what S00 exists to prevent; the choice is S02's, the ambiguity is not S02's to discover |
| 3 | S04 | the arming-relevant capability subset is stated explicitly; every C constant names its licensing case |
| 4 | S04 / S09 | retiring the @ heuristic (and any other capability promoted into the arming set) is a user-visible GitLab behaviour change → its own D-nnn row + changelog entry, per epic judgment call (e). Not minted by S00, which changes no behaviour |
| 5 | operator | OQ-33 — protected-pipeline-source predicate (three candidate routes, one of them §4's own largely-forge-readable same-repo route); until answered, v1 GitHub does not arm |
| 6 | operator / S08 | OQ-34 — whether an adapter-computed CODEOWNERS eligible set can be full. Until answered, GitHub row 9 is unknown and GitHub does not satisfy require-review, the same standing as GitLab Free. With rows 9 and 11 both unknown, v1 GitHub comments and does not gate |
Claims deliberately not resurrected¶
Both were found False by the 2026-08-10 adversarial review and are recorded here so a later reader does not re-derive them:
- Extracting the conformance suite does not unblock
catalog.yaml'sgithub-deferredrows — both arelevel: L3, package: test/e2e, gated on live GitHub infrastructure (S18). capabilityGapdoes not already model the absent-capability case generally; it models merge-result pinning, which is why it is singular and coupled tomergeResultDigest.