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 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 adapter (ADR 0067); 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)
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) and classify it as actor.kind: bot using the provider's authoritative actor metadata. Under ADR 0107, the provider resolves actor.bot_role and actor.role is null; authorization comes from the recognized bot identity rather than the forge permission role. fullsend dispatch applies the same identity lookup as webhook paths.

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) ​

For human actors, 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, adapters MUST use the provider's authoritative bot classification and the provider-backed bot_role lookup; role and bot_role are mutually exclusive as specified by ADR 0107. A bot's forge installation permission is not copied into actor.role.

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 from GitLab CI event payload (cron-polled; see ADR 0067)
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