NormalizedEvent v1
Forge-neutral routing input for fullsend dispatch and harness CEL trigger expressions (ADR 0061).
The field names and transition vocabulary are forge-neutral; v1 normative scope covers GitHub, GitLab, and Jira (see Scope).
Contract
- Schema:
normalized-event.schema.json - CEL context: harness
triggerexpressions receive a single root variableeventbound to aNormalizedEventobject. This describes the currently shipped event-backed path. ADR 0098 adopts a future entity-first context with requiredentityand nullableevent; its field-level contract remains follow-up versioned work. - Authorization:
fullsend dispatchenforces the Authorization Contract v1 as a platform-level gate after normalization and before CEL evaluation. Harnesstriggerexpressions express routing only, not permission policy. This authorization statement applies to the event-backed path; Authorization Contract v1 also defines the trusted-origin gate for future entity discovery. The historical decision is recorded in ADR 0054.
ADR 0107 target contract — partially implemented: The bot-specific actor fields and lookup semantics described below are normative design. The Go event types (
bot_role,role_verifiedon the actor) and the dispatch authorization gate that enforces them exist, but no production adapter emits the fields yet. Existing compatibility behavior remains authoritative until adapters are migrated.
Scope (v1)
v1 adapters and examples target GitHub webhooks, GitLab cron-poll and native-webhook input, and Jira poll input:
source.systemisgithub,gitlab,jira,manual, orschedule.repois the target Fullsend repository (owner/repofor GitHub,group/subgroup/projectfor GitLab) for all systems — including Jira poll events (see jira-poll-adapter.md).- The
gha-eventinput driver is the production GitHub adapter;gitlab-pollis the production GitLab poll adapter (ADR 0067);gitlab-webhookis the shipped GitLab native-webhook fast-path adapter, provisioned at install time once its readiness gates hold (ADR 0125). Live issue-to-agent validation covers GitLab.com Free, self-managed EE, and self-managed CE. The CE runs used a patched agents helper, so stock agents on a custom-port CE instance remain unverified (see Validation status);jira-pollis the production Jira poll adapter;jsonsupports tests and replay. entity.kind: conversationcovers GitHub Discussions and future chat systems (ADR 0086). A conversation is the container (Discussion / Slack channel). Threading is expressed on the message viatransition.comment.idandtransition.comment.parent_id(always the thread-root id; equal toidfor top-level messages). GitHub Discussion adapters are planned undergha-event; additionalsource.systemvalues (e.g. Slack) are non-breaking enum additions when those drivers land.- Conversations carry
state.conversation.category(exactly one; 1:M category→conversation) separately fromstate.labels(M:M tags on the conversation). Threads and messages carry neither category nor labels;comment_addedevents still snapshot the parent conversation's category and labels and MUST includetransition.comment.idandparent_id.
Versioning
Breaking changes require docs/normative/normalized-event/v2/.
| Change | v1 impact |
|---|---|
| Breaking (requires v2): remove or rename fields, change field types, remove enum values, add new required top-level fields, tighten patterns that reject previously valid documents | Consumers must migrate |
| Non-breaking (allowed in v1.x schema/README): add optional fields, add new enum values, relax validation, clarify documentation | Existing fixtures and triggers keep working |
The new optional actor fields are additive, but consumers that vendor this schema with additionalProperties: false must refresh their copy before an adapter emits bot_role or role_verified; otherwise their local validator may reject an otherwise valid v1 event. Fullsend controls the supported NormalizedEvent producers and consumers, so the bundled validator and other supported consumers MUST be updated to this v1 schema before any adapter emits the new fields. Older strict schema copies are not a supported producer/consumer combination after that rollout. v1 compatibility means that existing events without these fields remain valid; it does not require a stale additionalProperties: false schema to accept newly emitted fields. The JSON Schema's cross-field rules are currently stricter than the Go normevent.Validate() implementation. The adapter migration MUST bring the Go validator to parity before emitting the new fields; until then the schema remains the normative stricter check.
Adding a new transition.kind is non-breaking — CEL triggers use boolean expressions, not exhaustive enum matching.
Normalized-event and entity-context-snapshot v1 carry independently versioned copies of the actor-role vocabulary. The entity-context-snapshot bot marker is a non-authority kind marker, not a normalized-event repository permission, and must not be projected into event.actor.role; adapters validate each document against its own schema.
Adapters
Input drivers map native forge events into this struct:
| Driver | Source | v1 status |
|---|---|---|
gha-event | GITHUB_EVENT_PATH + gh snapshot for labels and change-proposal metadata | Production; Discussions → entity.kind: conversation planned (ADR 0086) |
gitlab-poll | GitLab CI event payload (cron-polled; ADR 0067) | Production (poll) |
gitlab-webhook | TRIGGER_PAYLOAD, a file-type CI/CD variable whose value is a path to the webhook body delivered by a native "use a webhook" pipeline trigger pinned to the protected default branch: the file's contents are untrusted hinting only (resource IDs, event type). The adapter MUST accept only GitLab's own file-type TRIGGER_PAYLOAD variable — never a caller-supplied path override, and never following a symlink out of the runner-provided location — read the file, and re-fetch the referenced entities from the GitLab API. Provenance splits by field: entity, state, actor, and state.labels MUST come solely from the project-pinned API re-fetch, failing closed on any missing or mismatched resource. transition.kind (and source.raw_type/raw_action) has no API-snapshot equivalent — a resource GET cannot distinguish opened/labeled/synchronized/merged for an MR, or added/edited for a note — so it MAY be taken from the payload's object-kind/action hint only after the payload action is mapped onto the v1 transition vocabulary and validated by the fail-closed consistency checks enumerated in GitLab webhook transition provenance below. Any action that contradicts the snapshot, or that cannot be checked against it, MUST fail closed. Required transition sub-objects follow the same field-split: transition.comment and transition.review MUST be populated from the re-fetch, while transition.label (name and action, which a current-state snapshot cannot recover) is taken from the payload's changes.labels diff and admitted only after the present/absent snapshot check — see the subsection for the per-label fan-out. This is not a pure snapshot re-fetch — the payload action is untrusted until the check passes. The enumerated checks are current-state predicates and do not by themselves prove the action just occurred, so snapshot-consistent replay remains a named residual trigger-token risk (see the subsection); the adapter MUST use event-time evidence to bind the action and the actor that performed it to a recent occurrence and fail closed when no matching event links them. For transitions whose snapshot carries no actor (e.g. label_changed), GitLab's resource label/state events — which record user, action, and time — are a required part of the re-fetch; a users/members GET of the payload-named actor is not sufficient actor provenance. Actor-to-transition attribution is a distinct requirement, separate from replay (see the subsection). Any identifier read from the payload MUST be validated against its expected format before use in an API path; moreover every re-fetch URL MUST be constructed from the pinned API host (the CI_JOB_TOKEN job record's project, plus the pinned API root described below) combined with those validated identifiers, and the adapter MUST NOT follow any URL, host, or path_with_namespace taken from the payload (e.g. project.web_url, object_attributes.url, repository.git_http_url, project.path_with_namespace) — doing so would turn the payload into SSRF against the PAT-bearing client and would bypass the API-root pin. The payload's contents MUST NOT be logged or echoed. Project identity MUST come from the running job's CI_JOB_TOKEN job record (GET /api/v4/job) as the sole project-identity source — never from the trigger-overridable CI_PROJECT_ID, CI_API_V4_URL, CI_SERVER_URL, or FULLSEND_GITLAB_URL environment values, which the adapter MUST ignore for identity. An id_tokens JWT is not an independent or interchangeable identity source (it lands in an ordinary env var a same-named trigger variable can outrank); if a JWT is used at all it MUST be bound to the CI_JOB_TOKEN-authenticated job record by matching its job_id/project_id claims — in addition to JWKS signature verification — and MUST NOT stand alone. Host/base-URL: the adapter MUST target the API root from the same identity pin, never CI_API_V4_URL, CI_SERVER_URL, or FULLSEND_GITLAB_URL directly, and MUST fail closed when no pinned API root is available. The shipped dispatcher passes the validated https API root that bootstrapped the CI_JOB_TOKEN job lookup (FULLSEND_PINNED_GITLAB_URL). That bootstrap value is still in the overridable class, so its trust rests on the ci_pipeline_variables_minimum_override_role=no_one_allowed restriction, which install verifies before it provisions the webhook, and on the inherited CI/CD variable trust assumption in the GitLab configuration guide. A base-URL pin independent of that restriction is not specified (ADR 0125, Consequences) | Shipped (webhook fast-path); live-validated on GitLab.com Free, self-managed EE, and self-managed CE, with stock agents on custom-port CE unverified — see Validation status |
jira-poll | Jira issue search + changelog/comments since lastCheck (jira-poll-adapter.md, ADR 0063) | Production (poll) |
json | stdin or --input-file | Tests, replay |
Adapters must populate:
state.labelswhen routing guards or label-based triggers apply.state.conversation(withcategory.nameat minimum) wheneverentity.kindisconversation— including message/comment_addedevents. Do not encode the category as a synthetic label. On comment transitions, also settransition.comment.idandparent_id(parent_id == idfor thread-root messages; otherwise the root id).state.change_proposal(includinghead_ref,base_ref, andhead_shawhen known) whenever a matched harness needs change-proposal execution context. Webhook payloads are often incomplete — adapters should fill gaps via GitHub API calls before dispatch.
Schedule and manual sources
When source.system is schedule or manual, there is no native webhook payload. The input driver must resolve and populate entity (and state.change_proposal when the target is a change proposal) from the scheduled or operator-specified work item before dispatch proceeds. Schedule drivers must not emit events with a missing or synthetic entity.
Authorization: the platform authorization gate (Authorization Contract v1; ADR 0054) treats schedule and manual dispatch as trusted operator actions, not end-user webhook events. Adapters set actor.id to the configured operator or service identity (e.g. a GitHub App bot or workflow GITHUB_ACTOR) and classify actor.kind as either human or bot using authoritative provider metadata. Under ADR 0107, human identities use verified forge permissions, while bot identities may resolve actor.bot_role and retain actor.role: none for v1 compatibility. Authorization therefore follows the identity kind: human permission thresholds for humans, and recognized bot identity for bots. fullsend dispatch applies the same identity lookup as webhook paths. If that lookup returns no recognized role or fails/unverifiable, authorization is denied. These lookup and denial rules are ADR 0107 target behavior and are not yet enforced by the production runtime.
Transition sub-objects
Transition-specific fields are present only when required by transition.kind:
transition.kind | Required sub-object | Forbidden otherwise |
|---|---|---|
label_changed | label | comment, review |
comment_added | comment | label, review |
review_submitted | review | label, comment |
| all other kinds | none | label, comment, review |
The schema enforces presence/absence of transition sub-objects via conditional required / false properties. Cross-field ID consistency (below) is documented here and validated by adapter tests — JSON Schema cannot express cross-field equality.
Transition kind vocabulary
| Kind | Use |
|---|---|
opened | Entity created or first opened |
reopened | Entity reopened after close; adapters MAY map to opened when the distinction is unnecessary |
edited | Title/body/metadata edit without new commits |
synchronized | Head branch received new commits (GitHub synchronize) |
updated | Legacy umbrella; prefer edited or synchronized for new adapters |
merged | Change proposal merged into target branch |
closed, marked_ready, label_changed, comment_added, review_submitted | As named |
Comment extraction
For comment_added, adapters extract command and instruction from the raw comment body before applying the 4096-character truncation stored in comment.body (JSON Schema maxLength counts Unicode code points). This keeps slash-command routing and fix instructions intact even when the stored body is truncated for transport.
This moves instruction extraction from downstream workflow steps (e.g. reusable-fix.yml) into the input adapter — a behavioral change called out in ADR 0061 Consequences.
Actor role mapping (GitHub)
For human actors, actor.role uses permission levels aligned with ADR 0054 and the GitHub collaborator permission API:
actor.role | GitHub permission | Typical use in triggers |
|---|---|---|
admin | admin | Full repo control |
maintain | maintain | Settings without destructive admin |
write | write (member) | Push, label, comment |
triage | triage | Label and moderate without write |
read | read | Read-only collaborator |
none | none | Authenticated user without explicit repo permission |
external | — | Actor outside the repository (fork PR author, drive-by commenter) |
Adapters populate role from the GitHub collaborator permission API for human actors. Current adapters may also populate a GitHub App bot's legacy forge permission in actor.role, and that representation remains valid in NormalizedEvent v1 while the adapter has not adopted ADR 0107. Once an adapter adopts ADR 0107, it MUST use the provider's authoritative bot classification, emit role: none for bots, and use the provider-backed optional bot_role lookup instead. Both representations are therefore intentional v1 compatibility forms; the adapter implementation determines which one it emits.
actor.role_verified is an optional additive field. For humans, it is true only when actor.role is a trusted forge permission. For bots, it is true when the bot-role lookup completed, whether it found a role or not. A recognized bot has role_verified: true and a provider-resolved bot_role; an unknown bot has role_verified: true and an absent or null bot_role; failed resolution has role_verified: false and no bot_role.
For a label-added event, the permanent label authorization exception does not require bot-role lookup. On that path, the provider's positive bot classification and the forge-authorized label mutation are sufficient; an absent role_verified field means that bot-role lookup was not applicable. At the time of this ADR, GitHub is the only production adapter implementing this exception; other source adapters must provide equivalent authoritative label and actor-transition evidence before enabling the target behavior. Whether an event uses the legacy representation is determined by the adapter's implementation contract, not inferred from field omission alone.
This is the ADR 0107 target representation. The Go normevent.Actor type carries both fields (omitted when unset), internal/botidentity (ApplyBotRoleResult) projects a lookup outcome onto them, and the dispatch gate denies a bot with role_verified set but no recognized bot_role on non-label paths. The production adapters do not emit the fields yet, so triggers that read them must guard with has().
Fork security (state.change_proposal.is_fork)
is_fork is true when head_repo differs from base_repo (fork-based change proposal). Write-capable agents (code, fix) that push commits or open follow-up PRs must gate on !state.change_proposal.is_fork in harness trigger expressions or rely on dispatch-level authorization per ADR 0054. Read-only agents (triage, review, retro) may run on fork PRs when policy allows.
Change facts (state.change_proposal.diff, state.change_proposal.linked_work_items)
Optional and added for ADR 0112's routing; no v1 adapter populates them yet. When known, adapters set state.change_proposal.diff (file count, additions, deletions and changed paths at head_sha, with paths_complete false when the forge truncated the list, in which case a check for a path's absence should treat it as unknown) and linked_work_items (each linked work item's id, optional key, state and labels). Harness trigger and overlays: expressions may read them, for example to route model and effort per task; they are untrusted (see below) (ADR 0112). A trigger that reads them lets the change's author decide whether a harness runs, so security-relevant harnesses shouldn't trigger on them.
Trusted fields
A field is trusted when neither the change's or entity's author nor an actor below the permission level ADR 0054 requires can choose its value beyond what their permission already allows (for example, only an author with write access can make is_fork false). An overlay that sets a guarded harness field may read only these fields, runtime.forge and config (ADR 0112):
reposource.system, andentity.source.systemin the entity context of ADR 0098actor.rolestate.change_proposal.base_repoandis_fork
Every other field, including a field added to this schema later, is untrusted until it is listed here. For example transition.comment (its body, command and instruction all come from the comment text), the action fields (transition.kind, source.raw_type, source.raw_action, entity.kind), actor.kind (some adapters infer it from a display name), actor.is_entity_author (the author makes it true by acting), state.labels, base_ref (the author picks the target branch), head_* and the change facts are untrusted. A guarded overlay may test has() only on objects the schema always requires, such as source; reading is_fork when there is no change proposal is simply no match. actor.role says who triggered the event, not who wrote the change, so an overlay that loosens a guarded value on a change proposal should also require !state.change_proposal.is_fork.
CEL trigger examples
Harness trigger expressions are CEL booleans over event:
// Triage on new issues
event.entity.kind == "work_item" && event.transition.kind == "opened"
// Code when ready-to-code label added
event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-to-code"
&& event.transition.label.action == "added"
// Fix on review changes requested
event.transition.kind == "review_submitted"
&& event.transition.review.state == "changes_requested"
// Fix on /fs-fix slash command (non-fork PR)
event.transition.kind == "comment_added"
&& event.transition.comment.command == "/fs-fix"
&& !event.state.change_proposal.is_fork
// Conversation-native agent on Discussion slash command (ADR 0086)
event.entity.kind == "conversation"
&& event.transition.kind == "comment_added"
&& has(event.transition.comment.command)
&& event.transition.comment.command == "/fs-vouch"
&& has(event.state.conversation.category.slug)
&& event.state.conversation.category.slug == "vouch-request"See examples/ for matching NormalizedEvent fixtures. The existing top-level fixtures retain the legacy v1 representation; target fixtures for ADR 0107 are under examples/adr-0107/.
Examples
See examples/. The examples/adr-0107/ subdirectory contains target representations and is not emitted until an adapter adopts the ADR 0107 target implementation.
Execution ref projection
fullsend dispatch projects each matched harness to the execution ref consumed by existing agent workflows and fullsend run (unchanged CLI contract):
| Execution ref field | Source in NormalizedEvent |
|---|---|
source_repo | repo |
event_type | source.raw_type (native event name from the source forge; see notes below) |
event_action | source.raw_action when present |
event_payload.issue | entity when entity.kind == "work_item": {number: entity.id, html_url: entity.url} |
event_payload.pull_request | See below |
event_payload.comment | transition.comment when present: {body: transition.comment.body} |
event_payload._normalized_event | Complete NormalizedEvent embedded by buildEventPayload for CEL overlay resolution during fullsend run (#6748); includes actor.bot_role and actor.role_verified only when the adapter set them (ADR 0107) |
trigger_source (fix agent only) | See below |
status-repo | repo |
status-number | entity.id |
project_number | Not in NormalizedEvent; prioritize agent reads PRIORITIZE_PROJECT_NUMBER from workflow env |
run-url | Runtime-only; set by the dispatch workflow, not projected from NormalizedEvent |
event_type / pull_request_target: v1 preserves the GitHub Actions event name in source.raw_type. When the workflow runs on pull_request_target, adapters emit raw_type: "pull_request_target" (not normalized to pull_request) so downstream routing matches today's dispatch behavior.
trigger_source (fix agent only): this field is emitted only for the fix harness execution ref. When transition.kind == "review_submitted", set trigger_source to transition.review.reviewer_id (the bot that requested changes). When transition.comment.command == "/fs-fix", set trigger_source to actor.id (the human or bot that invoked the command). Omit trigger_source for all other agents and transitions.
event_payload.pull_request (GitHub-shaped, for backward compatibility):
When entity.kind == "change_proposal":
{
"number": 99,
"html_url": "https://github.com/org/repo/pull/99",
"head": {
"ref": "feature-branch",
"sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"repo": { "full_name": "org/repo" }
},
"base": {
"ref": "main",
"repo": { "full_name": "org/repo" }
}
}(number is JSON integer; substitute from entity.id and related fields.)
When entity.kind == "work_item" and entity.linked_change_proposal is set (e.g. GitHub issue_comment on a PR), emit both issue from entity and pull_request from linked_change_proposal + state.change_proposal using the same shape above (number/html_url from linked_change_proposal).
Change-proposal identity: when state.change_proposal is present, state.change_proposal.id MUST equal entity.id if entity.kind == "change_proposal", or entity.linked_change_proposal.id if the work item carries a linked change proposal. Adapters MUST NOT populate conflicting IDs across these fields. When entity.kind == "work_item" and state.change_proposal is present, entity.linked_change_proposal is required (schema-enforced).
Omit pull_request when state.change_proposal is absent. Omit issue when the event targets only a change proposal with no work-item carrier.
head.sha may be omitted in the projected payload when head_sha is unset; downstream workflows may still resolve refs via GitHub API as a fallback.
No execution-ref field requires information outside this schema when adapters have populated state.change_proposal for change-proposal workloads, except project_number (prioritize env) and run-url (runtime).
GitLab event projection: the table above uses GitHub-oriented examples, but the same projection logic applies to GitLab events. source.raw_type carries the GitLab object type (e.g. merge_request, note), and event_payload.pull_request uses the same GitHub-shaped structure for backward compatibility — the GitLab adapter maps MR fields into this shape so downstream workflows do not need forge-specific handling.
GitLab adapter notes
GitLab is a normative v1 source system (gitlab-implementation.md):
| Concern | Mapping |
|---|---|
| Input driver | gitlab-poll (production, cron-polled; ADR 0067) and gitlab-webhook (shipped fast-path, provisioned at install time behind readiness gates, live-validated with stock agents on custom-port CE unverified — see Validation status; reads the file TRIGGER_PAYLOAD points to, re-fetched with a CI_JOB_TOKEN-pinned project identity and API root, and fail-closed on mismatch; see the Adapters table above; ADR 0125) |
source.system | gitlab |
repo slug | Nested group path (group/subgroup/project) — repo_path pattern supports multi-segment paths |
| MR events | Cron-polled MR → entity.kind: change_proposal (native merge_request_event dispatch removed in #7322; see ADR 0067) |
| Issue opened | Cron poll (created_at > both the discovery watermark and a persisted installation cutoff, state == "opened") → entity.kind: work_item, transition.kind: opened; backstop for a missed webhook delivery, keyed like the webhook's issue open so a delivered webhook is not dispatched twice. The cutoff is set when install/converge seeds the events branch, falling back to the start of the first events poll when none is persisted; it is persisted once and independent of the watermark, so an issue created before installation is never recovered. |
| MR opened | Cron poll (created_at > watermark) → transition.kind: opened (review) |
| MR merge | Cron poll (merged_at > watermark) → transition.kind: merged (retro; GitLab merge and close are distinct events) |
| MR closed (unmerged) | Cron poll (closed_at > watermark, merged_at empty) → transition.kind: closed (retro) |
| Notes | note → transition.kind: comment_added |
| Role mapping | Guest→read, Reporter→triage, Developer→write, Maintainer→maintain, Owner→admin |
| Bot identity | GitLab provider metadata may classify project-token actors as bots, but bot classification alone does not provide the registered bot_role required for non-label bot dispatch; an equivalent provider lookup is required. |
The issue/MR/note rows above are the cron-poll watermark mappings. The webhook fast-path derives the transition from the payload action instead of a watermark — see the next subsection.
GitLab webhook transition provenance (gitlab-webhook)
For the webhook fast-path, transition.kind (and source.raw_type/ raw_action) is derived from the payload's object_kind/action hint mapped onto the v1 transition vocabulary, then validated against the project-pinned re-fetch. GitLab action names are not the v1 vocabulary and MUST be mapped:
object_kind | payload action | transition.kind | Snapshot check (else MUST fail closed) |
|---|---|---|---|
merge_request | open | opened | MR exists and state == "opened" |
merge_request | reopen | reopened (or opened) | MR exists and state == "opened" |
merge_request | merge | merged | state == "merged" and merged_at set |
merge_request | close | closed | MR is currently closed (state == "closed"), closed_at set, and merged_at empty |
merge_request | update + changes.labels | label_changed (one per label diffed) | each named label present (add) / absent (remove) as claimed |
merge_request | update (other) | synchronized/edited/marked_ready | uncheckable as a unit → MUST fail closed |
note | create | comment_added | referenced note exists in the re-fetch |
issue | open/reopen | opened/reopened | issue exists and state == "opened" |
issue | close | closed | issue exists and state == "closed" |
issue | update + changes.labels | label_changed (one per label diffed) | each named label present (add) / absent (remove) as claimed |
issue | update (other) | — (ignored, non-routable) | none; claims no transition → MUST be ignored: no re-fetch, no dispatch, no state write, job succeeds |
An ignored event and a rejected event are different outcomes. The issue/update (other) row covers a bare issue update with no changes.labels key, such as a title, description, or assignee edit. It claims no transition, so there is nothing to validate and nothing to route. The adapter MUST record it as ignored and MUST NOT dispatch, write poll state, or fail the dispatcher job. It SHOULD log an explicit no-op message. A rejected event names a transition that the re-fetch contradicts or cannot check. This includes every update that carries changes.labels: a diff that does not resolve to discrete add/remove pairs, a label entry without a title, a claimed end state the re-fetched labels contradict, or a failed actor or resource-label-event binding. A rejected event MUST fail closed: the job fails and nothing is dispatched. The presence of changes.labels decides which outcome applies, not whether its contents are well formed.
Dispatch stack and implementation status. The webhook event builder (BuildWebhookEvents in internal/poll/webhook.go, #7770) reuses the poll stack (RoutableEvent → toNormalizedEvent → dispatch.HarnessRouter → the ADR 0131 signed pipeline-input dispatch), not the CEL spine over normevent.Event. That way the fast-path and the poller backstop share one authorization gate, one RoutableEvent.Key() deduplication scheme, and one launch transport. A dispatch.NormalizedEvent → normevent.Event bridge belongs to any later work that moves GitLab onto the CEL spine. The builder only takes the project from its caller-pinned client, so the base-URL and CI_JOB_TOKEN identity-pin requirements above fall on the driver that wires it in (#7773). That driver has shipped. The dispatcher job pins project, ref, and API root from the CI_JOB_TOKEN job record and fails closed when any of them is unset. fullsend poll --input-driver gitlab-webhook requires an explicit --project and has no CI_PROJECT_PATH fallback. These requirements stay normative: when a pin is unavailable the driver MUST fail closed, never fall back to an overridable variable. Shipping did not close snapshot-consistent replay, which remains a residual risk (see below). Current builder behavior, all of it fail-closed except the ignored bare issue update:
- A bare issue
update(nochanges.labels) is ignored. The builder returns no events and no error, and the dispatcher job logs a no-op and succeeds. A baremerge_requestupdatestill fails closed. - Rows whose actor has no event-time evidence in the re-fetch are rejected:
merge_request/reopenandissue/reopen, which need resource state events. merge_request+changes.labelsis rejected because the poll stack has no MR label transition.- Label removals are validated and then dropped, because the poll stack routes only label additions.
- Every re-fetched event time must fall within a 30-minute window. This narrows replay but does not close it.
The MUST fail closed cells above have no implementer-defined extension point: an update action that is not resolved by a row in this table MUST fail closed. The issue/update (other) row resolves the bare issue update as ignored. That is the only ignored row, and an implementation MUST NOT extend it to other actions or object kinds. Any future named sub-check (e.g. draft→ready for marked_ready) MUST be added as an explicit row in this spec, not invented at implementation time. The merge_request/close row requires the current state == "closed" in addition to the timestamps because GitLab can leave a stale closed_at on a reopened MR; the production poller already guards on state == "closed" for the same reason (internal/poll/events.go), and timestamps alone would admit a closed transition against an open (reopened) MR.
GitLab emits no dedicated labeled action; label changes arrive on both merge requests and issues as an update with changes.labels, so the adapter MUST fan that case out to label_changed and MUST NOT accept a bare update (no changes.labels) as a routable transition. changes.labels is a previous/current array that can add and remove several labels in one webhook; the adapter MUST emit one NormalizedEvent per label diffed (each carrying its own transition.label), or fail closed when the diff cannot be resolved to discrete label add/remove pairs.
Transition sub-object provenance: transition.comment (id, body, command, instruction) and transition.review MUST come from the re-fetch, not the payload. transition.label is the exception — a current-state snapshot cannot recover which label changed or how — so its name and action (added/removed, both schema-required) are derived from the payload's changes.labels diff, admitted only after the present/absent snapshot check confirms the claimed end state (named label present for added, absent for removed). The derivation MUST use GitLab's real wire strings, not the v1 vocabulary:
transition.label.nameis the label object'stitlefield. GitLab's webhook and REST label objects key the human label ontitle, notname; the adapter diffschanges.labelsprevious/currentontitleand maps the diffedtitleontotransition.label.name.transition.label.action(added/removed) is derived from the set difference of that diff: atitleincurrentbut notpreviousisadded; one inpreviousbut notcurrentisremoved.- Actor binding (the actor-to-transition MUST above) matches against GitLab resource label events, whose fields are distinct again:
actionisadd/remove(imperative, not the schema'sadded/removed) and the label is onlabel.name. The adapter MUST match a resource label event whoseaction == "add"(for anaddedtransition) oraction == "remove"(forremoved) and whoselabel.nameequals the diffedtitle, failing closed when none links the claimed actor to the change.
Copying .name straight off the webhook object, or equality-matching the schema's added/removed against a resource label event's add/remove, fail-closes every legitimate label_changed.
Residual replay risk. The snapshot checks above are current-state predicates — they reject an action that contradicts the resource's state, but they do not by themselves prove the action just occurred. Where the CI_JOB_TOKEN-pinned re-fetch exposes event-time evidence (resource events, system notes, or a timestamp comparable to a per-project watermark), the adapter MUST use it to bind the claimed action to a recent occurrence and MUST fail closed when the action is not uniquely determined. Absent such evidence, a trigger-token holder can re-fire a snapshot-consistent historical action (e.g. replay an old note as comment_added); this snapshot-consistent replay is a named residual trigger-token risk that the poller/dedup backstop narrows but does not fully close. ADR 0125 listed it as a fast-path ship gate. The shipped builder narrows it with the 30-minute event-time window and the dedup keys it shares with the poller, but it remains open: within the window, a token holder can still re-fire a matching event.
Residual actor-attribution risk. Distinct from replay, the snapshot checks bind the action to current state but do not establish that the payload-named actor performed this transition. A label_changed whose label is already present, or any transition confirmed only by a users/members GET of the payload-named actor, lets a token holder attribute the change to any write-access member and pass authorization. The adapter MUST bind the actor to the specific transition via event-time evidence (GitLab's resource label events and resource state events, which record user, action, and time) and MUST fail closed when no matching event uniquely links the named actor to the claimed change. This requirement is independent of replay: closing replay would not satisfy it. ADR 0125 listed it as a fast-path ship gate. The shipped builder meets it for each row it accepts. It requires the payload-named actor to equal the actor the re-fetch records for that transition: the note author, the MR author, merger, or closer, the issue author or closer, or the user on the latest matching resource label event. It rejects rows with no event-time actor evidence (both reopen rows). The requirement stays normative: any row added later MUST bind its actor the same way or fail closed.
GitLab webhook validation status
GitLab.com Free and self-managed EE. The 2026-10-20 run on main at a87815b6c passed the same flows on disposable projects in both environments. A newly opened issue started a webhook-triggered dispatcher within about two seconds, and its inference-backed triage agent succeeded. With the webhook removed, the polling schedule dispatched a new issue to a triage agent that succeeded, and replay found no further dispatches. Trigger account rotation moved both active schedules to the replacement account without a second install, and a post-rotation issue reached a successful triage agent through the webhook. repos status reported no drift, and the trigger deploy key was the only protected-branch push grant Fullsend added, separate from the Poller identity. Uninstall left no managed hooks, schedules, variables, trigger tokens, or deploy keys.
Self-managed CE. Runs on an isolated GitLab CE 19.4.1 instance exercised the same real GitLab CI paths, with inference-backed triage using GitLab CI OIDC workload identity federation:
- The first CE run on
mainat6cd9caa58496passed install andrepos statuswith zero drift. The Poller, Analyst, Coder, and trigger account had Developer access. A newly opened issue reached the webhook dispatcher and a triage agent that succeeded. An interrupted trigger-account rotation was recovered by a normalrepos install, after whichrepos statusagain reported zero drift. Uninstall left no managed variables, hooks, triggers, schedules, memberships, trigger deploy keys, or trigger push grants. - The second CE run on
mainat5740e70511d8covered the scenarios the first left open. With the webhook absent, the event-poll schedule's natural run dispatched an issue to a triage agent that succeeded. After--rotate-gitlab-trigger-accountreplaced the trigger account, deploy key, protected-branch grant, and trigger token, a newly opened issue reached the dispatcher and a triage agent that succeeded. The Poller stayed a separate Developer identity,repos statusreported zero drift, and uninstall again removed all managed resources.
Phase A pause scenarios. The instrumented pause run on CE 19.4.1, using main at a87815b6c, recorded all four scenarios. It supersedes an earlier partial run that covered only the first.
- A paused old-Poller trigger-create request, resumed after the fresh account was elevated and the old PAT revoked, returned 403.
- A paused old-Poller PAT self-rotation returned a new PAT, but that PAT belonged to the old Developer user and could not create a trigger.
- When a test proxy dropped the successful response to a bootstrap trigger create, Fullsend quarantined that generation, and the next
repos installdid not repeat the create. A request paused after GitLab's final trigger-creation authorization check still completed after the bootstrap PAT was revoked and the account demoted. The trigger it created was owned by the replacement account, now at Developer level. - A paused bootstrap PAT self-rotation, resumed after revocation and demotion, returned a PAT for the Developer-level replacement user, which could not create a trigger.
Custom-port CE qualification. The CE fixture served GitLab on an HTTPS port other than 443. The stock agents GitLab triage helper rejected that URL, so the CE triage runs used an isolated agents patch (371fdf36cef0, never pushed). The gap is tracked in fullsend-ai/agents#1732. The patch accepts an optional HTTPS port and sends API calls to CI_SERVER_URL. The CE runs therefore verify the Fullsend install, webhook, polling, rotation, and cleanup paths on CE with that patch. They do not verify stock agents on a custom-port CE instance. The port fix has since merged in fullsend-ai/agents#1733, but no CE issue-open or natural scheduled-polling retest with stock agents is recorded yet. The qualification lifts once that retest passes with stock agents main. Until then, the #8245 live-validation gate stays open.
Demonstrated dispatch does not relax any MUST in this section. The fail-closed provenance, identity-pin, and actor-binding requirements above still apply. Snapshot-consistent replay remains a residual risk.
