{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://abicheck.github.io/abicheck/reference/schemas/v1/aggregate_report.schema.json",
  "title": "abicheck aggregate report",
  "description": "Schema for the JSON document produced by `abicheck aggregate --format json`, the multi-target CI fan-in gate. Four axes are kept separate (ADR-042, plus ADR-049 \u00a77): gate (each report's own recorded severity decision, combined), coverage (did every required target report?), compatibility (worst verdict, for reporting), and contract_coverage (added in schema 1.3: did a target that *did* report have the evidence to close its selected --contract domain?). The last two are distinct on purpose \u2014 coverage and contract_coverage both contribute 1 and answer different questions, so an exit of 1 stays attributable to one of them. The document is versioned via aggregate_schema_version; additive changes (new optional keys) bump the MINOR component, breaking changes bump the MAJOR. Consumers should accept any document whose MAJOR matches theirs and ignore unknown keys.",
  "type": "object",
  "required": [
    "aggregate_schema_version",
    "status",
    "compatibility",
    "coverage",
    "contract_coverage",
    "gate",
    "targets",
    "unexpected_targets"
  ],
  "properties": {
    "aggregate_schema_version": {
      "type": "string",
      "description": "SemVer-style MAJOR.MINOR version of this document's structure."
    },
    "status": {
      "type": "string",
      "enum": ["pass", "fail"],
      "description": "Whether the gate passed (mirrors gate.passed)."
    },
    "compatibility": {
      "type": "object",
      "description": "The reporting-only compatibility axis — NOT the gate.",
      "required": ["verdict", "analyzed_targets"],
      "properties": {
        "verdict": {
          "type": ["string", "null"],
          "description": "Worst compatibility verdict over the targets feeding this axis (expected analyzed targets plus gated unexpected ones); null when nothing was analyzed."
        },
        "analyzed_targets": {
          "type": "integer",
          "minimum": 0,
          "description": "Count of targets contributing to the compatibility verdict."
        }
      }
    },
    "coverage": {
      "type": "object",
      "description": "Did every required expected target actually report?",
      "required": [
        "status",
        "required_targets",
        "analyzed_required_targets",
        "missing_required_targets",
        "blocking"
      ],
      "properties": {
        "status": {
          "type": "string",
          "enum": ["complete", "partial", "empty"]
        },
        "required_targets": {"type": "integer", "minimum": 0},
        "analyzed_required_targets": {"type": "integer", "minimum": 0},
        "missing_required_targets": {
          "type": "array",
          "items": {"type": "string"}
        },
        "blocking": {
          "type": "boolean",
          "description": "True when the required-coverage gap contributes to the exit code."
        }
      }
    },
    "gate": {
      "type": "object",
      "description": "The single CI gate decision.",
      "required": ["passed", "exit_code", "blocking_targets", "coverage_blocking"],
      "properties": {
        "passed": {"type": "boolean"},
        "exit_code": {
          "type": "integer",
          "description": "0 pass / 1 coverage gap, addition-or-quality block, or a non-verdict per-report failure / 2 source-API break / 4 ABI break."
        },
        "blocking_targets": {
          "type": "array",
          "items": {"type": "string"}
        },
        "coverage_blocking": {"type": "boolean"}
      }
    },
    "contract_coverage": {
      "type": "object",
      "description": "Added in schema 1.3. ADR-049 Phase 7's orthogonal contract-coverage axis for the whole target set. Separate from `coverage` above because the two answer different questions and both contribute 1: `coverage` is about a required target reporting at all, this is about a target that did report having the evidence to close its selected --contract domain. Present in every document; exit_contribution is 0 and incomplete_targets empty when no target evaluated a contract. The two can disagree: a target accepting incomplete coverage via contract.unresolved=warn is listed while contributing 0.",
      "required": ["exit_contribution", "incomplete_targets"],
      "properties": {
        "exit_contribution": {
          "type": "integer",
          "description": "0 or 1, the max over every gated target's own contract_coverage_exit_contribution. Folded into gate.exit_code with max, so it raises a clean 0 to 1 and can never lower a real break's 2/4.",
          "enum": [
            0,
            1
          ]
        },
        "incomplete_targets": {
          "type": "array",
          "description": "The target ids whose contract coverage was incomplete -- chiefly the why behind a non-zero exit_contribution, but not only that: a target that accepted incomplete coverage via contract.unresolved=warn is listed here too, with its own contract_coverage_exit staying 0. warn accepts incomplete assurance, it does not hide it, so omitting such a target would misreport the matrix as fully covered. Which listed targets actually gated is readable per target.",
          "items": {
            "type": "string"
          }
        }
      }
    },
    "targets": {
      "type": "array",
      "description": "Every expected target, in stable id order — analyzed or unavailable.",
      "items": {"$ref": "#/$defs/target"}
    },
    "unexpected_targets": {
      "type": "array",
      "description": "Reports for target ids not in the expected set (a matrix leg the manifest has not caught up to).",
      "items": {"$ref": "#/$defs/target"}
    },
    "profile_matrix": {
      "type": "array",
      "description": "Added in schema 1.1 (status-review item 5): every distinct base_target among `targets` whose target_id is check_id-shaped (ADR-047 §7 target@profile#baseline_channel@requested_depth), grouped across the profiles that checked it — so a project running one target under several toolchain profiles sees one logical entry instead of unrelated-looking target rows. Empty when no target carries a parseable profile (the common single-profile case).",
      "items": {"$ref": "#/$defs/profile_matrix_entry"}
    },
    "finding_matrix": {
      "type": "array",
      "description": "Added in schema 1.2 (G34 Phase D): per-finding cross-profile reconciliation. Where profile_matrix answers 'which profiles are affected', this answers 'which finding is the one that differs' — every distinct logical finding across the profiles of each base_target, keyed by the same tiered identity model diff_filtering.py uses as its cross-detector dedup key (ADR-049 Phase 2), so a finding one profile reports with rich DWARF evidence and another reports from symbols alone reconciles to one entry. Empty when no target carries a parseable profile, or when no profile's report enumerated its findings.",
      "items": {"$ref": "#/$defs/finding_matrix_entry"}
    }
  },
  "$defs": {
    "target": {
      "type": "object",
      "required": ["target_id", "required", "state", "compatibility_verdict", "gate", "contract_coverage_exit"],
      "properties": {
        "target_id": {"type": "string"},
        "required": {"type": "boolean"},
        "state": {
          "type": "string",
          "enum": ["analyzed", "unavailable"]
        },
        "compatibility_verdict": {"type": ["string", "null"]},
        "gate": {
          "anyOf": [{"type": "null"}, {"$ref": "#/$defs/gate"}]
        },
        "unexpected": {"type": "boolean"},
        "profile_id": {
          "type": "string",
          "description": "Added in schema 1.1: the profile component of a check_id-shaped target_id — absent when target_id doesn't follow that shape."
        },
        "report_path": {"type": "string"},
        "library": {"type": "string"},
        "contract_coverage_exit": {
          "type": "integer",
          "description": "ADR-049 Phase 7 (aggregate schema 1.3): this target's own contract-coverage contribution, read verbatim from its report's contract_coverage_exit_contribution -- 0 when the target evaluated no contract, or evaluated one whose evidence closed, or accepted incomplete coverage via contract.unresolved=warn. Folded into the top-level contract_coverage.exit_contribution and thence into gate.exit_code with max. Always present; 0 for an unavailable target, whose absence is already gated on the separate required-coverage axis.",
          "enum": [
            0,
            1
          ]
        },
        "reason": {
          "type": "string",
          "description": "Present when the target is unavailable — why it could not be analyzed."
        }
      }
    },
    "profile_matrix_entry": {
      "type": "object",
      "description": "Added in schema 1.1. One logical target's outcome across every profile that checked it.",
      "required": ["base_target", "profiles", "affected_profiles", "incomplete_profiles", "unanalyzed_profiles", "contract_incomplete_profiles", "verdict_by_profile"],
      "properties": {
        "base_target": {"type": "string"},
        "profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Every profile that reported for this target, sorted."
        },
        "affected_profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Subset of profiles whose worst analyzed verdict was neither NO_CHANGE nor COMPATIBLE, or whose gate is blocking even though the verdict itself is compatible."
        },
        "incomplete_profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Subset of profiles where at least one of that profile's *required* checks (a base_target/profile pair can have more than one, at different baseline channels/requested depths) is unavailable -- no report arrived for it. An unavailable optional check does not set this. A profile can be both affected and incomplete at once."
        },
        "unanalyzed_profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Subset of profiles with zero analyzed checks at all (every check for that profile is unavailable, required or optional) -- verdict_by_profile[pid] is null for these. Distinct from incomplete_profiles: a profile can have some analyzed result and still be incomplete, but a profile here has none at all, so it must never be described as clean."
        },
        "contract_incomplete_profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Added in schema 1.3. Subset of profiles with at least one check whose ADR-049 contract-coverage axis is short of evidence -- the profile-level view of the top-level contract_coverage.incomplete_targets, using the same predicate. Deliberately separate from affected_profiles, which is defined by verdict and gate: ADR-049 \u00a77 keeps the coverage axis orthogonal to compatibility and asks reports to identify which axis produced an exit of 1, so folding it in would answer \"which profile\" by destroying \"which axis\". A profile can appear here and in affected_profiles at once."
        },
        "verdict_by_profile": {
          "type": "object",
          "additionalProperties": {"type": ["string", "null"]},
          "description": "profile -> verdict value, or null when that profile's target report is unavailable."
        }
      }
    },
    "finding_matrix_entry": {
      "type": "object",
      "description": "Added in schema 1.2. One logical finding, reconciled across every profile that checked its target. The three profile lists partition that target's profiles and never overlap.",
      "required": ["base_target", "finding_identity", "identity_tier", "kinds", "symbol", "description", "scope", "affected_profiles", "unaffected_profiles", "undetermined_profiles", "cross_abi_declaration"],
      "properties": {
        "base_target": {"type": "string"},
        "finding_identity": {
          "type": "string",
          "description": "The reconciliation key: aggregate_findings.resolve_report_change_identity's primary_id. Deliberately NOT the per-report finding_id, which also hashes description and source_location unconditionally -- right for correlating two runs of the same comparison, too strict across profiles, where a header path or a rendered size can legitimately differ for one logical event. Opaque: compare it for equality, don't parse it."
        },
        "identity_tier": {
          "type": "string",
          "enum": ["canonical", "normalized", "reduced"],
          "description": "Confidence of the identity that matched these profiles' findings. A 'reduced' match is a weaker claim that two profiles' findings are really the same one."
        },
        "kinds": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Every distinct ChangeKind slug the affected profiles reported this finding under, sorted -- across every check each profile ran, not just the first. Usually one; two when profiles (or two checks of one profile, at different depths) differ in available evidence and the identity model collapses the pair (e.g. func_removed where DWARF was available, func_removed_elf_only where it was not)."
        },
        "symbol": {"type": "string", "description": "Representative symbol/type name, from the first affected profile in sorted order."},
        "description": {"type": "string", "description": "Representative description, from the first affected profile in sorted order."},
        "scope": {
          "type": "string",
          "enum": ["all_profiles", "profile_specific", "partial", "undetermined"],
          "description": "How this finding is distributed. 'undetermined' wins over every other answer whenever any profile's findings are unknown -- a finding is never called profile-specific while a profile that might also carry it was never read."
        },
        "affected_profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Profiles whose reports carry this finding, sorted. A profile that reported it on one check and failed to report at all on another is listed here, not as undetermined -- it demonstrably has the finding."
        },
        "unaffected_profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Profiles whose findings are fully known and do not include this one, sorted -- a positive statement that the profile was checked and is clean of it."
        },
        "undetermined_profiles": {
          "type": "array",
          "items": {"type": "string"},
          "description": "Profiles that can be placed in neither list above: at least one of their reports fell short of a complete finding set -- missing, unreadable, not-comparable, carrying no finding array at all, carrying an unparseable entry, or being a compare-release report (which lists bundle/matrix findings but only per-library counts). Never read as unaffected -- that would claim a profile was checked and found clean when it was never checked at all. An incomplete report can still place a profile in affected_profiles: seeing a finding proves it is there, while not seeing one proves nothing unless the list was exhaustive. Also lands here when the profile reported a finding on the same declaration under a different C++ mangling scheme (see cross_abi_declaration): it demonstrably has a finding there, so it is precisely the profile that must not be called clean, even though nothing proves the two are the same overload."
        },
        "cross_abi_declaration": {
          "type": ["string", "null"],
          "description": "The declaration this finding is about, spelled independently of its C++ mangling scheme (e.g. lib::add, recovered from either _ZN3lib3addEii or ?add@lib@@YAHHH@Z), or null when none could be recovered (a type-level finding, an extern \"C\" symbol, or a mangling the deliberately conservative parsers decline to model). Two entries sharing it concern the same declaration under different manglings. They are deliberately NOT merged into one entry: neither parser recovers parameter types, so nothing in a report distinguishes \"both profiles lost the same overload\" from \"each lost a different one\". The link is exposed here so a consumer can present them together without the report asserting a pairing it cannot prove."
        },
        "profile_contract": {
          "type": "array",
          "items": {"$ref": "#/$defs/profile_contract_state"},
          "description": "Added in schema 1.4. Per-affected-profile ADR-049 contract-decision detail for this finding -- one entry per profile in affected_profiles, using that profile's own representative report entry. Present only when at least one affected profile's report carries a contract_relevance (i.e. that profile ran --contract-evaluation); entirely absent for a comparison where no profile ever evaluated contract relevance, so a report using none of ADR-049's contract machinery renders byte-identical to schema 1.3. Answers, per profile, the four questions a single scope/verdict pair collapses together: whether the finding was IN_CONTRACT/NOT_APPLICABLE/etc (contract_relevance), whether policy actually scored it (compatibility_evaluation_status), what it resolved to (compatibility_decision), and whether it contributed to that profile's own gate (gate_contribution)."
        }
      }
    },
    "profile_contract_state": {
      "type": "object",
      "description": "Added in schema 1.4. One profile's ADR-049 contract-decision state for one finding_matrix_entry -- see finding_matrix_entry.profile_contract.",
      "required": ["profile", "contract_relevance", "compatibility_evaluation_status", "compatibility_decision", "gate_contribution"],
      "properties": {
        "profile": {"type": "string", "description": "The profile this state belongs to; one of the entry's affected_profiles."},
        "contract_relevance": {
          "type": ["string", "null"],
          "description": "ADR-049 relevance classification for this finding on this profile (e.g. IN_CONTRACT, NOT_APPLICABLE, PROVEN_OUT_OF_CONTRACT, UNKNOWN_UNRESOLVED), or null when this profile's report never stamped one -- it did not run --contract-evaluation, or ran it under a mode that never reached this finding."
        },
        "compatibility_evaluation_status": {
          "type": ["string", "null"],
          "description": "Whether compatibility policy actually scored this finding on this profile (EVALUATED vs NOT_EVALUATED), or null when unstamped -- mirrors the per-finding compatibility_evaluation_status field in a compare report."
        },
        "compatibility_decision": {
          "type": ["string", "null"],
          "description": "The resolved per-finding compatibility_decision on this profile, or null when policy never ran for this finding (NOT_EVALUATED) or the profile never stamped one at all -- these two null cases are not distinguished here; compatibility_evaluation_status disambiguates them."
        },
        "gate_contribution": {
          "type": ["integer", "null"],
          "description": "Whether this finding contributed to this profile's own gate decision (non-zero) or not (0), or null when unstamped."
        }
      }
    },
    "gate": {
      "type": "object",
      "description": "One target's own recorded CI gate decision.",
      "required": ["exit_code", "blocking", "blocking_categories", "from_report"],
      "properties": {
        "exit_code": {"type": "integer"},
        "blocking": {"type": "boolean"},
        "blocking_categories": {
          "type": "array",
          "items": {"type": "string"}
        },
        "from_report": {
          "type": "boolean",
          "description": "True when read from the report's own severity/scan gate; false when legacy-derived from the verdict."
        }
      }
    }
  }
}
