{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://artifacts.apis.io/arcnautical-com/json-schema/arcnautical-com-voyage-assessment-schema.json",
  "title": "VoyageAssessment",
  "x-generated": "2026-11-02",
  "x-method": "derived",
  "x-generator": "derive-json-schema.py",
  "x-source": "openapi/arcnautical-com-openapi.json#/components/schemas/VoyageAssessment",
  "type": "object",
  "required": [
    "id",
    "route",
    "score",
    "risk_level",
    "drivers",
    "jwc_intersected_areas",
    "eez_transit",
    "confidence",
    "missing_sources",
    "source_status",
    "methodology_version",
    "disclaimer",
    "assessed_at",
    "expires_at"
  ],
  "properties": {
    "id": {
      "type": "string"
    },
    "customer_reference": {
      "type": "string"
    },
    "route": {
      "$ref": "#/$defs/NormalizedRoute"
    },
    "alternatives": {
      "type": "array",
      "description": "The other way round Africa, when the lane has one; empty otherwise and when `via` fixed the path. A comparison, not a recommendation: which passage a sailing takes is the carrier's. No extra unit.",
      "items": {
        "$ref": "#/$defs/VoyagePassageAlternative"
      }
    },
    "previous": {
      "oneOf": [
        {
          "$ref": "#/$defs/PreviousAssessment"
        },
        {
          "type": "null"
        }
      ],
      "description": "Your last assessment of the same lane, or null when there is none inside retention."
    },
    "score": {
      "type": "number",
      "minimum": 0,
      "maximum": 100
    },
    "risk_level": {
      "type": "string",
      "enum": [
        "low",
        "moderate",
        "elevated",
        "high",
        "critical"
      ]
    },
    "drivers": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/RiskDriver"
      }
    },
    "jwc_intersected_areas": {
      "type": "array",
      "description": "Joint War Committee listed areas the route crosses. Empty when none apply or the source was unavailable.",
      "items": {
        "$ref": "#/$defs/JwcIntersectedArea"
      }
    },
    "eez_transit": {
      "type": "array",
      "description": "Exclusive Economic Zones the route transits, with sanctions status where the coastal state is designated.",
      "items": {
        "$ref": "#/$defs/EezTransit"
      }
    },
    "score_floor": {
      "$ref": "#/$defs/ScoreFloor"
    },
    "confidence": {
      "type": "number",
      "minimum": 0,
      "maximum": 1
    },
    "missing_sources": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "source_status": {
      "type": "object",
      "deprecated": true,
      "description": "DEPRECATED — use `sources`, which carries the same status plus `checked_at`. Keeps working until 2026-10-31 under the 90-day policy at /developers/stability/; removal ships as v2, never in place.",
      "additionalProperties": {
        "type": "string",
        "enum": [
          "ok",
          "stale",
          "error"
        ]
      }
    },
    "sources": {
      "type": "array",
      "description": "Per-source status and freshness. `checked_at` is null whenever the freshness is not genuinely known — it is never filled in with the request time. Read `basis` before comparing timestamps across sources: \"when we last fetched\" and \"the newest record we hold\" answer different questions, and only `refreshed_at` and `published_at` tell you whether a source is stale.",
      "items": {
        "$ref": "#/$defs/SourceStatus"
      }
    },
    "methodology_version": {
      "type": "string"
    },
    "assessed_at": {
      "type": "string",
      "format": "date-time",
      "description": "When the SCORE was computed — not when this record was written. Scores are cached for four hours by scoring version, date and route hash, so an identical request inside that window returns the ORIGINAL computation time and `cached: true`. Use this as the \"when did we know this\" field in an audit trail."
    },
    "cached": {
      "type": "boolean",
      "description": "True when this response came from the score cache rather than being computed for this request. `assessed_at` then predates your request."
    },
    "expires_at": {
      "type": "string",
      "format": "date-time",
      "description": "When this RECORD is deleted (retention), not how long the verdict is valid."
    },
    "disclaimer": {
      "type": "string"
    }
  },
  "$defs": {
    "EezSanctionsStatus": {
      "type": "object",
      "required": [
        "is_sanctioned",
        "regimes"
      ],
      "properties": {
        "is_sanctioned": {
          "type": "boolean"
        },
        "regimes": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Sanctions regimes designating this coastal state, e.g. EU, UNSC, OFAC."
        }
      }
    },
    "EezTransit": {
      "type": "object",
      "required": [
        "country_code",
        "country_name",
        "risk_category",
        "route_fraction"
      ],
      "properties": {
        "country_code": {
          "type": "string"
        },
        "country_name": {
          "type": "string"
        },
        "risk_category": {
          "type": "string",
          "description": "Why this EEZ is notable: \"sanctioned\", \"conflict\", \"piracy\", \"chokepoint\" or \"context\". Only \"sanctioned\" is authoritative — it comes from the live OFAC/EU/UNSC lookup reflected in `sanctions`; the rest are advisory hints from the bundled EEZ dataset."
        },
        "route_fraction": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "Fraction of total route distance inside this EEZ."
        },
        "sanctions": {
          "$ref": "#/$defs/EezSanctionsStatus"
        }
      }
    },
    "JwcIntersectedArea": {
      "type": "object",
      "required": [
        "id",
        "name",
        "risk_level",
        "route_fraction"
      ],
      "properties": {
        "id": {
          "type": "string"
        },
        "name": {
          "type": "string"
        },
        "risk_level": {
          "type": "string",
          "description": "Dominant peril for this area — currently \"war\" or \"piracy\". This is ArcNautical's classification, not the Joint War Committee's: JWLA lists areas without grading them, so severity comes from the score and its drivers, not from this field."
        },
        "route_fraction": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "description": "Fraction of total route distance crossing this listed area."
        }
      }
    },
    "NormalizedGeoPoint": {
      "type": "object",
      "required": [
        "lat",
        "lon"
      ],
      "properties": {
        "lat": {
          "type": "number",
          "minimum": -90,
          "maximum": 90
        },
        "lon": {
          "type": "number",
          "minimum": -180,
          "maximum": 180
        }
      }
    },
    "NormalizedRoute": {
      "type": "object",
      "required": [
        "origin_locode",
        "dest_locode",
        "via_waypoints",
        "distance_nm"
      ],
      "properties": {
        "origin_locode": {
          "type": "string",
          "pattern": "^[A-Z]{5}$"
        },
        "dest_locode": {
          "type": "string",
          "pattern": "^[A-Z]{5}$"
        },
        "via_waypoints": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/NormalizedGeoPoint"
          }
        },
        "distance_nm": {
          "type": "number"
        },
        "passage": {
          "type": "string",
          "enum": [
            "suez",
            "cape",
            "panama",
            "none"
          ],
          "description": "Which way the SCORED route goes, read from the computed route: through the Suez Canal, round the Cape of Good Hope, through Panama, or none of them."
        },
        "passage_preference": {
          "type": "string",
          "enum": [
            "shortest",
            "suez",
            "cape"
          ],
          "description": "What the request asked for; `shortest` when it did not say."
        },
        "chokepoints": {
          "type": "array",
          "description": "Chokepoints the route passes within 100nm of, by the same rule as the `chokepoint_disruption` driver. Geometry only: their current status is in that driver.",
          "items": {
            "type": "object",
            "required": [
              "id",
              "name"
            ],
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              }
            }
          }
        },
        "transit_days": {
          "type": "number",
          "description": "Sea days at `speed_knots_assumed`, one decimal. Excludes port calls, canal queues and weather: a planning figure, not a schedule."
        },
        "speed_knots_assumed": {
          "type": "number",
          "description": "Your speed_knots, or 14 when you sent none."
        },
        "geometry": {
          "type": "object",
          "description": "GeoJSON Feature containing the computed maritime route. Absent when the request set include_geometry:false."
        }
      }
    },
    "PreviousAssessment": {
      "type": "object",
      "required": [
        "assessment_id",
        "score",
        "risk_level",
        "assessed_at",
        "score_change"
      ],
      "description": "Your most recent earlier assessment of the same lane: same origin, destination, via points and scored passage, the same vessel_type, load_condition and speed_knots, same environment, inside retention.",
      "properties": {
        "assessment_id": {
          "type": "string"
        },
        "score": {
          "type": "number"
        },
        "risk_level": {
          "type": "string"
        },
        "assessed_at": {
          "type": "string",
          "format": "date-time"
        },
        "score_change": {
          "type": "number",
          "description": "This assessment's score minus that one."
        }
      }
    },
    "RiskDriver": {
      "type": "object",
      "required": [
        "source",
        "label",
        "summary",
        "event_count",
        "signal_score",
        "weight_pct",
        "contribution_pct",
        "contribution_band",
        "sets_floor",
        "relative_rank"
      ],
      "properties": {
        "source": {
          "type": "string"
        },
        "label": {
          "type": "string"
        },
        "summary": {
          "type": "string"
        },
        "event_count": {
          "type": "integer",
          "minimum": 0
        },
        "signal_score": {
          "type": "integer",
          "minimum": 0,
          "maximum": 100,
          "description": "This signal's own severity, independent of how much the model weights it."
        },
        "weight_pct": {
          "type": "integer",
          "minimum": 0,
          "maximum": 100,
          "description": "This driver's share of the model. Sums to exactly 100 across all drivers."
        },
        "contribution_pct": {
          "type": "integer",
          "minimum": 0,
          "maximum": 100,
          "description": "How much of this assessment this driver accounts for: signal_score x weight_pct, normalised across the driver list. Sums to exactly 100 across drivers, or is 0 on every driver when no signal reported anything."
        },
        "contribution_band": {
          "type": "string",
          "enum": [
            "low",
            "medium",
            "high",
            "critical"
          ],
          "description": "Coarse band over contribution_pct: critical >= 30, high >= 20, medium >= 10, otherwise low. RELATIVE, not a severity scale — a driver can band critical on a low-scoring route because it is most of what little risk there is. Use score and risk_level for danger, signal_score for one signal's severity, and this for what the result is made of. Always critical when sets_floor is true."
        },
        "sets_floor": {
          "type": "boolean",
          "description": "True for the driver whose categorical fact set score through score_floor. That driver is the reason for the published number rather than one weighted input to it, so it bands critical whatever its share. Exactly one driver carries it when score_floor is present; none do otherwise."
        },
        "relative_rank": {
          "type": "integer",
          "minimum": 1
        }
      }
    },
    "ScoreFloor": {
      "type": "object",
      "description": "Present only when a categorical fact raised `score` above the weighted signal average — so `score` is deliberately not reproducible from `drivers` alone. Absent when the weighted signals decided the score by themselves.",
      "required": [
        "basis",
        "value",
        "explanation",
        "weighted_score"
      ],
      "properties": {
        "basis": {
          "type": "string",
          "enum": [
            "listed_area_transit",
            "listed_area_active_hostility",
            "listed_area_hostility_unknown",
            "named_country_port_call",
            "sanctioned_eez_transit"
          ],
          "description": "Which categorical fact set the floor. `listed_area_active_hostility` means the route crosses a JWC listed area where our live feeds place hostile activity (a hostile navigational warning, an attack, or AIS/GPS disruption) inside that area. `listed_area_hostility_unknown` means the route crosses a listed area, no hostile activity was found, and at least one of the three feeds that would have detected it was not reporting — so the score is held at the active-hostility floor rather than published as calm. Check `sources[]` to see which feed was degraded; `explanation` names it."
        },
        "value": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "description": "The floor that was applied. `score` is at least this."
        },
        "explanation": {
          "type": "string",
          "description": "One line naming the areas or states that produced the floor."
        },
        "weighted_score": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "description": "The score before the floor. Use `score` for the verdict; use this to rank two lanes that both floored into the same band."
        }
      }
    },
    "SourceStatus": {
      "type": "object",
      "required": [
        "source",
        "status",
        "checked_at",
        "basis"
      ],
      "properties": {
        "source": {
          "type": "string",
          "description": "Signal family, e.g. sanctions, navwarn, piracy. On a screening, `vessel_vetting` covers the port-state-control, registry and build-year inputs behind `vetting` and `ownership`: it reports `error` when that enrichment did not complete (a source failure — retry) and `ok` when it ran, whose `message` then distinguishes \"inputs were read\" from \"the sources hold no record for this identifier\" (retrying will not change that). Before 2026-09-06 this row did not exist, so a failed enrichment left every source reading `ok` with the grade simply absent."
        },
        "status": {
          "type": "string",
          "enum": [
            "ok",
            "stale",
            "error",
            "not_requested"
          ],
          "description": "`ok` — read. `error` — we tried and could not read it; a retry may succeed. `not_requested` — you asked us not to read it (see `include_vetting`), so no retry will change it. These have opposite retry policies and must not be collapsed."
        },
        "checked_at": {
          "type": [
            "string",
            "null"
          ],
          "format": "date-time",
          "description": "Null when basis is `unknown`, or when the source returned nothing to date. Never substituted with the request time."
        },
        "basis": {
          "type": "string",
          "enum": [
            "refreshed_at",
            "latest_event_at",
            "published_at",
            "build_constant",
            "unknown"
          ],
          "description": "What checked_at means. `refreshed_at` — when we last fetched the source. `published_at` — the publication date the source itself states, which dates the DATA rather than our copy. `latest_event_at` — the newest record we hold, NOT a refresh time: a quiet week and a dead feed look identical. `build_constant` — bundled at deploy time. `unknown` — no timestamp exists in the fetch path, INCLUDING the `vessel_vetting` row when its status is `not_requested`: nothing was attempted, so there is nothing to date."
        },
        "message": {
          "type": "string",
          "description": "Present when the basis needs explaining."
        },
        "label": {
          "type": "string",
          "description": "Human-readable source name. Present on screening responses (e.g. \"OFAC SDN\")."
        }
      }
    },
    "VoyagePassageAlternative": {
      "type": "object",
      "required": [
        "passage",
        "status"
      ],
      "description": "The same lane the other way round Africa, scored by the same engine.",
      "properties": {
        "passage": {
          "type": "string",
          "enum": [
            "suez",
            "cape"
          ]
        },
        "status": {
          "type": "string",
          "enum": [
            "scored",
            "unavailable"
          ],
          "description": "`unavailable` when the comparison could not be scored in time. The assessment it rides on is still complete; re-send the request to retry."
        },
        "reason": {
          "type": "string",
          "description": "Present when status is `unavailable`."
        },
        "score": {
          "type": "number",
          "minimum": 0,
          "maximum": 100
        },
        "risk_level": {
          "type": "string",
          "enum": [
            "low",
            "moderate",
            "elevated",
            "high",
            "critical"
          ]
        },
        "distance_nm": {
          "type": "number"
        },
        "transit_days": {
          "type": "number",
          "description": "At the same speed as route.transit_days."
        },
        "days_vs_scored": {
          "type": "number",
          "description": "This transit_days minus route.transit_days."
        },
        "score_floor_basis": {
          "type": [
            "string",
            "null"
          ],
          "description": "The categorical floor this passage hit, e.g. listed_area_active_hostility; null when none."
        },
        "jwc_intersected_areas": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Names of JWC listed areas this passage crosses."
        },
        "chokepoints": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Chokepoint ids this passage transits."
        }
      }
    }
  }
}