---
title: "67. GitLab cron-polling event dispatch"
status: Accepted
relates_to:
  - agent-infrastructure
  - gitlab-implementation
  - security-threat-model
topics:
  - gitlab
  - forge
  - ci-cd
  - per-repo
  - polling
  - cron
  - dispatch
  - drivers
---

# 67. GitLab cron-polling event dispatch

Date: 2026-06-13

## Status

Accepted

The dispatch topology (native-CI two-path, then pure cron-polling after
[#7322](https://github.com/fullsend-ai/fullsend/issues/7322)) is superseded
by [ADR 0125](0125-gitlab-hybrid-webhook-poller-dispatch.md). The single-bot
credential model is superseded by the three-role decision in
[#7424](https://github.com/fullsend-ai/fullsend/issues/7424) and
[#7496](https://github.com/fullsend-ai/fullsend/issues/7496) (see
[gitlab-role-credentials.md](../contributing/gitlab-role-credentials.md));
poller internals, HMAC dispatch signing, and forge-interface
extensions remain current. Of this ADR's security guardrails, the
protected/masked CI variable model, the poller reconciliation backstop, and
the in-job dispatch gate remain current; the "no inbound attack surface / no
webhook parser / no trigger token" and "events read only from the GitLab
API, never from spoofable webhook payloads" properties are **superseded** by
ADR 0125's webhook fast-path — see ADR 0125's trust boundaries and
trigger-token threat model for the new inbound-surface controls.

<!-- ADRs are point-in-time records, but not fully frozen after acceptance.
     Minor annotations are welcome: cross-references to related ADRs, short
     notes linking to newer decisions, or clarifying remarks. However, do not
     substantially rewrite the Context, Decision, or Consequences sections. If
     the decision itself needs to change, write a new ADR that supersedes this
     one. For evolving design narrative, use docs/architecture.md. -->

> **Update (2026-09, #7502 / #7424 / #7496):** The shared-identity
> assumption in "Credential model" below is superseded. Fullsend uses
> three built-in GitLab responsibility identities per repository —
> **Poller**, **Analyst**, and **Coder** — decided in
> [#7424](https://github.com/fullsend-ai/fullsend/issues/7424) and
> implemented under [#7496](https://github.com/fullsend-ai/fullsend/issues/7496).
> Administrator-registered custom roles are an optional extension of
> the same registry, not a replacement of the three built-ins. Runtime
> never authenticates as `FULLSEND_FORGE_TOKEN`. Separate identities do
> not grant finer GitLab API permissions: every role token remains
> Developer (30) with the `api` scope. See
> [gitlab-role-credentials.md](../contributing/gitlab-role-credentials.md).
>
> **Update (2026-09, #7758):** Dispatch topology is superseded by
> [ADR 0125](0125-gitlab-hybrid-webhook-poller-dispatch.md): a GitLab-native
> webhook fast-path with this ADR's cron-poller as the reconciliation
> backstop. The Decision below is the historical record; do not treat
> poll-only dispatch as current.
>
> **Update (2026-07, #5556):** The child-pipeline output driver described in
> this ADR was replaced by direct API-triggered pipelines
> (`POST /projects/:id/pipeline`). The poller now creates standalone pipelines
> with dispatch variables instead of generating child pipeline YAML. This
> eliminates the bridge job, YAML generation, and 2-level pipeline nesting.
> The cron-polling input driver and dispatch core are unchanged. Superseded
> sections: "Relationship to the dispatch driver architecture" (child-pipeline
> output driver), "Pipeline nesting" under GitLab tier considerations, and the
> architecture diagram showing parent-child pipeline flow.
>
> **Trust boundary change:** With child pipelines, dispatch variables were
> computed server-side by the trusted poller and injected via the trigger YAML
> artifact — only the poller could produce them. With API-triggered pipelines,
> any user with pipeline-create access on the protected branch can POST
> arbitrary variables (STAGE, EVENT_TYPE, EVENT_PAYLOAD_B64, RESOURCE_KEY,
> IS_FORK, MR_AUTHOR_ID, ACTOR_ID, STATUS_IID, FULLSEND_POLL_JOB_URL,
> ORIGINATING_URL, REPO_FULL_NAME). The
> in-job authorization gate and fork
> protection read these attacker-supplied variables. Mitigation #1 is
> implemented: the agent job uses the Pipelines API
> (`GET /projects/:id/pipelines/$CI_PIPELINE_ID`) to fetch the server-side
> `.source` field and `.user.id`, then branches with a deny-by-default
> `case` statement. For API-triggered pipelines (`.source == "api"`), it
> verifies the pipeline creator matches the bot PAT identity. MR child
> pipelines (`.source == "parent_pipeline"`) skip this check since their
> creator is the MR author. A missing or unrecognized `.source` aborts the
> job (fail-closed). Both dispatch paths now depend on a successful
> pipeline-record read. The `.source` field is server-computed and cannot
> be overridden by pipeline variables, unlike the `CI_PIPELINE_SOURCE` env
> var. Residual risk: `CI_API_V4_URL` and `CI_PIPELINE_ID` are still
> overridable, so a sophisticated attacker can redirect the API calls.
> Mitigation #2 (HMAC signing, #5572) reduces this residual risk: the
> poller signs dispatch variables with `FULLSEND_DISPATCH_SECRET` using
> HMAC-SHA256 and the agent job verifies the signature before trusting
> any dispatch variable. The HMAC computation itself uses no CI-provided
> URLs, but the verification is gated on PIPELINE_SOURCE (derived from
> CI_API_V4_URL), so the risk is reduced rather than fully closed. `FULLSEND_DISPATCH_SECRET`
> MUST be configured as a protected, masked CI/CD variable — pipeline
> variables can be overridden by API-triggered pipelines, so protection
> is required to prevent a Developer+ user from supplying their own
> secret and computing a valid HMAC over forged variables.
>
> **New permission requirement:** The bot PAT must have merge or push access
> to the protected branch to create pipelines via the API endpoint. The
> child-pipeline path had no such requirement (the trigger ran inside an
> existing pipeline context).
>
> **Pipeline visibility change:** Dispatched pipelines are first-class
> pipelines on the default branch, not nested children. Agent failures mark
> the latest pipeline on main as failed. The scaffold sets
> `workflow:auto_cancel:on_new_commit:none` to prevent new commits from
> canceling queued agent pipelines. This setting applies globally (including
> MR pipelines) because GitLab does not scope auto_cancel per pipeline
> source. MR dispatch jobs are fast (<30s) so the impact on MR pipeline
> redundancy is negligible.
>
> **Observability trade-off:** The old `trigger: strategy: depend` mirrored
> child pipeline pass/fail into the poll job's own status. API-triggered
> pipelines are fire-and-forget — the poll job reports success after creating
> the pipeline, regardless of downstream agent outcome. Dispatched pipeline
> URLs are logged for manual inspection.
>
> **Update (2026-09, #7293):** Native `merge_request_event` review is
> incompatible with protected CI/CD variables. MR pipelines run on the
> unprotected `refs/merge-requests/N/head` ref, so `FULLSEND_FORGE_TOKEN`
> is empty and the review agent aborts. MR-open review now uses the cron
> poller (`created_at` > watermark → `transition.kind: opened`) which
> dispatches on the protected default branch. The native dispatch job
> no-ops the review stage the same way it already no-ops merged retro.
> This trades sub-second latency for up to one poll interval. Superseded
> sections: the "MR opened/updated/reopened → native CI → review" row in
> Event routing, the architecture diagram line routing MR open to
> `fullsend-dispatch.yml`, and "MR review latency is unaffected" under
> Consequences. Push-to-open-MR (GitHub `synchronize`) is not detected
> by the poller; use `/fs-review`.
>
> **Update (2026-09, #7323):** The single shared bot PAT (see "Credential
> model" below) means GitLab's own `merge_requests_author_approval=false`
> default always rejects `POST .../approve` with 401 whenever the
> authenticated bot identity is also the MR author — which is always true
> for fullsend-authored MRs on GitLab, since code and review share one
> identity. That outcome is a certainty, not a possible failure to
> recover from after the fact, so `CreatePullRequestReview` (APPROVE)
> checks the authenticated identity against the MR author via
> `GetAuthenticatedUser` / `GetPullRequestInfo` *before* calling
> `/approve`, and only when they affirmatively match, skips the call
> outright and posts an MR note recording the approve verdict instead. A
> post-hoc check using the same identity comparison remains as a safety
> net for a 401 that arrives despite the pre-call check (e.g., the
> identity lookup itself errored, or a future per-role PAT is not the
> author but project settings still block the approval). In both the
> pre-call and post-hoc paths, a 401 whose body matches known
> credential-failure phrasing (`isCredentialFailure` — invalid, expired,
> or revoked token) is always a hard error, never a note, and any error
> while performing the identity check itself (including an empty
> username from either lookup) also fails closed as a hard error rather
> than falling back. This is a documented trade-off of the single-shared-PAT
> credential model's interaction with the "no self-approval"
> defense-in-depth control in [Threat 2 of the security threat
> model](../problems/security-threat-model.md#threat-2-insider-threat--compromised-credentials):
> GitHub keeps that separation via distinct bot identities; GitLab's
> single-PAT model (chosen here for operational simplicity) does not, and
> this fallback is an accepted consequence of that tradeoff rather than a
> per-role-token gap to close.
>
> **Update (2026-09, #7502 / #7424):** The single-shared-PAT self-approval
> trade-off above is superseded when Analyst and Coder are distinct GitLab
> users: native `POST .../approve` can succeed because the Analyst token
> is not the MR author. The pre-call identity check remains as a safety
> net when those identities coincide (for example a Free-tier one-user
> PAT arrangement). See
> [configuring-gitlab.md](../guides/getting-started/configuring-gitlab.md#role-identities-and-gitlab-free).
>
> **Update (2026-09, #7322):** Native `merge_request_event` dispatch is
> removed. After #7293 moved MR-open review to the poller, the only
> remaining native path was best-effort `closed` → retro (a
> push-then-close race that generated no-op child pipelines for every
> other MR event). All GitLab events now route through the cron poller,
> including closed-unmerged MRs (`closed_at` > watermark and `merged_at`
> empty → `transition.kind: closed` → retro). This is Option 4 (pure
> cron-polling), originally rejected for sub-second MR review latency
> that #7293 already gave up. `fullsend-dispatch.yml` is retained as a
> version-marker carrier and is no longer included by the pipeline
> wrapper.
>
> **Update (2026-09, #7707):** The leftover `fullsend-dispatch.yml` stub
> is no longer scaffolded. The `# fullsend-ref:` version marker now lives
> in `fullsend-pipeline.yml`. Status, probe, and upgrade still read a
> leftover dispatch stub so already-enrolled repos keep reporting a ref;
> converge deletes the stub and writes the marker onto the pipeline
> wrapper. Uninstall still removes the file when it is present. Superseded sections: the two-path Decision, the native-CI
> architecture-diagram line, and the #5556 auto_cancel note's "MR
> pipelines / MR dispatch jobs are fast (<30s)" claim above — no MR
> pipelines or dispatch jobs exist anymore. ("MR review latency is
> unaffected" under Consequences was already superseded by the #7293
> note above.)
>
> **Transitional risk on already-enrolled repos:** the root
> `.gitlab-ci.yml` is user-owned and, prior to this note, was only
> touched by the install merge path (fresh installs) and the uninstall
> unmerge path (teardown) — neither runs during `repos upgrade`/`repos
> install` convergence. Without a migration, an already-enrolled repo
> that converges after #7322 would keep the obsolete
> `merge_request_event` workflow rule while the newly-synced pipeline
> wrapper defines no job matching that source, so GitLab would create
> an empty/config-error pipeline on every MR event. Converge now
> strips this specific obsolete rule from the root file in place
> (`StripObsoleteGitLabWorkflowRules`, `internal/repos/gitlabci.go`),
> leaving fullsend's current rules and all user configuration
> untouched. See risk item 6 under Consequences. #7337 subsequently
> dropped the leftover empty `dispatch` stage from the required stage
> list and strips it from already-enrolled root files on converge
> (`StripObsoleteGitLabStages`).

## Context

Fullsend needs to detect and react to GitLab events — new issues, merge
requests, comments, and label changes — so that agent stages (triage, code,
review, fix, retro) can be dispatched automatically. On GitHub, native event
triggers (`pull_request_target`, `issues`, `issue_comment`) handle this within
GitHub Actions. GitLab has no equivalent for most event types.

GitLab's CI/CD pipeline trigger sources are: `push`, `merge_request_event`,
`schedule`, `trigger`, `web`, `api`, and `parent_pipeline`. Of these, only
`merge_request_event` maps to an agent-relevant event. Issue creation, comment
posting, and label changes have no native CI pipeline trigger. GitLab supports
per-repo installation mode only (no per-org); the pipeline runs inside the
enrolled project on the protected default branch.

See [ADR 0028](0028-gitlab-support.md) for the original GitLab
support architecture discussion. ADR 0028 documented a webhook bridge approach;
this ADR supersedes that direction based on the operational complexity analysis
in Options 1–3 below. [ADR 0045](0045-forge-portable-harness-schema.md)
defines the forge-portable harness schema that GitLab stage templates must
conform to.

## Options

### Option 1: Webhook bridge Cloud Function

Deploy a GCP Cloud Function that receives GitLab webhook POST requests,
validates the `X-GitLab-Token` header, and calls the Pipeline Trigger API to
dispatch agent stages.

**Rejected.** Requires external infrastructure (Cloud Function) that must be
deployed, monitored, and secured. Exposes a public HTTPS endpoint — an inbound
attack surface. Requires three credential types per project (bot PAT, webhook
secret, trigger token). Creates a complex deployment story for self-hosted
GitLab behind corporate firewalls (VPN peering, on-premise containers, or
Cloud Run + VPC Connector). The bridge cannot be eliminated even in a hybrid
model — if any event type uses webhooks, the full bridge must be deployed.

### Option 2: Webhook-only (all events via bridge)

Use the webhook bridge for all events, eliminating native CI triggers.

**Rejected.** Still requires the bridge with all its operational complexity.
The correct response to "if we need webhooks for some events, why not all?" is
to eliminate the bridge entirely, not to double down on it.

### Option 3: Native merge request (MR) events + webhook bridge for issues/comments

Use GitLab's native `merge_request_event` for MR events, keep the webhook
bridge only for issues and comments.

**Rejected.** Still requires the bridge Cloud Function. The bridge's
operational cost is dominated by deployment, monitoring, and credential
management — not by event type count.

### Option 4: Pure cron-polling (no native CI triggers)

Poll for all events including MR creation and updates.

**Rejected.** MR events have a viable native CI path (`merge_request_event` +
`include: local:`) with sub-minute latency and zero additional infrastructure.
Polling for MRs adds unnecessary latency to the most frequent, most
latency-sensitive operation (code review).

## Decision

GitLab event dispatch uses a **two-path model**:

1. **Native CI triggers for MR events.** MR creation, update, and reopen
   trigger pipelines via GitLab's `merge_request_event` pipeline source.
   MR merge does **not** fire `merge_request_event` — GitLab creates a
   `push` event on the target branch instead, so MR merge detection uses
   the cron-poller (see event routing table below). The dispatch template
   is loaded via `include: local:` from the protected default branch,
   ensuring untrusted MR branches cannot modify dispatch logic.

2. **Cron-polled events for everything else.** A scheduled pipeline runs every
   N minutes (5 minutes on Premium/Ultimate, 60 minutes on Free tier), queries
   the GitLab API for new issues, comments, and label changes since the last
   poll, and dispatches agent stages via parent-child pipelines.

No external infrastructure is required for event dispatch — no webhook bridge,
no webhook secrets, no trigger tokens.

### Relationship to the dispatch driver architecture

[ADR 0061](0061-harness-cel-dispatch.md) defines the dispatch pipeline:
`input driver → authorize → enumerate harnesses → CEL triggers → output driver`.
This ADR implements a **`gitlab-poll` input driver** for `fullsend poll` and a
**child-pipeline output driver** for GitLab CI, following the same composition
as other poll input drivers (e.g. `jira-poll`):

```
gitlab-poll input driver → per-event coordination → dispatch core → child-pipeline output driver
```

The `gitlab-poll` input driver discovers events and emits
[`NormalizedEvent`](../normative/normalized-event/v1/) values. The **dispatch
core** — authorization ([ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md))
and harness CEL `trigger` evaluation ([ADR 0061](0061-harness-cel-dispatch.md))
— is shared with `fullsend dispatch` and other poll input drivers. The poll
input driver does not duplicate trigger routing or authorization logic.

```
ENROLLED PROJECT                           GCP (optional, for inference)
────────────────                           ────
.gitlab-ci.yml (root pipeline)             WIF pool/provider (inference OIDC validation)
.gitlab/ci/fullsend-dispatch.yml (MR routing)
.gitlab/ci/fullsend-poll.yml (cron-poller)
.gitlab/ci/fullsend-agent.yml (generic stage)
  (replaces per-stage templates — see PR #3193)
.fullsend/ (config workspace)

MR events (native CI):
  MR opened/updated/reopened → merge_request_event → fullsend-dispatch.yml → review stage

MR merge (cron):
  Pipeline schedule → fullsend-poll.yml → MR merged_at > watermark → retro stage

Issues, comments, labels (cron):
  Pipeline schedule (5 min) → fullsend-poll.yml → GitLab API → dispatch agent stage

Credentials:
  Pipeline job → protected CI/CD variable FULLSEND_FORGE_TOKEN → bot PAT
```

> **See also (#7424, #7496, #7497, #7498):** The registered-role credential contract
> (built-in Poller/Analyst/Coder plus administrator-registered custom
> roles) is specified in
> [gitlab-role-credentials.md](../contributing/gitlab-role-credentials.md).
> `repos install` can provision those credentials additively without
> revoking `FULLSEND_FORGE_TOKEN`. Job routing (#7499, #7559) selects the
> registered role credential when the gate is `migrating` or `enforced`
> and fails closed if that secret is missing; this ADR's single-bot
> identity remains the leftover/`rollback` path while the gate is unset,
> leftover `disabled`, or explicit `rollback`.
>
> **Update (#7782):** The leftover/`rollback` single-bot-identity path
> described above no longer applies to runtime credential selection.
> Runtime job routing now requires the registered role credential in
> every gate mode, including leftover unset/`disabled` and explicit
> `rollback` — there is no shared-token fallback left. See
> [gitlab-role-credentials.md](../contributing/gitlab-role-credentials.md).
>
> **Update (#7524 / #7559):** Ordinary `repos install` retires the
> leftover shared `FULLSEND_FORGE_TOKEN` credential once all roles are
> ready, and there is no public rollback control. See
> [gitlab-role-credentials.md](../contributing/gitlab-role-credentials.md).

### Credential model

A Maintainer-role project access token with `api` scope, created during
`fullsend admin install`. Maintainer role is required because the poller
updates CI/CD variables (watermark and label state persistence) via the
API, which requires Maintainer-level access. The bot PAT is stored as a
protected, masked CI/CD variable (`FULLSEND_FORGE_TOKEN`).

> **Update (2026-09, #7502 / #7424 / #7496):** The single bot PAT
> described here is superseded by the three built-in responsibility
> identities (Poller, Analyst, Coder) plus optional administrator-
> registered custom roles. Runtime never authenticates as
> `FULLSEND_FORGE_TOKEN`. See Status and
> [gitlab-role-credentials.md](../contributing/gitlab-role-credentials.md).
>
> **Update (2026-09, #7343 / #7362 / #7381):** Poll-state persistence
> (watermarks, dispatched/failed-key dedup, label state) moved off
> CI/CD variables onto two per-mode, HMAC-signed `state.json`
> documents on dedicated unprotected branches:
>
> | Branch | Written by | Fields |
> |---|---|---|
> | `fullsend-poll-state-slash` | slash poll (`*/5`) | `last_poll_at_fast`, `dispatched_keys_fast`, `failed_keys_fast`, `hmac` |
> | `fullsend-poll-state-events` | event poll (`2,17,32,47`) | `last_poll_at_full`, `dispatched_keys_full`, `failed_keys_full`, `label_state`, `hmac` |
>
> Two branches keep concurrent slash+events runs from clobbering each
> other (each mode has its own `resource_group`, but the two modes can
> overlap) and let force-re-root pruning drop only that mode's prior
> commit. Two files on one branch would lose the sibling file on every
> force-re-root.
>
> **Force-re-root pruning.** Every save is a single
> `ForceCommitFileToBranch` (`POST /projects/:id/repository/commits`
> with `force: true` and `start_sha` = the repository's root commit).
> The branch is always root + 1 commit; prior state commits become
> unreachable. The commit message is suffixed `[skip ci]`. Force +
> start point also creates the branch on first write.
>
> **Update (#7768 / [ADR 0125](0125-gitlab-hybrid-webhook-poller-dispatch.md)):** Install-time seeding still force-re-roots as
> described above. Runtime persist is now conflict-detecting instead:
> the write is parented at the loaded tip without `force`, and a 409
> retries a merge of this writer's deltas rather than last-writer-wins
> overwrite. See ADR 0125 for the current runtime persist design and
> the residual risk this introduces for unbounded state-branch history.
>
> **HMAC.** Each document is HMAC-SHA256-signed with
> `FULLSEND_DISPATCH_SECRET` (already provisioned for dispatch
> signing). The MAC covers a per-branch, per-project domain prefix
> (`fullsend-poll-state-slash/1\n` or `fullsend-poll-state-events/1\n`,
> then `{owner/repo}\n`) plus the canonical JSON with the `hmac` field
> cleared, so a signed file cannot be substituted across branches or
> projects.
>
> **Fail-closed vs self-heal.** Secret unset → refuse to load or write
> (fail closed). Present but missing/invalid signature, or unreadable
> JSON → discard the branch (`DeleteRef`) and fail that cycle; the
> next cycle recreates a fresh signed baseline. Missing branch or
> `state.json` is *not* tampering: load a fresh baseline (watermark
> defaults to ~1 hour ago) and the next save recreates the branch.
> Losing a state branch therefore causes a one-time re-scan and
> at-least-once re-dispatch of recent items, not a stall.
>
> **Lifecycle.** `repos install` / `repos converge` create both
> branches with an initial signed document (seeded from legacy
> CI/CD-variable state when present). The poller self-heals a deleted
> branch within one cycle. `repos uninstall` deletes both branches.
>
> Phase 3c (#7381) dropped the created bot PAT from Maintainer (40)
> to Developer (30); Developer can force-write and delete an
> unprotected branch. The historical Maintainer-role description
> above is superseded for the CI/CD-variable rationale only. It is
> not superseded for the #5556 "New permission requirement" above:
> `internal/poll/dispatch.go` still calls `CreatePipeline` on the
> protected default branch, which requires merge or push access.
> Under GitLab's default "Protected" branch preset (Developers and
> Maintainers can merge), Developer (30) still satisfies that
> requirement, but a repo whose branch protection restricts both merge
> and push to Maintainers will get a 403 on pipeline creation and
> dispatch will silently stop working. This change does not verify or
> grant that access; operators using a stricter branch-protection
> configuration must grant Developers merge (or push) access to the
> default branch, or keep the bot PAT at Maintainer (40), for dispatch
> to keep working.
>
> **Update (#7665):** `repos install` now grants the poller
> project-access-token user merge access (not push) on the protected
> default branch when Developer-class merge/push is absent, and fails
> closed if that grant is not possible. `repos status` reports
> `protected-ref-pipeline` drift if the access is later removed.
>
> **Update (#7667):** a failed `CreatePipeline` (including that 403) now
> fails the poll cycle after persisting retry state. The permission gap
> is unchanged; the poll job no longer reports success when no agent
> pipeline was created.

Key properties:

- **Single credential type.** One bot PAT per project handles all REST and
  GraphQL operations. No webhook secrets, trigger tokens, or mint service.
- **Bot identity.** The project access token creates a dedicated bot user,
  providing attributable identity equivalent to GitHub Apps.
- **GraphQL support.** Unlike `CI_JOB_TOKEN`, the bot PAT authenticates
  GraphQL — required for GitLab's Work Items API.
- **`CI_DEBUG_TRACE` guard.** The scaffold script aborts when debug trace
  is enabled, preventing PAT exposure in job logs.
- **Inference credential support.** Vertex AI inference credentials
  (`FULLSEND_GCP_PROJECT_ID`, `FULLSEND_GCP_WIF_PROVIDER`,
  `FULLSEND_GCP_REGION`) are configured via OIDC/WIF when inference is
  enabled, so that agent jobs can authenticate to Vertex AI.
- **OIDC issuer reachability requirement.** Inference WIF requires the
  GitLab instance's OIDC discovery endpoints to be publicly reachable
  by GCP's Security Token Service (STS). During the WIF token exchange,
  GCP's STS resolves the GitLab instance hostname to validate the JWT
  issuer. Internal or private GitLab instances (e.g., those accessible
  only via VPN or corporate DNS) will fail with
  `Error code invalid_grant: Error connecting to the given credential's issuer`.
  Inference is not available for GitLab instances that are not
  resolvable in public DNS.

### Cron poller (`gitlab-poll` input driver)

The `gitlab-poll` input driver runs as `fullsend poll` inside the fullsend
container image, invoked by a scheduled pipeline on the protected default
branch. It reads a timestamp watermark, queries the GitLab API for events
since the last poll, emits a `NormalizedEvent` per detected change, passes
events to the dispatch core for authorization and harness CEL evaluation, and
advances the watermark.

Change detection for labels uses client-side state diffing — the input driver
tracks previously-seen labels per issue and emits events only for newly-added
labels. This compensates for the lack of a `changes` object that webhook
payloads provide.

**Multi-frequency polling (Premium/Ultimate):** Two pipeline schedules — a
fast poll (every 5 minutes, slash commands only) and a slow poll (every 15
minutes, full event scan). On Free tier, a single hourly poll is the only
option. Each mode uses a separate watermark
(`FULLSEND_LAST_POLL_AT_FAST` / `FULLSEND_LAST_POLL_AT_FULL`) so that
fast polls do not advance past label/note events that only the full poll
handles. A consequence is that slash commands discovered by a fast poll
may be re-discovered by the next full poll. `resource_group`
serialization prevents concurrent execution, so a duplicate dispatch
queues behind the first. The second run executes the same stage
against already-processed state (the agent sees no new work) and
exits as a no-op, wasting one pipeline invocation's CI minutes.
This is an accepted tradeoff — the alternative (sharing a
processed-note-IDs set or cross-reading watermarks between modes)
adds state coupling that complicates the independent-schedule design.

> **Update (2026-09, #7343):** The two watermarks named above are now
> `last_poll_at_fast` / `last_poll_at_full` fields in the HMAC-signed
> `state.json` on `fullsend-poll-state-slash` and
> `fullsend-poll-state-events` respectively, not CI/CD variables. See
> "Credential model".

> **Update (2026-08, #5959):** ~~The dual-schedule architecture above was replaced
> by a single `*/5 * * * *` schedule with automatic full-poll promotion. The
> poller now decides at runtime whether to run a fast poll or full poll based on
> elapsed time since the last full poll (`FULLSEND_LAST_POLL_AT_FULL`). This
> eliminates the tier distinction (Premium vs Free), the separate fast/full
> schedules, and the `FULLSEND_POLL_MODE` variable. The fast-poll watermark
> (`FULLSEND_LAST_POLL_AT_FAST`) is still used for slash-command-only cycles.
> Free tier in-CI polling is no longer supported by this schedule (Free tier's
> minimum interval is 60 minutes); Free tier users should use off-system polling
> (`fullsend poll` on a VM or Kubernetes CronJob) as documented in "GitLab tier
> considerations" below. Superseded sections: "Multi-frequency polling" above,
> the "5 minutes on Premium/Ultimate, 60 minutes on Free tier" reference in the
> cron-poller introduction, "Multi-frequency polling" and fast-poll MR note
> limitation under "Slash command latency", the Free tier 60-minute interval
> references in "GitLab tier considerations", and the "5 minutes on Premium, 60
> minutes on Free" latency in "Consequences".~~ Superseded by #6077 below.
>
> **Update (2026-08, #6077):** The single auto-promoting schedule from #5959
> was reverted to two independent schedules with explicit mode selection. The
> auto-promote logic coupled slash-command latency to full-poll duration and
> used a single `resource_group`, causing GitLab to cancel the in-progress
> poll when the next schedule fired. The new architecture:
> - **Slash poll:** `*/5 * * * *` with `FULLSEND_POLL_MODE=slash` — processes
>   only `/fs-*` slash commands, fast and lightweight.
> - **Event poll:** `2,17,32,47 * * * *` with `FULLSEND_POLL_MODE=events` —
>   full event discovery (labels, MR merges, non-command notes).
> - Each schedule uses a per-mode resource group
>   (`fullsend-poll-slash` / `fullsend-poll-events`) so they never cancel
>   each other. Resource group process modes differ by purpose:
>   `newest_first` for slash (latest command wins, stale polls are
>   preempted) and `oldest_first` for events (long-running discovery
>   completes before the next cycle starts).
> - The `--mode` CLI flag (also `FULLSEND_POLL_MODE` env var) selects the
>   mode explicitly; empty uses the events discovery path but does not
>   filter `/fs-*` notes (backward compatibility with pre-dual-schedule
>   installations where a single schedule handled all event types).
> - The `shouldFullPoll` auto-promote logic and `FullPollInterval` are removed.
> - Superseded sections: "MR note limitation (fast-poll)" (slash commands
>   on MRs are now handled by the dedicated slash poll schedule, not gated
>   behind full-poll cycles), and the "Multi-frequency polling" reference
>   under "Slash command latency" (replaced by the independent schedule
>   architecture above).

### Event routing

The design goal is **functional event-type parity with GitHub** — users see the
same labels, slash commands, and stage dispatches regardless of forge (latency
differs: cron-polled events have 5–60 minute delay vs sub-second on GitHub).
Routing is performed by harness CEL `trigger` expressions
([ADR 0061](0061-harness-cel-dispatch.md)) evaluated in the dispatch core, not
by the `gitlab-poll` input driver. The table below documents how each detected
change maps to a `NormalizedEvent` and transport path, not trigger
configuration.

| Detected Change | Transport | Stage |
|---|---|---|
| Issue label `ready-to-code` added | Cron poll (label state diff) | code |
| Issue label `ready-for-review` added | Cron poll (label state diff) | review |
| Issue note starting with `/fs-{triage,code,review,fix,retro,prioritize}` | Cron poll (note body prefix) | corresponding stage |
| ~~Issue note (non-command) on issue with `needs-info` label~~ | ~~Cron poll (label check); Reporter+ or issue author~~ | ~~triage~~ Removed in [#6740](https://github.com/fullsend-ai/fullsend/issues/6740) — use `/fs-triage` instead |
| ~~MR opened/updated/reopened~~ | ~~Native CI (`merge_request_event`)~~ | ~~review~~ Moved to cron poll in [#7293](https://github.com/fullsend-ai/fullsend/issues/7293) — protected CI/CD variables are not exposed on unprotected MR refs |
| MR opened | Cron poll (MR `created_at` > watermark) | review |
| MR merged | Cron poll (MR `merged_at` > watermark) | retro |
| MR closed (unmerged) | Cron poll (MR `closed_at` > watermark, `merged_at` empty) | retro |
| MR note with `<!-- fullsend:changes-requested -->` | Cron poll (note body marker) | fix (same-project MRs only) |

Bot-authored comments are skipped to prevent re-triggering loops (exception:
the `changes-requested` marker from the review agent).

### Slash command latency

Slash commands (`/fs-*`) are the only latency-sensitive operation. Mitigations:

- **Labels as primary triggers.** Applying `ready-for-review` or
  `ready-to-code` labels is discoverable and visible. Labels on issues are
  detected via cron-poll (5–60 minute latency). Labels on MRs are also
  detected via cron-poll; native CI `merge_request_event` label
  detection was removed in #7322.
- **Multi-frequency polling** keeps slash command latency to 5 minutes on
  Premium/Ultimate.
- **Manual pipeline trigger** via the GitLab UI as a power-user escape hatch.
- **Off-system polling** via `fullsend poll` on a standalone VM or Kubernetes
  CronJob, at any desired interval. This reintroduces external infrastructure
  but is architecturally simpler than a webhook bridge — see
  [GitLab tier considerations](#gitlab-tier-considerations) below for details.

**MR note limitation (fast-poll):** GitLab's `merge_request_event` pipeline
source fires on MR creation, update, and reopen — not on merge, close, or
individual MR comments. Comment-based triggers on MRs (`/fs-fix`, `/fs-code`) must
therefore use the cron-poller. Within the cron-poller, these commands on MR
notes are only acted upon during the full-poll cycle (every 15 minutes on
Premium/Ultimate), not the fast poll. The fast-poll path does not fetch MR
source/target project IDs, so the fork MR protection check (deny-by-default
when unknown) blocks these stages. This adds up to 10 minutes of latency
beyond the fast-poll interval. Fetching MR details per note in fast-poll
would add API calls that defeat its lightweight purpose. In practice, fix
stages are typically triggered by the review bot's `changes-requested`
marker (which uses the full-poll path), not human slash commands.

**Quick Action risk:** GitLab may silently strip unrecognized `/`-prefixed
lines. If confirmed empirically, GitLab should use an alternative prefix
(`fs:triage` or `@fullsend triage`). [ADR 0042](0042-fs-prefix-for-slash-commands.md)
permits forge-specific syntax.

### GitLab tier considerations

| Feature | Free | Premium | Ultimate |
|---|---|---|---|
| Schedule minimum interval | 60 min | 5 min | 5 min |
| Project access tokens (SaaS) | Not available | Available | Available |
| CODEOWNERS enforcement | Not available | Available | Available |
| CI minutes (shared runners) | 400/month | 10,000/month | 50,000/month |
| Parent-child pipeline nesting | 2 levels | 2 levels | 2 levels |

**Pipeline nesting:** The cron-poller uses exactly 2 levels of
`trigger: include:` child pipeline nesting — the GitLab maximum. The poll
runs inline in the root scheduled pipeline (no child pipeline). Level 1:
the root pipeline triggers a dynamically generated dispatch child pipeline
(via `trigger: include: artifact:`). Level 2: the dispatch child pipeline
triggers per-stage child pipelines (via
`trigger: include: .gitlab/ci/fullsend-agent.yml`). This is at the
nesting ceiling — no additional `trigger: include:` levels can be added
without restructuring. See
[GitLab CI/CD pipeline nesting](https://docs.gitlab.com/ee/ci/pipelines/downstream_pipelines.html#nesting).

**Free tier** is functional but degraded: 60-minute poll interval, no project
access tokens on gitlab.com (must use personal access token), no CODEOWNERS
guardrails, and CI minute quota is insufficient for polling on shared runners.
Self-hosted runners are required. As an alternative, Free tier users can run
`fullsend poll` on an external scheduler (cron on a VM, Kubernetes CronJob,
etc.) at any desired interval. This reintroduces external infrastructure but
is architecturally simpler than a webhook bridge — the poller is entirely
outbound (no public endpoint, no inbound payload parsing) and uses the same
code path as the in-CI poller.

**Premium** (recommended minimum): 5-minute polling, project access tokens,
CODEOWNERS enforcement, adequate CI minutes for a single project.

`fullsend admin install` adapts poll frequency and interaction model to the
detected tier.

### Security model

The security model follows the project's threat priority order (external
injection > insider > drift > supply chain):

- **No inbound attack surface.** Polling is entirely outbound — no public
  endpoint, no webhook parser, no shared-secret authentication.
- **Protected branch enforcement.** `workflow:rules` require
  `$CI_COMMIT_REF_PROTECTED == "true"` for scheduled pipelines.
- **Protected CI/CD variables.** All fullsend CI/CD variables are marked
  protected — accessible only to pipelines on protected branches.
- **`CI_DEBUG_TRACE` guard.** Install-time validation and runtime abort if
  debug tracing is detected. This guard is the sole defense against PAT
  exposure via debug tracing — GitLab logs CI/CD variables at job init,
  before any script runs. **Known limitation:** install-time validation
  checks project-level and group-level variables but cannot query
  instance-level CI/CD variables (requires admin API access).
- **Event data sanitization.** Attacker-controlled content is base64-encoded
  before passing to child pipelines.
- **Fork MR protection.** Fix/code stages are skipped when
  `source_project_id != target_project_id`.
- **Slash command authorization.** Only users with Developer-level (30+)
  project access can trigger agent stages via `/fs-*` commands.
  ~~Exception: non-command comments on issues with the `needs-info` label
  trigger triage with a reduced authorization gate — the commenter must
  have at least Reporter-level (20+) project access or be the issue
  author.~~ The needs-info re-triage exception was removed in
  [#6740](https://github.com/fullsend-ai/fullsend/issues/6740) — use
  `/fs-triage` to re-trigger triage after providing requested
  information.

**Security properties of the credential model:**

| Threat vector | Mitigation |
|---|---|
| `CI_DEBUG_TRACE` by Maintainer | PAT exposed at job init before script guard runs; guard limits further damage but cannot prevent initial exposure |
| Maintainer marks branch as protected | Token exposed (protected variable accessible) |
| GitLab database compromise | PAT stored in GitLab as protected CI/CD variable |
| Audit trail | GitLab audit logs (Premium+) |

> **Update (2026-09, #7343):** The table above describes the original
> CI/CD-variable / Maintainer model. Poll state is now an HMAC-signed
> `state.json` on Developer-writable branches, so the relevant threat
> rows are:
>
> | Threat vector | Mitigation |
> |---|---|
> | Developer forges poll state | HMAC-SHA256 (`FULLSEND_DISPATCH_SECRET`) with per-branch and per-project domain separation. Secret unset → refuse load/write. Bad/absent signature → discard the branch and fail that cycle. |
> | Missing poll-state branch | Not tampering: fresh baseline (watermark ~1h ago); next save recreates the branch. One-time re-scan / at-least-once re-dispatch, not a stall. |
> | Unbounded history on state branches | Force-re-root every save on the repository's root commit (`force: true` + `start_sha`); branch stays at base + 1 commit. |
> | ↳ **Update (#7768 / [ADR 0125](0125-gitlab-hybrid-webhook-poller-dispatch.md))** | Install-time seed still force-re-roots as above. Runtime persist is now CAS and no longer force-re-roots every save, so old HMAC-signed `state.json` commits stay reachable. See ADR 0125 for the current mitigation status and the accepted residual risk this introduces (old-signed-document replay by a Developer with push access). |
> | Concurrent poller + webhook writers | Conflict-detecting persist ([ADR 0125](0125-gitlab-hybrid-webhook-poller-dispatch.md)): 409 / non-fast-forward reloads and unions dispatched keys; exhaustion fails closed. |
> | `CI_DEBUG_TRACE` / protected-branch exposure of the bot PAT | Unchanged: `FULLSEND_FORGE_TOKEN` and `FULLSEND_DISPATCH_SECRET` remain protected CI/CD variables. |

### Forge abstraction

[ADR 0005](0005-forge-abstraction-layer.md) requires new forges to implement
`forge.Client`. This ADR extends the forge interface with new methods (some GitLab-specific, some forge-neutral):

- `IsProtectedBranch` — maps to GitHub branch protection API and GitLab
  protected branches API
- `CreatePipelineSchedule` / `DeletePipelineSchedule` / `UpdatePipelineSchedule` — GitLab-native; GitHub
  returns `ErrNotSupported`
- `UpdateCIVariable` — for poll watermark management
  > **Update (2026-09, #7343):** Poll watermarks are no longer CI/CD
  > variables. Persistence uses `GetFileContentAtRef` /
  > `ForceCommitFileToBranch` / `DeleteRef` on the two poll-state
  > branches. Credential and secret writes (`FULLSEND_FORGE_TOKEN`,
  > `FULLSEND_DISPATCH_SECRET`) use `CreateRepoSecret`, not
  > `UpdateCIVariable`. `UpdateCIVariable` has no production callers;
  > it remains on `forge.Client` only for non-credential CI/CD-variable
  > operations, should any be added.
  >
  > **Update (#7768 / [ADR 0125](0125-gitlab-hybrid-webhook-poller-dispatch.md)):** Runtime persist is compare-and-swap
  > (`CommitFileToBranch` parented at the loaded tip). `ForceCommitFileToBranch`
  > remains the install-time seed primitive. Concurrent poller and webhook
  > writers retry-merge dispatched keys on 409 and fail closed on exhaustion.

A new `ErrNotSupported` sentinel (complementing the existing forge
sentinel errors) allows forge
implementations to reject inapplicable operations. GitHub-only methods
(`ListOrgInstallations`, `GetAppClientID`) move to a `GitHubExtensions`
extension interface. This requires interface evolution beyond pure
implementation — adding methods to `forge.Client` and refactoring
GitHub-specific methods into an extension interface. This is anticipated
growth of the abstraction boundary, not a violation of
[ADR 0005](0005-forge-abstraction-layer.md)'s design; the changes to
`appsetup.go` and `admin.go` are limited to calling new forge-neutral
methods rather than adding forge-conditional logic.

## Consequences

**What becomes easier:**

- **No external infrastructure for event dispatch.** No Cloud Function, no
  webhook bridge. Self-hosted GitLab requires only outbound HTTPS.
- **Single credential per project.** One bot PAT, stored as a protected
  CI/CD variable. No webhook secrets, trigger tokens, or mint service
  changes.
- **Stronger event authenticity.** Events read directly from the GitLab API,
  not from potentially spoofed webhook payloads.
- **No event loss.** Polling reads from the source of truth. Webhooks can fail
  silently or auto-disable after 4 consecutive failures.
- **Simpler emergency shutdown.** Disable the pipeline schedule or revoke the
  bot PAT. No bridge to tear down.
- **MR review latency is unaffected.** Native `merge_request_event` provides
  sub-second triggering for the highest-frequency operation.
- **Tier-adaptive.** Works on all GitLab tiers with graceful degradation.
- **GCP required only for inference.** The agent runtime works without
  GCP, but `repos install` requires inference credentials (WIF provider
  and GCP project) for all new installations.

**What becomes harder or changes:**

- **Issue/comment event latency.** Up to 5 minutes on Premium, 60 minutes on
  Free. Acceptable for asynchronous agent operations, poor for interactive use
  on Free tier.
- **CI minute consumption.** Polling runs continuously. At 5-minute intervals:
  ~8,640 min/month on shared runners. Self-hosted runners are not billed.
- **State management.** The poller must track watermarks, deduplicate events
  across overlapping windows, and diff label state. This state is internal
  to the GitLab forge implementation and does not leak into the
  `forge.Client` interface, preserving the forge-neutral contract from
  [ADR 0005](0005-forge-abstraction-layer.md).
- **Slash command latency.** Up to 5 minutes vs sub-second with webhooks.
  Labels mitigate this for common operations.
- **Quick Action stripping.** GitLab may strip `/fs-*` commands from comments.
  Requires testing and potentially alternative syntax.
- **Per-repo only.** No centralized config or credential management across
  projects.
- **`api` scope is broad.** Narrower scopes are not available in GitLab today.

**Risks** (ordered by threat priority):

1. **YAML injection in child pipeline generation.** Attacker-controlled
   issue/MR content could break child pipeline YAML syntax. Mitigated by
   base64 encoding of event payloads passed to child pipelines.
2. **Prompt injection via polled events.** Attacker-controlled issue/MR
   content reaches the agent at inference time. This risk is identical
   across all forges and is handled by the existing agent harness security
   layer, not by the transport mechanism.
3. **Watermark tampering.** A Maintainer could skip or replay events by
   modifying the watermark variables. Mitigated by protected variable status
   and event deduplication.
   > **Update (2026-09, #7343):** Poll state now lives on unprotected,
   > Developer-writable `state.json` branches (see "Credential model"
   > above) rather than protected CI/CD variables, so tampering is
   > mitigated by an HMAC-SHA256 signature (`FULLSEND_DISPATCH_SECRET`,
   > per-branch and per-project domain separation) instead: a Developer
   > without the secret cannot forge state. Fail-closed: secret unset
   > refuses load/write; a missing or invalid signature discards the
   > branch and fails that cycle. A missing branch or file is not
   > tampering — the poller starts from a fresh baseline and the next
   > save recreates the branch. The signature does not bind freshness, so
   > a Developer *with* push access can still replay an old, validly-signed
   > document rather than forge a new one — see the "Unbounded history on
   > state branches" row above for that accepted residual risk.
4. **Schedule modification.** A Maintainer could retarget the schedule to a
   non-protected branch. Mitigated by protected variable status (bot PAT
   not exposed on non-protected branches).
5. **Missed events from API quirks.** The Notes API lacks `created_after`; the
   Events API `after` parameter is date-only. Mitigated by 30-second watermark
   overlap and dual-frequency polling as reconciliation.
6. **Stale root-file workflow rules surviving convergence (#7322).** The root
   `.gitlab-ci.yml` is user-owned and historically was only migrated on
   fresh install or full uninstall, not on `repos upgrade`/`repos install`
   convergence. An obsolete rule (e.g. `merge_request_event`, removed in
   #7322) could otherwise survive indefinitely on already-enrolled repos,
   producing an empty/config-error pipeline on every matching event.
   Mitigated by a converge-time migration step that strips only the
   specific obsolete rule(s), leaving current fullsend rules and user
   configuration untouched. The migration only fires when fullsend can
   prove it owns the `workflow:` block (the fullsend-generated
   `workflow.name`, set on fresh installs). Repos enrolled by merging
   fullsend rules into a pre-existing `workflow:` block carry no such
   marker, so `merge_request_event` cannot be safely distinguished from
   a user's own MR gate there and is left in place — those repos need
   manual removal (same as the uninstall path). This deliberately errs
   toward preserving user configuration over full auto-migration.

**Comparison with GitHub:**

| Concern | GitHub | GitLab (this ADR) |
|---|---|---|
| Primary credential | App installation token via mint | Bot PAT (WIF or CI/CD variable) |
| MR/PR event dispatch | `pull_request_target` | `merge_request_event` |
| Issue/comment dispatch | Native events (sub-second) | Cron polling (5 min) |
| External infrastructure | Mint Cloud Function | None for event dispatch |
| Credential types | App key + installation token | Single bot PAT |

> **Update (2026-09, #7343):** The GitLab primary credential is still
> a single bot PAT stored as a protected CI/CD variable; it is now
> created at Developer (30). Poller *state* is not a CI/CD variable
> — it lives on the HMAC-signed poll-state branches described under
> "Credential model".

Implementation covers poller pseudocode, forge interface changes, CI/CD
template scaffolding, and install flow.

## References

- [ADR 0002](0002-initial-fullsend-design.md) — initial fullsend design (webhook + dispatch service, label state machine)
- [ADR 0033](0033-per-repo-installation-mode.md) — per-repo installation model (the only supported mode for GitLab)
- [ADR 0054](0054-require-authorization-on-all-agent-dispatch-paths.md) — authorization on all dispatch paths (slash command ACL)
- [ADR 0061](0061-harness-cel-dispatch.md) — harness CEL triggers, dispatch drivers, and NormalizedEvent schema
- [ADR 0063](0063-polling-based-work-discovery.md) — polling-based work discovery via dispatch drivers (`fullsend poll`, input/output driver architecture)
- [ADR 0125](0125-gitlab-hybrid-webhook-poller-dispatch.md) — hybrid GitLab dispatch (webhook fast-path + this ADR's poller as backstop)
- [NormalizedEvent v1](../normative/normalized-event/v1/)
