Skip to content

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 trigger expressions receive a single root variable event bound to a NormalizedEvent object. This describes the currently shipped event-backed path. ADR 0098 adopts a future entity-first context with required entity and nullable event; its field-level contract remains follow-up versioned work.
  • Authorization: fullsend dispatch enforces the Authorization Contract v1 as a platform-level gate after normalization and before CEL evaluation. Harness trigger expressions 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.

Scope (v1) ​

v1 adapters and examples target GitHub webhooks, GitLab cron-poll and native-webhook input, and Jira poll input:

  • source.system is github, gitlab, jira, manual, or schedule.
  • repo is the target Fullsend repository (owner/repo for GitHub, group/subgroup/project for GitLab) for all systems — including Jira poll events (see jira-poll-adapter.md).
  • The gha-event input driver is the production GitHub adapter; gitlab-poll is the production GitLab poll adapter (ADR 0067); gitlab-webhook is the planned GitLab native-webhook adapter (ADR 0125); jira-poll is the production Jira poll adapter; json supports tests and replay.
  • entity.kind: conversation covers GitHub Discussions and future chat systems (ADR 0086). A conversation is the container (Discussion / Slack channel). Threading is expressed on the message via transition.comment.id and transition.comment.parent_id (always the thread-root id; equal to id for top-level messages). GitHub Discussion adapters are planned under gha-event; additional source.system values (e.g. Slack) are non-breaking enum additions when those drivers land.
  • Conversations carry state.conversation.category (exactly one; 1:M category→conversation) separately from state.labels (M:M tags on the conversation). Threads and messages carry neither category nor labels; comment_added events still snapshot the parent conversation's category and labels and MUST include transition.comment.id and parent_id.

Versioning ​

Breaking changes require docs/normative/normalized-event/v2/.

Changev1 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 documentsConsumers must migrate
Non-breaking (allowed in v1.x schema/README): add optional fields, add new enum values, relax validation, clarify documentationExisting fixtures and triggers keep working

Adding a new transition.kind is non-breaking — CEL triggers use boolean expressions, not exhaustive enum matching.

Adapters ​

Input drivers map native forge events into this struct:

DriverSourcev1 status
gha-eventGITHUB_EVENT_PATH + gh snapshot for labels and change-proposal metadataProduction; Discussions → entity.kind: conversation planned (ADR 0086)
gitlab-pollGitLab CI event payload (cron-polled; ADR 0067)Production (poll)
gitlab-webhookTRIGGER_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 ship-gating residual, 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 base URL once its pin is specified) 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 even a future base-URL 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 pinning is not yet specified — ADR 0125 tracks it as an open, ship-gating residual risk in its Consequences section, and this adapter MUST NOT be implemented until that gap is closedPlanned (webhook fast-path)
jira-pollJira issue search + changelog/comments since lastCheck (jira-poll-adapter.md, ADR 0063)Production (poll)
jsonstdin or --input-fileTests, replay

Adapters must populate:

  • state.labels when routing guards or label-based triggers apply.
  • state.conversation (with category.name at minimum) whenever entity.kind is conversation — including message/comment_added events. Do not encode the category as a synthetic label. On comment transitions, also set transition.comment.id and parent_id (parent_id == id for thread-root messages; otherwise the root id).
  • state.change_proposal (including head_ref, base_ref, and head_sha when 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 service identity (e.g. the GitHub App bot or workflow GITHUB_ACTOR), actor.kind to bot, and actor.role to the effective permission of that identity on the target repo (typically write for installed apps). fullsend dispatch applies the same permission check as webhook paths; it does not default schedule/manual actors to role: none.

Transition sub-objects ​

Transition-specific fields are present only when required by transition.kind:

transition.kindRequired sub-objectForbidden otherwise
label_changedlabelcomment, review
comment_addedcommentlabel, review
review_submittedreviewlabel, comment
all other kindsnonelabel, 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 ​

KindUse
openedEntity created or first opened
reopenedEntity reopened after close; adapters MAY map to opened when the distinction is unnecessary
editedTitle/body/metadata edit without new commits
synchronizedHead branch received new commits (GitHub synchronize)
updatedLegacy umbrella; prefer edited or synchronized for new adapters
mergedChange proposal merged into target branch
closed, marked_ready, label_changed, comment_added, review_submittedAs 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) ​

