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).
- Schema:
normalized-event.schema.json - CEL context: harness
triggerexpressions receive a single root variableeventbound to aNormalizedEventobject. - 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. The historical decision is recorded in ADR 0054.
v1 adapters and examples target GitHub webhooks, GitLab cron-poll 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 adapter (ADR 0067);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.
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 |
Adding a new transition.kind is non-breaking — CEL triggers use boolean
expressions, not exhaustive enum matching.
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) |
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.
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-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.
| 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 |
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 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. 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.
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.
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.
See examples/.
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) |
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 is a normative v1 source system (gitlab-implementation.md):
| Concern | Mapping |
|---|---|
| Input driver | gitlab-poll from GitLab CI event payload (cron-polled or merge_request_event; see ADR 0067) |
source.system |
gitlab |
repo slug |
Nested group path (group/subgroup/project) — repo_path pattern supports multi-segment paths |
| MR events | merge_request_event → entity.kind: change_proposal |
| MR merge | merge_request_event (state=merged) → transition.kind: merged (primary path for retro-stage dispatch; GitLab merge and close are distinct events) |
| Notes | note → transition.kind: comment_added |
| Role mapping | Guest→read, Reporter→triage, Developer→write, Maintainer→maintain, Owner→admin |