{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://fullsend.ai/normative/normalized-event/v1/normalized-event.schema.json",
  "title": "NormalizedEvent",
  "description": "Forge-neutral routing input for fullsend dispatch and harness CEL trigger evaluation (ADR 0061). v1 targets GitHub Actions webhooks (including Discussions via entity.kind conversation and transition.comment id/parent_id per ADR 0086), GitLab cron-poll input (ADR 0067) and the shipped GitLab native-webhook fast-path input (ADR 0125), and Jira poll input (ADR 0063).",
  "type": "object",
  "additionalProperties": false,
  "required": ["repo", "entity", "transition", "actor", "state", "source"],
  "properties": {
    "repo": {
      "$ref": "#/$defs/repo_path"
    },
    "entity": {
      "$ref": "#/$defs/entity"
    },
    "transition": {
      "$ref": "#/$defs/transition"
    },
    "actor": {
      "$ref": "#/$defs/actor"
    },
    "state": {
      "$ref": "#/$defs/state"
    },
    "source": {
      "$ref": "#/$defs/source"
    }
  },
  "allOf": [
    {
      "if": {
        "properties": {
          "entity": {
            "properties": { "kind": { "const": "change_proposal" } },
            "required": ["kind"]
          }
        },
        "required": ["entity"]
      },
      "then": {
        "properties": {
          "entity": {
            "properties": { "linked_change_proposal": false }
          }
        }
      }
    },
    {
      "if": {
        "properties": {
          "entity": {
            "properties": { "kind": { "const": "work_item" } },
            "required": ["kind"]
          },
          "state": {
            "required": ["change_proposal"]
          }
        },
        "required": ["entity", "state"]
      },
      "then": {
        "properties": {
          "entity": {
            "required": ["linked_change_proposal"]
          }
        }
      }
    },
    {
      "if": {
        "properties": {
          "source": {
            "properties": { "system": { "const": "jira" } },
            "required": ["system"]
          }
        },
        "required": ["source"]
      },
      "then": {
        "properties": {
          "entity": {
            "required": ["key"]
          }
        }
      }
    },
    {
      "if": {
        "properties": {
          "entity": {
            "properties": { "kind": { "const": "conversation" } },
            "required": ["kind"]
          }
        },
        "required": ["entity"]
      },
      "then": {
        "properties": {
          "state": {
            "required": ["conversation"]
          }
        },
        "required": ["state"]
      },
      "else": {
        "properties": {
          "state": {
            "properties": { "conversation": false }
          }
        }
      }
    }
  ],
  "$defs": {
    "repo_path": {
      "type": "string",
      "pattern": "^[a-zA-Z0-9._-]+(/[a-zA-Z0-9._-]+)+$",
      "minLength": 3,
      "maxLength": 512,
      "description": "Repository path (owner/repo for GitHub, group/subgroup/project for GitLab). Adapters MUST reject path segments containing '..' (path traversal) independently of this pattern."
    },
    "entity": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "id", "url"],
      "properties": {
        "kind": {
          "type": "string",
          "enum": ["work_item", "change_proposal", "conversation"],
          "description": "Forge-neutral entity kind (issue vs PR/MR vs conversation/Discussion/channel). Threads are not a separate entity.kind; reply association uses transition.comment.parent_id. See ADR 0086."
        },
        "id": {
          "type": "integer",
          "minimum": 1,
          "description": "Stable numeric entity id in the source system (GitHub issue/PR number, Jira issue id)."
        },
        "key": {
          "type": "string",
          "pattern": "^[A-Za-z][A-Za-z0-9_]+-\\d+$",
          "minLength": 1,
          "maxLength": 255,
          "description": "Human-readable work item key when the source system uses one (e.g. Jira PROJ-123). Required when source.system is jira, and must then match the Jira work item key pattern ^[A-Z][A-Z0-9_]+-[1-9][0-9]*$ (enforced by normalized-event validation; the pattern here is the cross-system shape)."
        },
        "url": {
          "type": "string",
          "format": "uri",
          "minLength": 1,
          "maxLength": 2048,
          "description": "Canonical URL for the entity in its source system."
        },
        "linked_change_proposal": {
          "type": "object",
          "additionalProperties": false,
          "required": ["id", "url"],
          "properties": {
            "id": {
              "type": "integer",
              "minimum": 1
            },
            "url": {
              "type": "string",
              "format": "uri",
              "minLength": 1,
              "maxLength": 2048
            }
          },
          "description": "When transition occurs on a work_item that is also a change proposal (e.g. GitHub PR comment events). When state.change_proposal is present, linked_change_proposal.id MUST equal state.change_proposal.id."
        }
      }
    },
    "transition": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind"],
      "properties": {
        "kind": {
          "type": "string",
          "enum": [
            "opened",
            "reopened",
            "edited",
            "synchronized",
            "updated",
            "closed",
            "merged",
            "marked_ready",
            "label_changed",
            "comment_added",
            "review_submitted"
          ],
          "description": "Semantic lifecycle transition. Prefer edited/synchronized over updated; adapters MAY map reopened to opened when distinction is unnecessary."
        },
        "label": {
          "type": "object",
          "additionalProperties": false,
          "required": ["name", "action"],
          "properties": {
            "name": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255
            },
            "action": {
              "type": "string",
              "enum": ["added", "removed"]
            }
          }
        },
        "comment": {
          "type": "object",
          "additionalProperties": false,
          "required": ["body"],
          "properties": {
            "id": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "Backend message id for this comment. Required when entity.kind is conversation on comment_* transitions (ADR 0086). GitHub: comment id or node_id; Slack: message ts."
            },
            "parent_id": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "Thread-root message id — always the safe reply target for this comment's thread (ADR 0086). Required when entity.kind is conversation on comment_* transitions. Equal to comment.id for top-level / thread-root messages; for replies, the root id (normalize GitHub discussion_comment null parent to id; Slack thread_ts, or ts when the message starts the thread)."
            },
            "command": {
              "type": "string",
              "maxLength": 64,
              "description": "First whitespace-delimited token of the first line (e.g. /fs-triage)."
            },
            "body": {
              "type": "string",
              "maxLength": 4096,
              "description": "Comment body truncated to 4096 Unicode code points by the source adapter; a /fs-* command body is carried unshortened only on the GitLab target (the Jira source keeps it whole past the cap there so the dispatch door can measure and refuse it when it does not fit the budget), while the GitHub target — whose transport has no chunk budget — keeps the 4096 cap for command bodies too. maxLength is the adapter cap and holds for the GitHub target and non-command bodies; a GitLab-target command body is the documented exception and may exceed it up to the dispatch chunk budget. Non-command bodies are bounded further by the dispatch transports (a shortening ladder on Jira, 270 code points on GitLab)."
            },
            "instruction": {
              "type": "string",
              "maxLength": 4096,
              "description": "Text after the command, extracted from the raw comment before body truncation (e.g. /fs-fix instruction)."
            }
          }
        },
        "review": {
          "type": "object",
          "additionalProperties": false,
          "required": ["state", "reviewer_id"],
          "properties": {
            "state": {
              "type": "string",
              "enum": [
                "approved",
                "changes_requested",
                "commented",
                "dismissed"
              ]
            },
            "reviewer_id": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "Forge login of the reviewer (bot accounts end with [bot] on GitHub)."
            }
          }
        }
      },
      "allOf": [
        {
          "if": {
            "properties": { "kind": { "const": "label_changed" } },
            "required": ["kind"]
          },
          "then": { "required": ["label"] },
          "else": { "properties": { "label": false } }
        },
        {
          "if": {
            "properties": { "kind": { "const": "comment_added" } },
            "required": ["kind"]
          },
          "then": { "required": ["comment"] },
          "else": { "properties": { "comment": false } }
        },
        {
          "if": {
            "properties": { "kind": { "const": "review_submitted" } },
            "required": ["kind"]
          },
          "then": { "required": ["review"] },
          "else": { "properties": { "review": false } }
        }
      ]
    },
    "actor": {
      "type": "object",
      "additionalProperties": false,
      "required": ["id", "kind", "role", "is_entity_author"],
      "properties": {
        "id": {
          "type": "string",
          "minLength": 1,
          "maxLength": 255,
          "description": "Forge login or service account identifier."
        },
        "kind": {
          "type": "string",
          "enum": ["human", "bot"]
        },
        "role": {
          "type": "string",
          "enum": [
            "admin",
            "maintain",
            "write",
            "triage",
            "read",
            "none",
            "external"
          ],
          "description": "Effective repository permission level (ADR 0054); post-migration ADR 0107 producers use `none` for bots, while legacy bot permission values remain valid in v1 during migration."
        },
        "bot_role": {
          "type": ["string", "null"],
          "minLength": 1,
          "maxLength": 255,
          "description": "Optional provider-resolved canonical Fullsend bot role; absent or null for humans and unrecognized bots."
        },
        "role_verified": {
          "type": "boolean",
          "description": "Optional lookup-verification bit: for humans, true means role is a trusted forge permission; for bots, true means bot-role lookup completed, including a successful no-match result. It is not required for the permanent GitHub label-added authorization exception."
        },
        "is_entity_author": {
          "type": "boolean",
          "description": "True when actor.id is the author of the work item, change proposal, or conversation."
        },
        "is_entity_assignee": {
          "type": "boolean",
          "description": "Optional. True when actor.id is one of the entity's current assignees when the event is built; false when the entity has no assignee, the actor is not one, or assignees cannot be determined. Reviewers are not assignees. A routing field like is_entity_author: it never grants permission. Absent is equivalent to false."
        }
      },
      "allOf": [
        {
          "if": {
            "properties": { "kind": { "const": "human" } },
            "required": ["kind"]
          },
          "then": {
            "properties": {
              "bot_role": { "const": null }
            }
          }
        },
        {
          "if": {
            "properties": {
              "kind": { "const": "bot" },
              "role_verified": { "const": false }
            },
            "required": ["kind", "role_verified"]
          },
          "then": {
            "properties": { "bot_role": { "const": null } }
          }
        },
        {
          "if": {
            "properties": {
              "kind": { "const": "bot" },
              "bot_role": { "type": "string" }
            },
            "required": ["kind", "bot_role"]
          },
          "then": {
            "required": ["role_verified"],
            "properties": { "role_verified": { "const": true } }
          }
        }
      ]
    },
    "state": {
      "type": "object",
      "additionalProperties": false,
      "required": ["labels"],
      "properties": {
        "labels": {
          "type": "array",
          "items": {
            "type": "string",
            "minLength": 1,
            "maxLength": 255
          },
          "description": "Snapshot of label names on the entity at event time. See state.conversation for conversation-specific label semantics."
        },
        "conversation": {
          "type": "object",
          "additionalProperties": false,
          "required": ["category"],
          "properties": {
            "category": {
              "type": "object",
              "additionalProperties": false,
              "required": ["name"],
              "properties": {
                "id": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 255,
                  "description": "Backend-stable category id when available (e.g. GitHub GraphQL node id)."
                },
                "name": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 255,
                  "description": "Human-readable category name (GitHub Discussion category; Slack channel section when mapped)."
                },
                "slug": {
                  "type": "string",
                  "minLength": 1,
                  "maxLength": 255,
                  "description": "Backend slug when available. On GitHub, present in webhook REST payloads (discussion.category.slug) but not on GraphQL DiscussionCategory; GITHUB_EVENT_PATH adapters can populate it, GraphQL-only enrichment cannot. Treat as untrusted input even after schema validation — no structural pattern is enforced so Slack/Discord/Matrix adapters can pass through backend identifiers."
                },
                "format": {
                  "type": "string",
                  "enum": [
                    "discussion",
                    "question_answer",
                    "announcement",
                    "poll",
                    "other"
                  ],
                  "description": "Optional portable category format for routing. Not a permission authority (ADR 0054). GitHub GraphQL DiscussionCategory exposes isAnswerable but no announcement/poll/discussion format field: adapters MUST set question_answer when isAnswerable is true, and SHOULD omit format otherwise unless they document an explicit heuristic (e.g. name/slug matching). UI-only GitHub create restrictions (e.g. announcement maintain/admin) are not queryable via the public API and MUST NOT be inferred solely from this field."
                }
              },
              "description": "Exclusive category of the conversation (1:M category→conversation). Distinct from state.labels (M:M tags on the conversation, not on threads or messages)."
            }
          },
          "description": "Required when entity.kind is conversation and forbidden otherwise (ADR 0086). Snapshot of conversation metadata at event time. Threading is on transition.comment (id / parent_id), not here. state.labels are labels on the conversation; category is not a label; threads and messages are not labeled independently."
        },
        "change_proposal": {
          "type": "object",
          "additionalProperties": false,
          "required": [
            "id",
            "head_repo",
            "base_repo",
            "head_ref",
            "base_ref",
            "author_id",
            "is_fork"
          ],
          "properties": {
            "id": {
              "type": "integer",
              "minimum": 1
            },
            "head_repo": {
              "$ref": "#/$defs/repo_path"
            },
            "base_repo": {
              "$ref": "#/$defs/repo_path"
            },
            "head_ref": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "Head branch name (Git ref) for checkout and push."
            },
            "base_ref": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255,
              "description": "Base branch name (Git ref) the change proposal targets."
            },
            "head_sha": {
              "type": "string",
              "pattern": "^[0-9a-fA-F]{40}$|^[0-9a-fA-F]{64}$",
              "description": "Commit SHA at the change-proposal head when known (optional; adapters should populate from webhooks when available)."
            },
            "author_id": {
              "type": "string",
              "minLength": 1,
              "maxLength": 255
            },
            "is_fork": {
              "type": "boolean"
            },
            "diff": {
              "type": "object",
              "additionalProperties": false,
              "required": [
                "files",
                "additions",
                "deletions"
              ],
              "dependentRequired": {
                "paths_complete": [
                  "paths"
                ]
              },
              "properties": {
                "files": {
                  "type": "integer",
                  "minimum": 0
                },
                "additions": {
                  "type": "integer",
                  "minimum": 0
                },
                "deletions": {
                  "type": "integer",
                  "minimum": 0
                },
                "paths": {
                  "type": "array",
                  "maxItems": 3000,
                  "items": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 4096,
                    "pattern": "^(?:[^./\\x00-\\x1f\\x7f][^/\\x00-\\x1f\\x7f]*|\\.[^./\\x00-\\x1f\\x7f][^/\\x00-\\x1f\\x7f]*|\\.\\.[^/\\x00-\\x1f\\x7f]+)(?:/(?:[^./\\x00-\\x1f\\x7f][^/\\x00-\\x1f\\x7f]*|\\.[^./\\x00-\\x1f\\x7f][^/\\x00-\\x1f\\x7f]*|\\.\\.[^/\\x00-\\x1f\\x7f]+))*$"
                  },
                  "description": "Changed file paths relative to the repository root. May be truncated when the forge caps the list; see paths_complete."
                },
                "paths_complete": {
                  "type": "boolean",
                  "description": "True only when paths is present and lists every changed file."
                }
              },
              "description": "Optional change facts at head_sha, added for ADR 0112's routing and not yet populated by any adapter. Untrusted (README, Trusted fields)."
            },
            "linked_work_items": {
              "type": "array",
              "maxItems": 50,
              "items": {
                "type": "object",
                "additionalProperties": false,
                "required": [
                  "id",
                  "state",
                  "labels"
                ],
                "properties": {
                  "id": {
                    "type": "integer",
                    "minimum": 1
                  },
                  "key": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 255,
                    "pattern": "^([A-Za-z][A-Za-z0-9_]+-\\d+|[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)+#\\d+)$",
                    "description": "Human-readable key when the id alone is ambiguous, for example a Jira key (PROJ-123) or a cross-repository reference (owner/repo#12)."
                  },
                  "state": {
                    "type": "string",
                    "enum": [
                      "open",
                      "closed"
                    ]
                  },
                  "labels": {
                    "type": "array",
                    "maxItems": 100,
                    "items": {
                      "type": "string",
                      "minLength": 1,
                      "maxLength": 255
                    }
                  }
                }
              },
              "description": "Optional work items the change proposal links to (for example through closing keywords), added for ADR 0112's routing and not yet populated by any adapter. Untrusted: the author chooses which items are linked (README, Trusted fields)."
            }
          },
          "description": "Required when the matched harness needs change-proposal execution context (review, fix, retro on a PR). id MUST match entity.id or entity.linked_change_proposal.id when those are present. Adapters must populate branch and repo fields so dispatch can project event_payload without API round-trips."
        }
      }
    },
    "source": {
      "type": "object",
      "additionalProperties": false,
      "required": ["system", "raw_type"],
      "properties": {
        "system": {
          "type": "string",
          "enum": ["github", "gitlab", "jira", "manual", "schedule"],
          "description": "Event provenance. v1 adapters emit github (webhooks), gitlab (cron-poll input per ADR 0067; shipped native-webhook input per ADR 0125, with live validation partial), jira (poll input), manual, or schedule."
        },
        "raw_type": {
          "type": "string",
          "minLength": 1,
          "maxLength": 128,
          "description": "Native event or object type (e.g. issues, issue_comment for GitHub; issue, comment for Jira)."
        },
        "raw_action": {
          "type": "string",
          "maxLength": 128,
          "description": "Native action when applicable (e.g. created, labeled, synchronize)."
        }
      }
    }
  }
}
