{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://demos-oracle.com/organism/schema",
  "title": "DNO /organism response",
  "description": "Public core network assessment feed of the Demos Network Oracle (independent, watch-only). DNO informs context; it does not advise, predict, score, certify, or decide action. Honest-uncertainty values (unknown, insufficient, uncertain) are first-class states, not errors.\n\nSTABILITY: Within api_version 1.x the contract is additive-only. The 17 required top-level fields are guaranteed to remain present, non-null, and never renamed. Existing enum values on top-level fields will not be removed (new values may be added; consumers should treat unrecognized future enum values as non-fatal). Any change that removes a top-level field, renames a field, makes a required field nullable, or removes an existing enum value is a breaking change and will ship under api_version 2.0 with an x-changelog entry. The last_24h object is informative only — its internal fields may be null or absent while history accumulates and are not covered by the stability guarantee.",
  "x-changelog": [
    { "version": "1.0", "date": "2026-06-10", "change_type": "initial",
      "notes": "Initial public contract. Testnet-era schema. 17 required top-level fields; all enums verified against source code." },
    { "version": "1.0", "date": "2026-06-11", "change_type": "clarification",
      "notes": "api_version constraint relaxed from const 1.0 to pattern ^1. to allow additive 1.x releases per the stability policy. Correction note: a diverged agreement value was briefly added and removed same-day (commit 52ca4f9, reverted); it originated from a separate legacy agreement computation (exposed as network_agreement on /health, no known consumers) and is not an emittable value of this field. Public agreement states remain strong|moderate|weak|unknown." },
    { "version": "1.1", "date": "2026-09-28", "change_type": "additive",
      "notes": "Additive fields: data_quality_reason, observed_at, height_last_advanced_at, height_static_seconds, active_public_conditions, agreement_detail; last_24h.critical_public_incidents_in_window; last_24h.chain_movement.blocks_advanced. Corrections of meaning, no type changes: staleness_seconds and last_updated are measured from the last completed public observation (previously staleness_seconds counted from the start of the fleet cycle and last_updated was the time of the response); seeds count toward data_quality and agreement only when they report their own block height (their own peerlist entry); a seed that does not list itself has no height here (its first listed peer's height stays on its /health row with height_source first_peer and is counted nowhere else); last_24h.active_critical_public_incidents counts incidents active now (the previous window count moved to critical_public_incidents_in_window). Reason strings say 'answered' where they said 'reachable'. Neither last_24h critical-incident count includes DNO's condition records, which follow status and are counted in active_public_conditions. Served on demos-oracle.com from 2026-10-02." },
    { "version": "1.2", "date": "2026-10-05", "change_type": "additive",
      "notes": "Additive fields: witnesses, height_standstill_after_seconds. Changes of meaning, no type changes and no new enum value: (1) Validators as witnesses. While at least two public seeds report their own height, status is made of those seeds alone, as in 1.1, and no validator is read for the reading (witnesses.mode seeds_only). When fewer than two do, DNO reads up to 8 validators it keeps as candidates: each was ACTIVE on a validator list two seeds agreed on and answered as the listed key at the address it published on chain, at the seeds' height, within the last 24 hours. Beside one seed height, a validator within 25 blocks of it counts with it and one further away is left out (seed_and_validators): agreement compares the seed with the validator closest to it, median_block is the seed's own height, and confidence is uncertain when the validators within 25 blocks are not more than half of those that gave a height. With no seed height, the validators within 25 blocks of the upper median of the validator heights are counted; a reading needs at least two of them and more than half of those that gave a height, and confidence is uncertain (validators_only). In both cases data_quality is sufficient, data_quality_reason is null and risk is at least elevated, so two combinations of existing values are new: status stable with risk elevated (either mode), and status stable with confidence uncertain (validators_only; seed_and_validators when the validators within 25 blocks are not more than half of those that gave a height). Both also occur in a reading of the seeds alone while DNO follows heights below a height it read earlier (after a start-over, see height_last_advanced_at): confidence is then uncertain and risk at least elevated. Otherwise there is no reading, as in 1.1 (insufficient, status unknown). agreement and agreement_detail describe the heights that were compared. last_24h.chain_movement and its blocks_advanced are made of the seeds' median only (two seeds, or one seed with validators beside it): a round that rested on validators alone is not part of them. When a read of a validator ends in a fault of DNO's own, no validator is counted in that observation and witnesses.validators is null. (2) Standstill. A reading that would be stable reads degraded once height_static_seconds reaches height_standstill_after_seconds. (3) A new height. height_last_advanced_at is set when a public seed reports a height above its own last answer that is the highest seed height of its round and above every height DNO had read before that round from a seed or from a validator it counted. A node that catches up, is back at a height it showed before, goes back and forth between two heights or answers for the first time shows none, neither does a change in which nodes answered, and from a round that rests on validators alone DNO takes none. Once the highest height DNO reads in a round is more than 25 blocks below the height that arrived, that arrival is no longer claimed: height_last_advanced_at is null until the next one. In 1.1 a seed that had fallen behind and caught up counted as an advance. height_static_seconds is how long DNO's count has run. The count stands on the highest seed height or, with no seed height, on the height more than half of the counted validators have reached; it starts again when that height is above every height DNO had read before that round, runs from the first reading of the highest height read before that round when the count steps up to a height that is not above every height read, and is published in every reading (after a start, once the stored rounds have been replayed). Both fields are null in a round without a reading and when the observation is stale (in 1.1 height_static_seconds was still published when one seed alone gave its height). status_reason says \"no new height for N min\" where it said \"height unchanged for N min\"; beside one seed, where the count is the seed's, it says \"the seed has shown no new height for N min\". (4) Wording: the degraded and unstable summaries read \"1 of 3 public nodes did not answer\" (was \"public node\"); when no seed answered, risk_factors says \"No public seed answered\" (was \"Only 0 of 3 public nodes answered — limited cross-checking\"). The date is the contract's; /timeline gives the day it was first served." }
  ],
  "type": "object",
  "required": ["status","trend","risk","data_quality","confidence","agreement","active_incidents","max_incident_severity","summary","status_reason","risk_factors","confidence_reason","agreement_reason","staleness_seconds","last_updated","api_version","last_24h"],
  "additionalProperties": true,
  "properties": {
    "status":   { "enum": ["stable","degraded","unstable","unknown"], "description": "What the heights DNO read show, not observer coverage. A seed that does not answer is no status input: status changes with the agreement of the heights that remain and with active public incidents, and is unknown when no reading is left; more than one silent seed raises risk. Since 1.2 a reading that would be stable is degraded once height_static_seconds reaches height_standstill_after_seconds. witnesses says what the reading rests on." },
    "trend":    { "enum": ["improving","stable","worsening","unknown"], "description": "Backward-looking direction of change." },
    "risk":     { "enum": ["low","elevated","high"], "description": "The observed conditions that reduce the margin of this reading; a snapshot of the present reading, not a forecast. risk_factors lists them, and can be empty when status is unknown: data_quality_reason then says why. By design, insufficient data (status=unknown) maps to risk=elevated: uncertainty resolves to a conservative posture rather than an unknown risk value." },
    "data_quality": { "enum": ["sufficient","insufficient"], "description": "Observability. If insufficient, treat all other values with skepticism. Sufficient: the observation is at most 300 s old and holds a reading (witnesses.mode is not insufficient)." },
    "confidence":   { "enum": ["clear","uncertain"] },
    "agreement":    { "enum": ["strong","moderate","weak","unknown"], "description": "Block-height alignment among the heights compared: the public seeds that reported their own height; since 1.2, when fewer than two did, one seed and the validator closest to it, or the counted validators (witnesses.mode)." },
    "active_incidents": { "type": "integer", "minimum": 0 },
    "max_incident_severity": { "enum": ["none","info","warning","critical"] },
    "summary":           { "type": "string" },
    "status_reason":     { "type": "string" },
    "risk_factors":      { "type": "array", "items": { "type": "string" } },
    "confidence_reason": { "type": "string" },
    "agreement_reason":  { "type": "string" },
    "staleness_seconds": { "type": "integer", "minimum": 0, "description": "Whole seconds since the last completed public observation (since agent start before the first one); use for freshness judgment." },
    "last_updated":      { "type": "string", "format": "date-time", "description": "Time of the last completed public observation (agent start before the first one)." },
    "data_quality_reason": { "enum": [null,"no_observation","stale","too_few_answers","too_few_heights"], "description": "Why data_quality is insufficient; null when sufficient. no_observation and stale are about the observation's age; too_few_answers and too_few_heights say what the public seeds gave (fewer than two answered; fewer than two reported their own height), and since 1.2 they appear only when validators did not stand in for the missing seed. Additive in 1.1." },
    "observed_at":       { "type": ["string","null"], "format": "date-time", "description": "Time of the last completed public observation; null before the first one. Additive in 1.1." },
    "height_last_advanced_at": { "type": ["string","null"], "format": "date-time", "description": "The last observation in which DNO saw a new height arrive: a public seed above its own last answer, at the highest seed height of its round and above every height DNO had read before that round from a seed or from a validator it counted. Null in a round without a reading or with a stale observation, and until DNO has seen one arrive. Null again once the highest height DNO reads in a round (a seed's, or a counted validator's) is more than 25 blocks below the height that arrived: DNO then no longer claims that arrival. One seed that falls back while another still stands within 25 blocks of that height does not end the claim. Null again, until the next one arrives, whenever the count did not start with such an arrival: the height was first read from a seed DNO had no earlier answer from, from a counted validator before a seed reached it, or from validators alone (with no seed height DNO gives the count and takes no arrival from them). Also null from a start-over until the first arrival after it has ended: when the highest seed DNO reads has kept rising below the highest height it has read (a seed's, or a counted validator's) for more than ten minutes, DNO gives that height up, follows the lower heights and claims no arrival (with validators alone it gives no height up), because it cannot tell a chain restarted lower, nodes catching up, and one answer far above the others apart. That ends when the height given up is read again, or a higher one, and 24 hours after the last start-over. Additive in 1.1; a seed catching up counted as an advance before 1.2." },
    "height_static_seconds": { "type": ["integer","null"], "minimum": 0, "description": "Seconds DNO's count has run, as of the last observation: the time without a new height, as DNO reads it. The count stands on the highest public seed height of the round; with no seed height, on the height more than half of the counted validators have reached. It starts again in the round that height is above every height DNO had read before that round (from a seed, or from a validator it counted); when the count steps up to a height that is not above every height read, it runs from the first reading of the highest height read before that round. So it never runs from before DNO first read the height it stands on, or a higher one. It is published in every reading (after a start, once the stored rounds have been replayed), and is 0 in the round the count starts: the first round, a round whose height is above every height read, and a round in which DNO starts over. Beside one seed the count is the seed's, and the texts say so (\"the seed has shown no new height for N min\"). While height_last_advanced_at is null it is a lower bound of how long that height has stood. One exception: when a height given up at a start-over is read again without the count's own height passing it, the count runs from that height's first reading, before the start-over, until the count's own height has passed it, or steps up after a higher height was read, or DNO gives it up again. That is one or two rounds at a 10 s block time when the nodes DNO follows reach it themselves; up to 13 rounds (25 blocks) when a validator that still stands on it is counted; and until the next start-over, ten minutes of rises or more, when a seed that still stands on it answers. Null when the latest observation had no reading or is stale, and while the host's clock is behind the start of the count. After a restart of DNO the stored rounds of the last 24 hours are replayed, so a longer standstill is counted from the oldest of them. Reported as observed; its cause is not known from here. At height_standstill_after_seconds a reading that would be stable is degraded. Additive in 1.1." },
    "active_public_conditions": { "type": "integer", "minimum": 0, "description": "Active public condition records (visibility, degraded, unstable) that DNO opens when its own status changes. Listed on /incidents with kind=condition; not included in active_incidents. Additive in 1.1." },
    "agreement_detail": { "type": "object", "description": "Counts behind the agreement label. total_nodes = heights compared: the seeds that reported their own height (also in the unknown state, where nothing was compared); in witnesses.mode seed_and_validators 2, the seed and the validator closest to it; in validators_only the counted validators. median_block: the upper median of the compared heights; in seed_and_validators the seed's own height; in validators_only the upper median of every validator height read; in the unknown state, where nothing was compared, the height of the one seed that reported its own (null when none did, or when the observation is stale). aligned_nodes and block_spread are null when agreement is unknown. Additive in 1.1.",
      "properties": { "aligned_nodes": { "type": ["integer","null"], "minimum": 0 }, "total_nodes": { "type": "integer", "minimum": 0 }, "median_block": { "type": ["integer","null"], "minimum": 0 }, "block_spread": { "type": ["integer","null"], "minimum": 0 } } },
    "witnesses": { "type": "object", "description": "What the reading rests on. Counts and one time only: never a key or an address, and no height is attributed to a validator (agreement_detail does hold the median and spread of the heights compared, which are validators' heights when they stand in). Additive in 1.2.",
      "properties": {
        "mode": { "enum": ["seeds_only","seed_and_validators","validators_only","insufficient"], "description": "seeds_only: at least two public seeds reported their own height; no validator was read for the reading. seed_and_validators: one seed did, and at least one validator is within 25 blocks of it. validators_only: no seed did; at least two validators, more than half of those that gave a height, are within 25 blocks of their upper median. insufficient: no reading (status unknown)." },
        "counted": { "type": "integer", "minimum": 0, "description": "Heights the reading rests on: seeds' own heights plus validators counted. 0 when there is no reading." },
        "public_seeds": { "type": "object", "properties": {
          "configured": { "type": "integer", "minimum": 0 }, "answered": { "type": "integer", "minimum": 0, "description": "Seeds that answered /info." },
          "own_height": { "type": "integer", "minimum": 0, "description": "Seeds that reported their own height (their own peerlist entry)." } } },
        "validators": { "oneOf": [ { "type": "null" }, { "type": "object", "properties": {
          "read": { "type": "integer", "minimum": 0, "description": "Validators read in this observation (at most 8)." },
          "own_height": { "type": "integer", "minimum": 0, "description": "Of those, answered as the listed key with their own height." },
          "counted": { "type": "integer", "minimum": 0, "description": "Of those, counted in the reading. 0 when there is no reading." },
          "list_agreed_at": { "type": ["string","null"], "format": "date-time", "description": "When the validator list was last read from two agreeing seeds in a round that counted: the round that last renewed the candidates." } } } ],
          "description": "null when no validator was read: two seeds reported their own height, no validator is kept as a candidate (none answered as listed at the seeds' height in a counted validator round of the last 24 hours), or validator dials are turned off on this server. Also null when a read of a validator ended in a fault of DNO's own in this observation: no validator is counted then." } } },
    "height_standstill_after_seconds": { "type": "integer", "minimum": 0, "description": "The standstill limit: a reading that would be stable is degraded once height_static_seconds reaches it. Additive in 1.2." },
    "api_version":       { "type": "string", "pattern": "^1\\." },
    "last_24h": {
      "type": "object",
      "description": "Rolling 24h observability summary. Informative tier: inner fields may be null or absent while history accumulates.",
      "properties": {
        "sufficient":      { "type": "boolean" },
        "coverage_pct":    { "type": ["number","null"], "minimum": 0 },
        "observed_cycles": { "type": ["integer","null"], "minimum": 0 },
        "expected_cycles": { "type": ["integer","null"], "minimum": 0 },
        "typical_set_size":{ "type": ["integer","null"], "minimum": 0 },
        "peak_set": { "oneOf": [ { "type": "null" }, { "type": "object",
          "required": ["size","cycles","avg_reachable","pct_of_window"],
          "properties": {
            "size": {"type":"integer","minimum":0}, "cycles": {"type":"integer","minimum":0},
            "avg_reachable": {"type":"number","minimum":0},
            "pct_of_window": {"type":"number","minimum":0,"maximum":100} } } ] },
        "chain_movement": { "type": "object", "required": ["state"],
          "properties": {
            "state": { "enum": ["unknown","normal","interrupted"] },
            "reason": { "type": "string", "description": "Present on some non-normal states. Examples: no_data, insufficient_buckets, low_advance_ratio, own_height_record_shorter_than_window (rounds recorded under the own-height rule do not cover the window yet; chain movement is not published until they do)." },
            "pct_advancing": { "type": "number" },
            "longest_static_minutes": { "type": "number" },
            "blocks_advanced": { "type": ["integer","null"], "minimum": 0, "description": "The seeds' median at the newest round in the window minus the oldest, over rounds with a reading whose median is a seed's own height: two seeds reported their own height, or since 1.2 one seed did and validators counted with it (the median is then that seed's height). A round that rested on validators alone is not part of it, nor of chain_movement. As observed. Null when chain movement is unknown, when there is no pair to compare, when the newest median is below the oldest, or when the median went down by more than 25 blocks between two such rounds (a reset, or seeds far apart). No target rate. Additive in 1.1." },
            "buckets": { "type": "integer" } } },
        "longest_non_stable_minutes": { "type": ["number","null"], "minimum": 0 },
        "active_critical_public_incidents": { "type": ["integer","null"], "minimum": 0, "description": "Critical public incidents active now. DNO's condition records are not counted (see active_public_conditions)." },
        "critical_public_incidents_in_window": { "type": ["integer","null"], "minimum": 0, "description": "Critical public incidents active now or resolved inside the window. DNO's condition records are not counted. Additive in 1.1." },
        "message": { "type": "string" },
        "computed_at": { "type": "string", "format": "date-time" }
      }
    }
  }
}
