ADR-0020: Forge snapshot changed-file completeness contract¶
| Status | Accepted (D-119 — implemented in P5-AUD-S01) |
| Date | 2026-08-06 |
| Deciders | Konrad Heimel |
| Context links | ADR-0008 §4 · ADR-0015 §1 · ADR-0017 §1 (honest capability gaps) · D-042 · D-076 · D-077 · REL-07 (PROJECT-AUDIT-2026-08-06) |
Context¶
In --checkout-less runs, the forge Snapshot's changed-file list is the sole
.assent/** detector beyond the governed subject (cmd/assent/run.go snapshot fold →
foldSnapshotPaths). The GitLab adapter's mrChangedFiles issues a single unpaginated
GET /merge_requests/:iid/changes, decodes neither overflow nor changes_count, and
maps 404 to an empty list. GitLab truncates the changes array at instance diff limits
(and has deprecated /changes in favour of the paginated /diffs endpoint). An MR padded
past the limit — contributor-controlled — can therefore carry a .assent/** policy edit
invisible to the fold, starving GUARD 1 (D-042: an MR cannot vouch for itself) and
permitting approve+CAS-merge of a self-modifying MR. This is the fail-open class the
reserved-class guard exists to prevent. Checkout mode is unaffected (D-077: the local
tree is the sole authority there).
The decision needed now: the completeness contract for snapshot changed-file enumeration, and the exact failure semantics when completeness cannot be proven.
Options¶
| Option | Pros | Cons |
|---|---|---|
(a) Keep /changes; decode overflow + changes_count; truncation and 404 → hard error |
Smallest diff; strongest-looking closure | Deprecated endpoint; hard error emits no DecisionRecord and no reviewer-visible thread; contributor-inducible truncation becomes a CI-red DoS lever |
(b) Migrate to paginated /diffs with a page ceiling; ceiling → hard error |
Supported endpoint; bounded requests | No independent count cross-check; same silent-to-reviewers hard-error semantics |
(c) Hybrid: paginated /diffs + changes_count cross-check; unprovable completeness → typed gap → opaque changeset → REVIEW; 404/non-200 → hard error |
Supported endpoint; two independent completeness signals; failure lands on the existing changeset.undecidable axis with an auditable record and a resolvable thread; merge remains impossible |
Larger diff; N/100 requests for large MRs |
| (d) Do nothing | Zero cost | Leaves the P1 fail-open open |
(e) Truncation → forced ClassAssentPolicy (BLOCK, GUARD-1 zero writes) |
Maximally conservative label | Asserts a self-edit that may not exist; GUARD 1 suppresses even the explanatory thread — least informative outcome for an uncertain state |
Decision¶
We chose (c): snapshot changed-file enumeration must either prove completeness or
declare an honest gap that degrades the run to fail-safe REVIEW, because an
unenumerable change set is epistemically identical to an opaque diff and must land on the
same frozen axis (changeset.undecidable → REVIEW, record emitted, thread posted, never
approve/merge) — accepting the cost of paginated requests and a larger adapter diff, over
(a)/(b) whose hard errors are silent to MR reviewers and lose the audit record, and over
(e) which asserts facts not in evidence while suppressing all reviewer-facing output.
Contract, normatively:
- Neutral type —
forge.SnapshotgainsChangedFilesComplete boolandChangedFilesGap string(ChangedFilesGapnon-empty iffChangedFilesComplete == false; mirrors the ADR-0017 §1mergeResultDigest/capabilityGaphonesty pattern). Every adapter and fake must set them explicitly. - GitLab mechanics —
mrChangedFilesmigrates toGET /merge_requests/:iid/diffs?per_page=100&page=Nfollowing pagination to a hard ceiling of 100 pages (10,000 files). Completeness requires ALL of: the enumeration terminated below the ceiling; the MR'schanges_count(from the MR GET, a string) parses as a plain integer with no+suffix; and that integer equals the number of enumerated diff entries. Any violation (ceiling hit,+suffix, count mismatch in either direction, or a decoded per-entryoverflow-class marker) →ChangedFilesComplete=falsewith a specificChangedFilesGapreason. - Hard errors — HTTP 404 or any non-200 on the diffs endpoint is a hard error
(
Snapshotreturns an error; the run exits non-zero with zero forge writes). The MR provably exists at this point (mrWithAuthorsucceeded), so a missing diff resource is forge anomaly, never evidence of an empty change set. The 404→empty-list mapping is removed. - Run semantics (
--checkoutunset) — whenChangedFilesComplete == false, the snapshot fold setschangeSet.Opaque = truewithOpaqueReason = "forge changed-file enumeration incomplete: " + ChangedFilesGap(in addition to the existing.assent/**dominance check over whatever paths WERE returned — a visible.assent/**path still dominates to BLOCK). The existingdecideshort-circuit then yields REVIEW with finding codechangeset.undecidable. No new finding code; the reason string carries the specifics. - Checkout mode — unchanged. D-077 local-tree authority stands; snapshot completeness is not consulted for classification there.
- Conformance obligations — the fake forge must model truncation (a knob forcing
ChangedFilesComplete=falsewith a gap reason, and a knob forcing diff-endpoint 404/5xx). The conformance suite gains, as required cases: truncated snapshot → REVIEW record + thread, zero approve/merge writes; truncated snapshot whose partial list still contains.assent/**→ BLOCK (GUARD 1, zero writes); diff-endpoint 404 → hard error, zero writes. The GitLab adapter gains httptest cases for pagination, ceiling,changes_countmismatch,+-suffix, and 404.
Consequences¶
- The D-042 self-vouch guard can no longer be starved by MR padding in snapshot mode; the worst attacker-achievable outcome becomes REVIEW (human in the loop) instead of auto-merge.
- Large MRs cost up to 100 paginated requests; acceptable (bounded, read-only), and the ceiling is a named constant so instances with higher diff limits can be accommodated by a future flag without contract change.
- Signal interplay: GitLab caps
changes_countwith a+suffix (commonly at 1000 files), so completeness becomes unprovable well below the 100-page/10,000-file ceiling — most of the ceiling headroom is unreachable in practice. This skews in the conservative direction only (the+-suffix violation degrades to REVIEW, never fail-open); implementers must not "fix" it by trusting the ceiling alone and dropping thechanges_countcross-check. forge.Snapshotwidens (additive Go struct change; not a serialized public schema). All fakes/tests must set the new fields explicitly. Go does not enforce struct-field initialization, so the real safety net is the zero value:ChangedFilesCompletedefaults tofalse, which fails SAFE (an adapter that forgets to set it degrades to REVIEW rather than fail-open), and the required conformance cases fail loudly against an adapter that never reports completeness./changesusage is eliminated ahead of GitLab's removal of the endpoint.- Reversible: revert the adapter to
/changes+overflowdecode (option (a)) without touching the neutral contract — points 1/3/4/5/6 are endpoint-agnostic.
Counterpoints considered¶
The strongest argument against is that REVIEW is "less closed" than a hard error: a run that cannot enumerate should arguably refuse to produce any decision at all. It did not win because both outcomes are equally merge-proof (REVIEW can never approve/merge), while the hard error strictly loses information: no DecisionRecord for the audit trail, no resolvable thread telling reviewers why assent stepped back, and a contributor- triggerable red-CI lever. The repo's frozen taxonomy already encodes this judgment — opaque diffs from the checkout fold degrade to REVIEW, not to exit 1 — and snapshot truncation is the same epistemic state arrived at over the network.