{
  "$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 planned GitLab native-webhook 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."
        },
        "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 for dispatch transport."
            },
            "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."
        }
      },
      "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; planned native-webhook input per ADR 0125), 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)."
        }
      }
    }
  }
}
