Forge behaviour dossier — GitLab (P1-E3-S01/S02)¶
Verified API mechanics for the Forge/Publisher/Reconcile port design (ADR-0005, ADR-0009 amendment, ADR-0015 §2/§4/§5, ADR-0017 §1/§3/§7). Every claim carries an endpoint citation (method + path + doc URL) and, per capability, the GitLab tier (Free/Premium/Ultimate) it is available on plus the fail-closed consequence of absence. Docs snapshot: docs.gitlab.com, 2026-07-21. Companion: forge-dossier-github.md (parity mapping).
Legend: F = Free, P = Premium, U = Ultimate. "Free" claims below refer to the licensed tier; where CE (unlicensed EE/Community Edition) semantics differ, the row says so explicitly.
1. Capability matrix¶
| # | Capability | Mechanism (method + endpoint) | Tier | Fail-closed consequence of absence |
|---|---|---|---|---|
| C1 | Post resolvable finding thread | POST /projects/:id/merge_requests/:iid/discussions (plain or diff-positioned via position[*]); notes carry resolvable: true — Discussions API |
F/P/U | n/a (present on all tiers) |
| C2 | Resolve / reopen a thread | PUT /projects/:id/merge_requests/:iid/discussions/:discussion_id?resolved= — resolvable by Developer+ or the MR author — Discussions API |
F/P/U | n/a. Note: author-resolvability is why challenge is acknowledgement only (ADR-0017 §3) |
| C3 | All-discussions-resolved merge gate | project setting only_allow_merge_if_all_discussions_are_resolved (PUT /projects/:id); surfaced as detailed_merge_status: discussions_not_resolved — Projects API, MR merge status |
F/P/U | gate absent → resolution has no merge-blocking force → no deferred auto-merge; assent doctor must verify the setting before arming (ADR-0009 amendment) |
| C4 | Approve / unapprove MR | POST /projects/:id/merge_requests/:iid/approve (optional sha= — mismatch → 409 Conflict); POST .../unapprove — Approvals API |
F/P/U | n/a. On Free, approvals are optional and never merge-blocking (see C6) |
| C5 | Read who approved (identities) | GET /projects/:id/merge_requests/:iid/approvals → approved_by[].user.{id,username}, approved_at, approvals_required/left, approved — Approvals API |
F/P/U | n/a. CE caveat: approved is true when at least one approval exists (no rule semantics); EE: true when configured rules are satisfied |
| C6 | Required approval rules (enforced) | project/MR approval rules; detailed_merge_status: not_approved blocks merge — Approval rules, Approvals overview ("GitLab Free … approvals are optional and don't prevent merging") |
P/U | capability gap → require-review never satisfiable → no auto-merge for archetypes needing it (OQ-23; §4 below) |
| C7 | Approval rules / eligibility APIs | GET .../approval_rules, GET .../approval_state (per-rule eligible_approvers[], approved_by[]), GET/POST /projects/:id/approvals — Approvals API ("All other endpoints require Premium or Ultimate") |
P/U | same as C6 — without the evidence chain the adapter cannot prove eligibility, which is the ADR-0017 §3 requirement |
| C8 | Self-approval limits | POST /projects/:id/approvals fields merge_requests_author_approval, merge_requests_disable_committers_approval, require_reauthentication_to_approve; UI defaults documented as "By default, the creator of a merge request (author) cannot approve it" — Approval settings, Approvals API |
P/U (settings page and config API are Premium) | on Free the forge does not enforce author/committer exclusion → the adapter must exclude author/bot approvals client-side from evidence; since C6 is absent anyway, require-review is already unsatisfiable on Free |
| C9 | Bot identity (write actor) | project access token → GitLab creates a bot user member, username project_{project_id}_bot_{random_string} — Project access tokens; resolve own identity via GET /user — Users API |
GitLab.com: P/U only ("On GitLab.com, project access tokens require a Premium or Ultimate subscription"). Self-Managed/Dedicated: any license | on GitLab.com Free, no project token → bot identity must be a dedicated user account with PAT (heavier custody) or no write actions at all (advisory-only mode, ADR-0015 §8) |
| C10 | SHA-guarded merge (source CAS) | PUT /projects/:id/merge_requests/:iid/merge?sha= — "this SHA must match the HEAD of the source branch" — MR API; response codes in §3 |
F/P/U | adapter that cannot SHA-guard must declare the gap → engine never auto-merges (ADR-0015 §2). Present on all tiers, so no gap on GitLab |
| C11 | Auto-merge arming ("merge when checks pass") | PUT .../merge with auto_merge=true (17.11+; merge_when_pipeline_succeeds deprecated) combined with sha=; cancel: POST .../cancel_merge_when_pipeline_succeeds (also removes from merge train) — MR API, Cancel; behaviour: new commits cancel the armed merge — Auto-merge |
F/P/U | n/a — this is the ADR-0009-amendment arming primitive; preconditions (C3 gate on, pipelines-must-succeed) verified by doctor |
| C12 | Merge-readiness polling | GET .../merge_requests/:iid → detailed_merge_status enum incl. discussions_not_resolved, not_approved, ci_must_pass, requested_changes, need_rebase, approvals_syncing — MR API merge status |
F/P/U (some statuses only fire with P/U features) | n/a |
| C13 | Merged results pipelines (evaluate the merge result) | project setting; pipeline runs on "a temporary merged commit that combines code from the source and target branches" — Merged results pipelines | P/U | capability gap → evaluation sees the source branch only, never the merge result → merge-result digest precondition (ADR-0017 §7) unverifiable → no deferred auto-merge on this tier |
| C14 | Merge trains / queue (merge-result pinning) | settings + POST /projects/:id/merge_trains/merge_requests/:iid (supports sha=, auto_merge=; 201/202); status GET /projects/:id/merge_trains/... — Merge trains, Merge trains API |
P/U | capability gap → no forge mechanism re-validates against a moved target → per ADR-0017 §1, deferred auto-merge is refused on this tier (immediate merge with fresh re-evaluation remains possible) |
| C15 | Merge train enforcement (no bypass) | Settings > Merge requests > "Merge train enforcement" (Allow bypass / Enforce for all users / Enforce with Owner override); enforced mode "rejects direct merges through the REST API and GraphQL API" — Merge trains: enforce | P/U | without enforcement, humans can Merge immediately around the train — acceptable (human action ≠ assent's write path), but doctor should report it |
| C16 | Read merge-result commit (evidence, not enforcement) | GET .../merge_requests/:iid/merge_ref → {"commit_id": …} — updates refs/merge-requests/:iid/merge to "the state the target branch would have if a regular merge action was taken" — MR API |
F/P/U | n/a — usable on every tier to record the evaluated merge-result digest in Decision.Pins; enforcement still needs C13/C14 |
| C17 | Protected pipeline source (job def not author-editable) | (a) CI/CD config file in another project (path/file.yml@group/project, Settings > CI/CD) — Pipeline settings (F/P/U); (b) pipeline execution policies (inject/override strategies, .pipeline-policy-pre reserved stage, skip_ci control) — Pipeline execution policies (U); compliance pipelines are deprecated in favour of (b) (same doc) |
(a) F/P/U · (b) U | if neither is used and the assent job lives in the MR-editable .gitlab-ci.yml, the topology is unsupported/insecure (ADR-0015 §4); doctor refuses to arm auto-merge (ADR-0015 §8) |
| C18 | External status checks as merge gate | project setting only_allow_merge_if_all_status_checks_passed — Projects API ("Ultimate only") |
U | optional defense-in-depth only; absence is not a gap for the v1 design |
| C19 | Bot approval-reset immunity controls | reset_approvals_on_push (default resets approvals on new push, patch-id based), selective_code_owner_removals; bot-only PUT .../reset_approvals ("Available only to bot users with a valid project or group token") — Approvals API |
reset-on-push behaviour F/P/U; config API P/U | approvals resetting on push is the desired fail-closed direction (stale approval cannot survive new commits); doctor should verify reset_approvals_on_push: true |
2. Reconcile preconditions (ADR-0017 §7) → GitLab mechanism per tier¶
Preconditions carried by Reconcile(DesiredReviewState, Preconditions): source SHA, target
SHA, evaluated merge-result digest, fact validity deadline, decision hash.
| Precondition | Enforce/verify mechanism | Free | Premium/Ultimate |
|---|---|---|---|
| Source SHA unchanged | PUT .../merge?sha= CAS (C10); POST .../approve?sha= (C4); armed auto-merge cancelled on new push (C11) |
enforced | enforced |
| Target SHA unchanged / re-validated | no target-SHA CAS parameter exists on the merge endpoint (verified against the MR API attribute table — sha refers to the source HEAD only). Only a merge train re-validates after target movement (C14) |
capability gap → no deferred auto-merge on this tier | merge train (C14) re-runs the merged-result pipeline when the train/target changes |
| Evaluated merge-result digest | record: GET .../merge_ref (C16, all tiers); enforce: merged-results pipeline + merge train (C13+C14) — the train pipeline runs on the merge result and drops the MR if it becomes invalid |
record-only → capability gap → no deferred auto-merge on this tier | enforced via train |
| Fact validity deadline | no forge mechanism — assent-side arming precondition (facts.max_age, ADR-0017 §4): one-shot may arm only if obligations cannot expire before a forge-enforced event |
assent-side | assent-side |
Fail-closed summary (ADR-0017 §1 wording): merge?sha= alone is source-only CAS and
insufficient; on tiers without merge trains the engine either merges immediately in the same
run that evaluated (target pinned by comparing diff_refs.base_sha/merge_ref before the
write) or refuses deferred auto-merge: capability gap → no deferred auto-merge on this tier.
3. SHA-guard behaviour: PUT /merge_requests/:iid/merge?sha= (REQ-P1-E3-S01-02)¶
Documented contract (MR API):
sha — "If present, this SHA must match the HEAD of the source branch. Use to ensure that
only reviewed commits are merged." Documented failure codes:
| HTTP | Message | Meaning for the adapter |
|---|---|---|
401 |
401 Unauthorized |
token lacks merge permission → fail closed, report capability/permission error |
405 |
405 Method Not Allowed |
MR "cannot merge" (draft, blocked, gate unmet) → fail closed, re-read detailed_merge_status |
409 |
SHA does not match HEAD of source branch |
the CAS miss — HEAD moved after evaluation → decision void, re-evaluate |
422 |
Branch cannot be merged |
merge itself failed (e.g. conflict) → fail closed |
Adversarial case (ADR-0015 §2): evaluate at SHA_A → author pushes SHA_B → adapter calls
merge?sha=SHA_A → expected 409 + no merge; the new push has also cancelled any armed
auto-merge (C11) and (default) reset approvals (C19). The same guard exists on approval:
approve?sha= mismatch → 409 Conflict (Approvals API).
| Verification | Status |
|---|---|
| Docs-derived expectation (table above) | done (this section) |
| Live gitlab.com run: push-after-evaluation → pinned merge attempt → capture actual codes/bodies | live verification pending — supplied by P2-E2 smoke (spike B) (no working gitlab.com token available in this lane; decision logged in the workspace INBOX) |
Edge to capture in the live run: response code when sha= matches but the target moved
(expected: merge succeeds — which is exactly the ADR-0017 §1 insufficiency being proven), and
merge_when_pipeline_succeeds-deprecation behaviour of auto_merge=true on 18.x.
4. Approval-eligibility evidence for require-review (S02, OQ-23)¶
ADR-0017 §3: require-review is satisfied only by forge-proven eligible approval; failed
authorization never degrades into an author-resolvable thread.
Premium / Ultimate — the evidence chain¶
| Step | Call | Fields consumed |
|---|---|---|
| (a) required rules | GET /projects/:id/merge_requests/:iid/approval_rules (doc, P/U) |
id, name, rule_type (regular, code_owner, report_approver, any_approver), approvals_required, overridden |
| (b) eligible approvers | same response / GET .../approval_state (doc, P/U) |
rules[].eligible_approvers[].{id,username} — CODEOWNERS-sourced rules appear as rule_type: code_owner (typed eligible principals, ADR-0017 §3) |
| (c) actual approvals + identities | GET .../approval_state per rule; GET .../approvals (doc, F) for the flat list |
rules[].approved_by[].{id,username}, rules[].approved; approved_by[].user.{id,username} + approved_at |
Adversarial exclusions — exact fields:
- MR-author approval: compare each
approved_by[].user.idagainst the MRauthor.id(GET /projects/:id/merge_requests/:iid, doc). Forge-side enforcement:merge_requests_author_approval: falseinGET /projects/:id/approvals(doc); the adapter must verify the setting and exclude client-side (defense in depth, since the setting is overridable per-MR unlessdisable_overriding_approvers_per_merge_request). - assent's own bot approval: the token's identity from
GET /user; project-access-token actors are bot users with usernameproject_{project_id}_bot_{random_string}(doc) — distinguishable by exactuser.idmatch (primary) and theproject_*_bot_*username shape (sanity check). Excluded from evidence for any rule: assent approving cannot satisfyrequire-review(ADR-0015 §5 makes the residual-gate implication explicit). - Committer approval:
merge_requests_disable_committers_approval(same config API) if the archetype demands author-independence stronger than authorship alone. - Invalid-rule pitfall (must-check): GitLab marks rules "Auto approved" when they are
impossible to satisfy (only eligible approver is the author; no eligible approvers;
required > eligible) — Invalid rules.
A rule auto-approved this way has
approved: truewith empty/insufficientapproved_by— the adapter must require non-empty eligibleapproved_by, never trust the rule-level boolean alone. - Staleness: approvals reset on new push by default (
reset_approvals_on_push, patch-id based — settings); pairapproved_atwith the evaluated head SHA, and treatdetailed_merge_status: approvals_syncing/checkingas not-yet-provable (timing note). - Re-authentication signal:
require_reauthentication_to_approvestrengthens the identity claim (CFR-11-style e-signature) — recordable in ApprovalEvidence but not required for v1.
Free — capability gap¶
GET .../approvals exists (identities visible), but: approval rules APIs are P/U-only
(Approvals API tier note), required
approvals are P/U (Approvals overview),
and on Free "approvals are optional and don't prevent merging" (same doc). There is no
forge-proven notion of eligible approver and no merge-blocking force. Additionally, CE's
approved field is true on any single approval (doc).
Recorded consequence: capability gap → require-review never satisfiable → no auto-merge
for archetypes needing it — never a silent downgrade to a resolvable thread (ADR-0017 §3).
5. Merge trains as merge-result pinning (ADR-0017 §1)¶
- Train pipelines run on "the changes of the merge requests combined with the target branch"; a failed/invalidated entry is dropped and later pipelines restart against the new combined state — Merge trains (P/U).
- API arming:
POST /projects/:id/merge_trains/merge_requests/:iidwithsha=(source CAS at train entry) +auto_merge=true→201/202— Merge trains API. - Drop conditions relevant to assent: draft, merge conflict, and "a new conversation thread that is unresolved, when all threads must be resolved is enabled" — i.e. the C3 gate keeps protecting the armed state inside the train (troubleshooting).
- Bypass control: merge-train enforcement modes (C15).
skip_merge_train=trueon the merge endpoint is the documented bypass — assent must never use it. - Constraint: with trains enabled, plain auto-merge cannot skip the train (cannot use auto-merge) — the adapter's arming call is the train-add on these projects.
6. Protected-pipeline topologies (ADR-0015 §4)¶
| Topology | Tier | Mechanics | doctor verdict |
|---|---|---|---|
CI config file outside the repo (.yml@group/project or external URL) |
F/P/U | Settings > CI/CD > "CI/CD configuration file"; config project can be locked down ("Give write permissions on the project only to users who are allowed to edit the file") — doc | supported — the one v1 path available on every tier (feeds OQ-24) |
| Pipeline execution policies (security policy project) | U | pipeline_execution_policy with inject_policy/override_project_ci, reserved .pipeline-policy-pre stage, skip_ci ignored by default — doc |
supported (strongest: MR authors cannot remove the job) |
| Compliance pipelines | U (deprecated) | "Compliance pipelines are deprecated… use pipeline execution policies for all new implementations" — doc | migrate; do not document as a target topology |
assent job in the MR-editable .gitlab-ci.yml with a privileged token |
any | author can edit/remove the gate and exfiltrate the token | unsupported, insecure — doctor refuses to arm auto-merge (ADR-0015 §8) |
Residual caveat (all tiers): even with an external config file, workflow:rules/rules in
included author-editable files can suppress jobs; the policy-strategy (override_project_ci)
is the only variant immune to that. The E4 adapter spec must pin the exact include layout.
7. Auto-merge arming lifecycle (ADR-0009 amendment)¶
- Preconditions checked by
doctor: C3 gate on; "Pipelines must succeed" on (auto-merge prerequisites); protected topology (§6); P/U: trains enabled for merge-result pinning (§5). - Arm: approve (
sha=-pinned) +PUT .../merge?auto_merge=true&sha=<evaluated>(or the train-add on train projects). GitLab then merges only when all merge checks pass — including "All discussions are resolved" and "All required approvals" (auto-merge checks list). - Forge-native revocation (what makes one-shot arming sound, ADR-0017 §4): new source commits cancel the armed merge and (default) reset approvals; on ff-only projects target movement also cancels (pipeline success behaviour); on trains, invalidation drops the entry (§5).
- What the forge does NOT revoke: fact expiry (
facts.max_age) — assent-side arming precondition; decisions with expiring authorization facts must not arm (ADR-0017 §4). - Cancel path (assent- or human-initiated):
POST .../cancel_merge_when_pipeline_succeeds→201;406when the MR is closed (doc).
Open verification items¶
- Live
merge?sha=run on gitlab.com (§3) — pending, P2-E2 smoke (spike B). - Free-tier author-self-approval default (does CE block author approval without the Premium
settings page?) — irrelevant to gating (C6 gap already refuses
require-reviewon Free) but worth confirming in the same smoke run. auto_merge=true+sha=interaction on merge trains (does the train re-check the source CAS at final merge, or only at train entry?) — E4 conformance case.