actor.role uses permission levels aligned with ADR 0054 and the GitHub collaborator permission API:

actor.roleGitHub permissionTypical use in triggers
adminadminFull repo control
maintainmaintainSettings without destructive admin
writewrite (member)Push, label, comment
triagetriageLabel and moderate without write
readreadRead-only collaborator
nonenoneAuthenticated 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. For GitHub App bots, use the installation's effective permission on the repository (typically write), not none — the collaborator API often returns 404 for [bot] accounts even when the app has write access via installation token.

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.

CEL trigger examples ​

Harness trigger expressions are CEL booleans over event:

cel
// 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.

Examples ​

See examples/.

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 fieldSource in NormalizedEvent
source_reporepo
event_typesource.raw_type (native event name from the source forge; see notes below)
event_actionsource.raw_action when present
event_payload.issueentity when entity.kind == "work_item": {number: entity.id, html_url: entity.url}
event_payload.pull_requestSee below
event_payload.commenttransition.comment when present: {body: transition.comment.body}
event_payload._normalized_eventComplete NormalizedEvent embedded by buildEventPayload for CEL overlay resolution during fullsend run (#6748)
trigger_source (fix agent only)See below
status-reporepo
status-numberentity.id
project_numberNot in NormalizedEvent; prioritize agent reads PRIORITIZE_PROJECT_NUMBER from workflow env
run-urlRuntime-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":

json
{
  "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):

ConcernMapping
Input drivergitlab-poll (production, cron-polled; ADR 0067) and gitlab-webhook (planned fast-path from the file TRIGGER_PAYLOAD points to, re-fetched with a CI_JOB_TOKEN-pinned project identity — host/base-URL pin still open — and fail-closed on mismatch; see the Adapters table above; ADR 0125)
source.systemgitlab
repo slugNested group path (group/subgroup/project) — repo_path pattern supports multi-segment paths
MR eventsCron-polled MR → entity.kind: change_proposal (native merge_request_event dispatch removed in #7322; see ADR 0067)
MR openedCron poll (created_at > watermark) → transition.kind: opened (review)
MR mergeCron 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)
Notesnote → transition.kind: comment_added
Role mappingGuest→read, Reporter→triage, Developer→write, Maintainer→maintain, Owner→admin

The 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_kindpayload actiontransition.kindSnapshot check (else MUST fail closed)
merge_requestopenopenedMR exists and state == "opened"
merge_requestreopenreopened (or opened)MR exists and state == "opened"
merge_requestmergemergedstate == "merged" and merged_at set
merge_requestcloseclosedMR is currently closed (state == "closed"), closed_at set, and merged_at empty
merge_requestupdate + changes.labelslabel_changed (one per label diffed)each named label present (add) / absent (remove) as claimed
merge_requestupdate (other)synchronized/edited/marked_readyuncheckable as a unit → MUST fail closed
notecreatecomment_addedreferenced note exists in the re-fetch
issueopen/reopenopened/reopenedissue exists and state == "opened"
issuecloseclosedissue exists and state == "closed"
issueupdate + changes.labelslabel_changed (one per label diffed)each named label present (add) / absent (remove) as claimed
issueupdate (other)—uncheckable as a unit → MUST fail closed

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. 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.name is the label object's title field. GitLab's webhook and REST label objects key the human label on title, not name; the adapter diffs changes.labels previous/current on title and maps the diffed title onto transition.label.name.
  • transition.label.action (added/removed) is derived from the set difference of that diff: a title in current but not previous is added; one in previous but not current is removed.
  • Actor binding (the actor-to-transition MUST above) matches against GitLab resource label events, whose fields are distinct again: action is add/remove (imperative, not the schema's added/removed) and the label is on label.name. The adapter MUST match a resource label event whose action == "add" (for an added transition) or action == "remove" (for removed) and whose label.name equals the diffed title, 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, and ADR 0125 gates the fast-path on it.

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. Actor-to-transition attribution is a ship-gating residual that remains open even if replay is fully closed; ADR 0125 gates the fast-path on it.