{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://artifacts.apis.io/arcnautical-com/json-schema/arcnautical-com-screening-schema.json",
  "title": "Screening",
  "x-generated": "2026-11-02",
  "x-method": "derived",
  "x-generator": "derive-json-schema.py",
  "x-source": "openapi/arcnautical-com-openapi.json#/components/schemas/Screening",
  "type": "object",
  "required": [
    "id",
    "imo",
    "imo_check_digit_valid",
    "vessel_name",
    "sanctions",
    "ownership",
    "vetting",
    "sources",
    "methodology_version",
    "disclaimer",
    "screened_at",
    "expires_at"
  ],
  "properties": {
    "id": {
      "type": "string",
      "format": "uuid"
    },
    "imo": {
      "type": "string",
      "pattern": "^[0-9]{7}$"
    },
    "imo_check_digit_valid": {
      "type": "boolean",
      "description": "False means the IMO fails its check digit. The hull was still screened. Treat as a flag to verify the identifier, not as a screening failure."
    },
    "imo_normalized_from": {
      "type": "string",
      "description": "Present ONLY when `imo` is not the value you sent. You sent this value, a valid IMO with one digit appended (e.g. \"94827701\"), and `imo` is the IMO we screened (9482770). This happens only on accounts where the correction is enabled, because their own traffic shows a pipeline appending one digit; every other account gets a 422 for that input. Absent on every other screening, including all written before 2026-09-25. When present, the `imo_normalized` notice says the same in words. Fix the source value on your side. If `imo` is not the hull you meant, do not rely on the verdict."
    },
    "customer_reference": {
      "type": [
        "string",
        "null"
      ]
    },
    "vessel_name": {
      "type": [
        "string",
        "null"
      ],
      "description": "Null when identity could not be resolved. A null name is why `sanctions.status` may be INCOMPLETE rather than GREEN — name-based screening is meaningless without it."
    },
    "identity_sources": {
      "type": "array",
      "description": "Which identity registers were asked for this hull and what each said. identity_basis says whether we named the hull; this says what we looked in. It exists because one sentence cannot carry two opposite facts: every register answered and none lists this hull, versus a register was UNREACHABLE and we do not know. Those have opposite retry policies, and reading the second as a clearance is the error this field prevents. named is true for every register that returned a usable name (they can disagree — a rename — and `name` on each row says what it said; `vessel_name` is the one we chose, the rest were screened as prior names). reachable: false means the lookup threw, so silence from that register is not evidence. Five registers are stores we hold (arcnautical_vessel_register, ais_registry, tokyo_mou_psc, paris_mou_thetis, apcis_tokyo_mou_history); when all five are silent, four public registers are read LIVE inside the request under a time budget (dnv_vessel_register, itf_ship_lookup, iacs_vessels_in_class, uscg_psix) — on one of those, reachable: false means it did not answer in time, its lookup is finishing in the background, and sanctions.retry_after is when it will have settled — the first slot of the automatic re-screen (see resolution). Recorded from what the resolver did, so a register that is removed stops appearing instead of being claimed forever.",
      "items": {
        "type": "object",
        "required": [
          "register",
          "named",
          "reachable"
        ],
        "properties": {
          "register": {
            "type": "string",
            "description": "Published register name, e.g. paris_mou_thetis."
          },
          "named": {
            "type": "boolean"
          },
          "reachable": {
            "type": "boolean"
          },
          "name": {
            "type": "string",
            "description": "What this register named the hull, present only when named is true. The name we chose is vessel_name; any other name here was screened as a prior name, and a match on it carries screened_name."
          }
        }
      }
    },
    "identity_basis": {
      "type": "string",
      "enum": [
        "sources",
        "user",
        "none"
      ],
      "description": "WHOSE name this hull was screened under. `sources` — we resolved it from our own identity data. `user` — we could not, and screened under the vessel_name YOU supplied; we have not confirmed that name belongs to this IMO, so verify it against the certificate before relying on the result. `none` — we could not resolve a name and you supplied none, so name-based matching did not run at all and the status is INCOMPLETE. A GREEN on `user` is not the same claim as a GREEN on `sources`. DELIBERATELY NOT REQUIRED: a stored screening is replayed verbatim as compliance evidence and is never rewritten, so screenings run before 2026-09-08 do not carry this field. Absent means the same as `sources` for those rows — the only identity path that existed."
    },
    "sanctions": {
      "$ref": "#/$defs/ScreeningSanctions"
    },
    "ownership": {
      "type": "object",
      "description": "Both fields are NULL when `vetting.assessed` is false — ownership could not be resolved for this hull, and a band is not reported unless it was measured. Never read a missing band as LOW or MEDIUM.",
      "required": [
        "opacity",
        "score"
      ],
      "properties": {
        "opacity": {
          "type": [
            "string",
            "null"
          ],
          "enum": [
            "LOW",
            "MEDIUM",
            "HIGH",
            null
          ]
        },
        "score": {
          "type": [
            "integer",
            "null"
          ],
          "minimum": 0,
          "maximum": 100,
          "description": "Higher = harder to trace the beneficial owner."
        }
      }
    },
    "vetting": {
      "$ref": "#/$defs/ScreeningVetting"
    },
    "sources": {
      "type": "array",
      "description": "Which lists were read and when. `status: \"error\"` means a list did not load — a source we could not read is NOT a source that returned nothing, and a GREEN built on unread lists is not a clear result. Cross-check against `sanctions.coverage_gaps`.",
      "items": {
        "$ref": "#/$defs/SourceStatus"
      }
    },
    "methodology_version": {
      "type": "string",
      "description": "The methodology family this record was computed under (`vessel_screening_external_v1`). Constant since 2026-08-02; the engine BUILD is `engine_version`."
    },
    "engine_version": {
      "type": "integer",
      "description": "The engine build this record was computed by — the integer the stability changelog cites when it says a rule changed (\"engine 30\"). Compare it across two records of the same hull to tell a model change from a vessel change. Absent on records stored before 2026-09-15."
    },
    "disclaimer": {
      "type": "string"
    },
    "screened_at": {
      "type": "string",
      "format": "date-time"
    },
    "expires_at": {
      "type": "string",
      "format": "date-time"
    },
    "unit_charged": {
      "type": "boolean",
      "description": "Whether this screening consumed a unit of the monthly allowance. `false` on every INCOMPLETE: a unit buys a verdict, and an INCOMPLETE is not one, so the reservation is refunded and `X-ArcNautical-Quota-Remaining` on the response already reflects that. ABSENT on records stored before 2026-09-13, which were all charged regardless of status. Treat absent as `true`."
    },
    "notices": {
      "type": "array",
      "items": {
        "$ref": "#/$defs/ScreeningNotice"
      },
      "description": "What THIS request left on the table, with the remedy beside it. Empty on a clean first screening under quota. Every entry is conditional on something true of this request that the next request can change — the array is never padded with standing advice, so a non-empty array is worth reading. Present (possibly empty) on every screening created by this endpoint since 2026-09-10; `[]` on batch and monitor screenings; ABSENT on records stored before that date, which are replayed verbatim. Treat absent as empty."
    },
    "resolution": {
      "oneOf": [
        {
          "$ref": "#/$defs/ScreeningResolution"
        },
        {
          "type": "null"
        }
      ],
      "description": "The promise behind an INCOMPLETE (since 2026-09-15). Present only on an INCOMPLETE that enrolled for automatic re-screening: the same request is run again for you on a bounded schedule until a verdict can be given or the schedule runs out. Absent on a verdict, on an INCOMPLETE nothing we do can change (a failing check digit, a contested number — the notices say which) and on records stored before it existed. The stored body carries the snapshot at creation; GET /api/v1/screenings/{id} overlays the LIVE state, so `status` and `resolved_screening_id` on a GET are current while an idempotent replay returns the body as it was. A resolution consumes one screening unit — the INCOMPLETE was refunded, and a unit buys a verdict — unless the INCOMPLETE itself was charged (records before 2026-09-13), in which case the verdict was already paid for."
    },
    "supersedes": {
      "type": "string",
      "format": "uuid",
      "description": "The INCOMPLETE record this verdict answers. Set when a hull with an open follow-up is screened to a verdict by ANY path — the background re-screen, your own re-ask, a batch item or a monitor run — so the link from question to answer is one rule. Absent otherwise."
    },
    "superseded_by": {
      "type": [
        "string",
        "null"
      ],
      "format": "uuid",
      "description": "The later record that answered this one. Present (possibly null) on every GET and list row since 2026-09-15 and absent from an idempotent replay — it is a column merged in at read time, because the stored body is evidence and is never rewritten. Null until a verdict for the same hull is written."
    }
  },
  "$defs": {
    "ScreeningMatch": {
      "type": "object",
      "required": [
        "source",
        "name",
        "match_type",
        "confidence",
        "programs"
      ],
      "properties": {
        "source": {
          "type": "string",
          "enum": [
            "ofac_sdn",
            "opensanctions",
            "eu_sanctions",
            "un_sanctions",
            "uk_ofsi",
            "vessel_vetting"
          ],
          "description": "The list the matched record came from. `vessel_vetting` NEVER appears on a match — it is a `sources[]` row (see `SourceStatus.source`), was listed here by mistake, and is retained only because removing a value from a closed enum is a breaking change under the stability policy; it will be removed with the 90-day notice."
        },
        "name": {
          "type": "string",
          "description": "The sanctioned entity name as it appears in the source list."
        },
        "match_type": {
          "type": "string",
          "enum": [
            "exact_imo",
            "exact_mmsi",
            "name_fuzzy"
          ]
        },
        "confidence": {
          "type": "number",
          "minimum": 0,
          "maximum": 1
        },
        "confidence_class": {
          "type": "string",
          "enum": [
            "confirmed",
            "probable",
            "possible",
            "weak"
          ],
          "description": "How sure the match is. Only `confirmed` is a designation, and read it WITH `record_type`: `confirmed` on a `vessel` record designates this hull; `confirmed` on an `entity` record designates a company, not the ship. Report anything else as an unconfirmed match to verify — restating a name-only hit as fact is defamatory and wrong."
        },
        "record_type": {
          "type": "string",
          "enum": [
            "vessel",
            "entity"
          ],
          "description": "WHAT was matched, as distinct from how well. `vessel` — the source record is a ship, so an identifier hit designates this hull. `entity` — the record is a company or organisation; an `exact_imo` hit on one means the number matched an IMO **Company** Number, which IMO issues from the same 7-digit series as ship numbers with the same check digit. That is a real finding about the operator and NOT a designation of the hull. Absent when the source publishes no type for the record — read that as unknown, never as `vessel`. Note that one source yields both: the EU list carries 728 designated vessels and 91 companies that happen to hold an IMO company number, so this cannot be inferred from `source`."
        },
        "drove_verdict": {
          "type": "boolean",
          "description": "Whether THIS match is one the verdict came from. The list is deliberately unfiltered — a name collision we ruled out is still evidence a reviewer may want to see — so on a RED the rows with `drove_verdict: true` are the confirmed designations that carried it, and on an AMBER they are the hits that survived our collision filter. On GREEN and INCOMPLETE every row is `false`: nothing drove the status. ABSENT means NOT COMPUTABLE, never \"no\" — it is omitted when a driving record carries no stable source id to join on, because reporting `false` on the row that actually carried a RED would be worse than saying nothing. Use it to answer \"which listing must I clear?\" without re-deriving our logic; keep reading every row for review."
        },
        "screened_name": {
          "type": "string",
          "description": "The name this record matched, when it is NOT vessel_name. Our identity registers can return more than one name for a hull — that is what a rename looks like (a class register carries the current name while an older file still carries the yard name) — and the hull is screened under every one of them, so a name hit may be against a name this response otherwise shows only in identity_sources[].name. Absent when the match was on vessel_name or on an identifier."
        },
        "programs": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Sanctions programs, e.g. [\"RUSSIA-EO14024\"]."
        },
        "source_record_id": {
          "type": "string",
          "description": "The identifier the SOURCE assigns to the matched record, so you can look it up there without repeating our search: the OFAC SDN entry number (`49248`), the UN permanent reference number (`LYe.006` — the one the Security Council's own notices cite), the OFSI Unique ID (`DPR0075`), the OpenSanctions entity id (`NK-…`). For `eu_sanctions` it depends on which of the two EU files the record came from: the EU reference number (`EU.4198.83`) on a record from the Commission's consolidated financial sanctions file, read directly; the OpenSanctions entity id on an Official Journal record (Annex XLII vessel designations), which reach us through that mirror. Present on every match screened since 2026-09-15; absent on records stored before, which are evidence and are never rewritten."
        },
        "source_url": {
          "type": "string",
          "format": "uri",
          "description": "The matched record's own page at the source, where the source publishes one: OFAC's sanctions search (`Details.aspx?id=`), the OpenSanctions entity page, the FCDO UK Sanctions List search (`/designations/<Unique ID>/<Ship|Entity|Individual>`), and for an EU financial-list record the Official Journal act that lists it, on EUR-Lex. ABSENT — not null, not a list-level link — for `un_sanctions`, which has no per-entry page. Every shape emitted was fetched before it was written down, and the customer-POV gate fetches every URL a RED emits, because a link that does not resolve is worse than none in an audit record."
        },
        "listed_on": {
          "type": [
            "string",
            "null"
          ],
          "format": "date",
          "description": "The date the record went on the list, as the SOURCE states it: OFAC's entry-creation date (from the SDN advanced XML — the CSV never carried one), the UN's LISTED_ON, OFSI's Date Designated, the earliest listing date any authority's designation states on an OpenSanctions entity, the EU's own designation date on a financial-list record. NULL means the edition consulted states no date for this record. It never means \"not listed\" — the match is still a match. Absent on records stored before 2026-09-15."
        },
        "list_edition": {
          "type": "object",
          "description": "Which edition of the list this match was found in — the file a compliance record names, kept as two timestamps because they answer different questions. `published` is what the source itself stamps on the edition (OFAC's DateOfIssue, the UN's dateGenerated, the FCDO report date, the OpenSanctions file's Last-Modified, the Commission's generationDate on the EU financial sanctions file); `retrieved_at` is when we took the copy we screened against. It is per RECORD, not per source: `eu_sanctions` is two files — the Commission's own export, regenerated on amendment, and the daily OpenSanctions mirror of the Official Journal — and each match names the one it was found in. `sources[].checked_at` collapses these into one value with a `basis`, which answers \"how current?\"; this answers \"which file?\". Absent on records stored before 2026-09-15.",
          "required": [
            "published",
            "retrieved_at"
          ],
          "properties": {
            "published": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time",
              "description": "The edition date the source states. Null when it states none or the source was unavailable."
            },
            "retrieved_at": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time",
              "description": "When we fetched the copy screened. Null when the source was unavailable."
            }
          }
        }
      }
    },
    "ScreeningNotice": {
      "type": "object",
      "required": [
        "code",
        "message",
        "remedy",
        "docs"
      ],
      "properties": {
        "code": {
          "type": "string",
          "enum": [
            "repeat_screening",
            "identity_unresolved",
            "identity_contested",
            "quota_warning",
            "quota_pace",
            "enrichment_latency",
            "auto_resolution",
            "imo_normalized"
          ],
          "description": "`imo_normalized` — the `imo` you sent was 8 digits, a valid IMO with one digit appended, and this screening is for the 7-digit IMO it contains; `imo_normalized_from` on the record carries what you sent. Only on accounts where that correction is enabled (see CreateScreeningRequest.imo). Fix the trailing digit in your source data. `quota_pace` — at the trailing seven days rate, this month allowance runs out before it resets. Carries the projected date, the measured per-day rate and the counts it came from. A PROJECTION, not a refusal: nothing is blocked, and it is emitted only while quota_warning is still silent, so the two never say the same thing twice. It stays quiet under three days of history, so a new account cannot project a cliff from one busy afternoon. `repeat_screening` — your account screened this hull inside the last 24 hours (the Idempotency-Key window) and this request carried a different key, so it ran again and spent a unit; `previous_screening_id` names the earlier record and `remedy` gives the derived key that would have replayed it free. On a screening that itself came back INCOMPLETE the notice names the earlier record as the last complete answer and gives NO key advice (a derived key would replay the non-answer); it is omitted altogether when the earlier record was INCOMPLETE too. `identity_unresolved` — no source we hold names this hull and the request carried no `vessel_name`, so nothing was screened (status INCOMPLETE, `identity_basis: none`, not charged). An IMMEDIATE identical retry cannot change that. Asking has placed the hull in the queue for our next scheduled port-State fetch, and `retry_after` (on this notice and on `sanctions`) is the instant by which that fetch will have run; when `retry_after` is absent, every scheduled fetch has already asked about this hull and found nothing, and only `vessel_name` will answer it. `identity_contested` — this IMO does not identify a single vessel in our registry: we hold four or more different vessel names recorded against it, which is the signature of a number that gets entered on forms rather than one belonging to a hull (1234567 is the common case, and it passes its own check digit). We decline to name it rather than pick one, because a screening run against the wrong name returns a CLEAN answer that means nothing. Fires INSTEAD of `identity_unresolved`, never alongside it; the remedy is to check where the number came from in your own data, with the `vessel_name` fallback for a caller who does hold a name. `quota_warning` — at least 80% of the current month's live screening allowance is used (the same threshold as the `usage.quota_warning` webhook event, placed where every caller reads); `quota` carries the numbers. On a one-time allowance it counts the total since it started and `quota.resets_at` is null. `enrichment_latency` — this screening took 6s or longer TO ANSWER, measured from when the request was received rather than from when computation began, with vetting/ownership enrichment included (the default); the sanctions verdict does not depend on enrichment, so a caller acting on `sanctions.status` alone can send `include_vetting: false` and stop waiting on external sources. `auto_resolution` — this INCOMPLETE enrolled for automatic re-screening (see the record's `resolution` block): the same request is run again for you on a bounded schedule with nothing to send, a verdict arrives as a new screening whose `supersedes` names this record (and as the `screening.resolved` webhook event), and `next_attempt_at` / `expires_at` / `max_attempts` are repeated here from the record. When it is present, `identity_unresolved` on the same response no longer asks you to re-screen — two notices never give opposite advice."
        },
        "message": {
          "type": "string",
          "description": "What is true of this request, in the account's own numbers."
        },
        "remedy": {
          "type": "string",
          "description": "The change to the NEXT request that makes this notice go away."
        },
        "docs": {
          "type": "string",
          "format": "uri"
        },
        "previous_screening_id": {
          "type": "string",
          "format": "uuid",
          "description": "`repeat_screening` only."
        },
        "previous_screened_at": {
          "type": "string",
          "format": "date-time",
          "description": "`repeat_screening` only."
        },
        "previous_status": {
          "type": "string",
          "description": "`repeat_screening` only — the earlier record's `sanctions.status`."
        },
        "retry_after": {
          "type": "string",
          "format": "date-time",
          "description": "`identity_unresolved` only, and only when a scheduled fetch — or a live register still answering in the background — can change the answer. The same instant as `sanctions.retry_after`, repeated so the notice is self-contained."
        },
        "next_attempt_at": {
          "type": [
            "string",
            "null"
          ],
          "format": "date-time",
          "description": "`auto_resolution` only — the first scheduled re-screen, as on `resolution.next_attempt_at`."
        },
        "expires_at": {
          "type": "string",
          "format": "date-time",
          "description": "`auto_resolution` only — when we stop trying and say so, as on `resolution.expires_at`."
        },
        "max_attempts": {
          "type": "integer",
          "description": "`auto_resolution` only — the schedule's length."
        },
        "webhook_event": {
          "type": "string",
          "description": "`auto_resolution` only — the event a resolution queues (`screening.resolved`)."
        },
        "quota": {
          "type": "object",
          "description": "`quota_warning` and `quota_pace`.",
          "required": [
            "limit",
            "used",
            "remaining",
            "resets_at"
          ],
          "properties": {
            "limit": {
              "type": "integer"
            },
            "used": {
              "type": "integer"
            },
            "remaining": {
              "type": "integer"
            },
            "resets_at": {
              "type": [
                "string",
                "null"
              ],
              "format": "date-time",
              "description": "When the allowance resets. null on a one-time allowance, which does not reset."
            }
          }
        }
      }
    },
    "ScreeningResolution": {
      "type": "object",
      "required": [
        "status",
        "cause",
        "attempts",
        "max_attempts",
        "next_attempt_at",
        "expires_at",
        "resolved_screening_id",
        "webhook_event"
      ],
      "properties": {
        "status": {
          "type": "string",
          "enum": [
            "pending",
            "resolved",
            "unresolved"
          ],
          "description": "`pending`: a re-screen is scheduled. `resolved`: a later screening answered this one — `resolved_screening_id` and the record's `superseded_by` name it. `unresolved`: every scheduled attempt still came back INCOMPLETE; the `screening.unresolved` webhook event carried the remedy, and only a request from you (`vessel_name`) can answer now."
        },
        "cause": {
          "type": "string",
          "enum": [
            "identity",
            "coverage"
          ],
          "description": "What the follow-up waits on. `identity`: no source we hold or read live names the hull; a store gaining a row is what answers it, on a daily-or-slower clock, so the schedule spans up to 30 days. `coverage`: a sanctions list did not load; outages end in minutes to hours, so the schedule is denser and gives up after 7 days."
        },
        "attempts": {
          "type": "integer",
          "minimum": 0,
          "description": "Re-screens made so far."
        },
        "max_attempts": {
          "type": "integer",
          "minimum": 1,
          "description": "The schedule's length; past it the status is `unresolved`."
        },
        "next_attempt_at": {
          "type": [
            "string",
            "null"
          ],
          "format": "date-time",
          "description": "The next scheduled re-screen. Null once resolved or unresolved. The first slot is `sanctions.retry_after` when the record carries one."
        },
        "expires_at": {
          "type": "string",
          "format": "date-time",
          "description": "The last slot of the schedule — after it, we stop trying and say so."
        },
        "resolved_screening_id": {
          "type": [
            "string",
            "null"
          ],
          "format": "uuid",
          "description": "The screening that answered, when `status` is `resolved`."
        },
        "webhook_event": {
          "type": "string",
          "enum": [
            "screening.resolved"
          ],
          "description": "The event a resolution queues, so a reader knows what to subscribe to without leaving the record."
        }
      }
    },
    "ScreeningSanctions": {
      "type": "object",
      "required": [
        "status",
        "detail",
        "coverage_complete",
        "coverage_gaps",
        "matches"
      ],
      "properties": {
        "status": {
          "type": "string",
          "enum": [
            "RED",
            "AMBER",
            "INCOMPLETE",
            "GREEN"
          ],
          "description": "RED — a confirmed IMO/MMSI-level match on a designating list. AMBER — a potential match needing review, NOT a designation, and NOT necessarily name-only: it is a name-only hit, OR an exact identifier listing in a source that does not itself designate (e.g. the Ukraine GUR war register), OR an identifier hit whose primary-list tag is uncorroborated and may be delisted. Read `detail` for which of the three drove it — an AMBER can carry an exact_imo match at full confidence. INCOMPLETE — fail-closed: identity or a mandatory list was unavailable, so no clearance can be defended. GREEN — screened against all reachable lists and matched none, as of the dates in `sources`."
        },
        "detail": {
          "type": "string"
        },
        "coverage_complete": {
          "type": "boolean"
        },
        "coverage_gaps": {
          "type": "array",
          "items": {
            "type": "string"
          },
          "description": "Display names of lists that did not load. Non-empty means say so rather than reporting a clean result."
        },
        "retry_after": {
          "type": [
            "string",
            "null"
          ],
          "format": "date-time",
          "description": "When a re-screen of this exact request may answer differently. Set ONLY on an INCOMPLETE whose cause is a hull we could not name, and only when something we set in motion can still change that: either our next scheduled port-State fetch will ask about the hull — asking placed it at the head of that demand-ordered queue, and this is the instant by which that fetch will have run (its worst case) — or a register we read live did not answer inside the request time budget (`identity_sources` marks it `reachable: false`), its lookup is finishing in the background, and this is the instant by which it will have settled. The `identity_unresolved` notice says which. Null on every other response, INCLUDING the INCOMPLETEs nothing pending can change: a failing check digit, and a hull every store has already been asked about and every live register answered without naming — there only `vessel_name` answers. Since 2026-09-15 you need not act on it: an INCOMPLETE that carries `resolution` is re-screened for you, and this instant is its first scheduled slot (`resolution.next_attempt_at`). If you do re-screen yourself, use a NEW Idempotency-Key; the old key replays the stored INCOMPLETE. Absent on records stored before 2026-09-13."
        },
        "matches": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/ScreeningMatch"
          }
        }
      }
    },
    "ScreeningScoreFloor": {
      "type": [
        "object",
        "null"
      ],
      "description": "Present only when the composite was FLOORED rather than computed, so `factors` cannot reproduce `score`. Null whenever the weighted factors decided the grade themselves. Deliberately separate from the voyage `ScoreFloor`: the two carry different bases and a shared enum would have published a contract our own responses violate.",
      "required": [
        "basis",
        "value",
        "explanation",
        "weighted_score"
      ],
      "properties": {
        "basis": {
          "type": "string",
          "enum": [
            "confirmed_sanctions_match",
            "watchlist_exact_imo"
          ],
          "description": "`confirmed_sanctions_match` — a confirmed sanctions match (exact IMO or MMSI on a monitored list) floors the composite at 90/E regardless of the weighted signals. `watchlist_exact_imo` (since 2026-09-13) — the hull is listed BY IMO on a non-designating watchlist, the same listing that raises the AMBER; the composite is floored at 61, the bottom of grade D. A value may be added to this list when a new floor is introduced; read it defensively."
        },
        "value": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "description": "The floored score actually published as `score`."
        },
        "weighted_score": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "description": "The composite the five `factors` DO reproduce, before the floor was applied. Reconcile charts and thresholds against this number, not against `score`."
        },
        "explanation": {
          "type": "string"
        }
      }
    },
    "ScreeningVetting": {
      "type": "object",
      "required": [
        "grade",
        "score",
        "status",
        "assessed",
        "factors"
      ],
      "properties": {
        "grade": {
          "type": [
            "string",
            "null"
          ],
          "enum": [
            "A",
            "B",
            "C",
            "D",
            "E",
            null
          ],
          "description": "NULL whenever `assessed` is false. There is no stand-in grade: a letter beside `assessed: false` gets read as a letter."
        },
        "score": {
          "type": [
            "integer",
            "null"
          ],
          "minimum": 0,
          "maximum": 100,
          "description": "Higher = worse. NULL whenever `assessed` is false."
        },
        "status": {
          "type": "string",
          "enum": [
            "acceptable",
            "marginal",
            "substandard",
            "unacceptable",
            "not_assessed",
            "not_requested"
          ],
          "description": "Grade band when assessed: `acceptable` (A-B), `marginal` (C), `substandard` (D), `unacceptable` (E). `not_assessed` — enrichment ran and produced no grade. `not_requested` — you passed `include_vetting: false`; nothing was attempted. The set has been closed since 2026-09-06; it is published as an enum from 2026-09-13."
        },
        "assessed": {
          "type": "boolean",
          "description": "False means the hull could not be assessed. `grade`, `score`, `detention_count` and `factors` are then NULL/empty rather than fail-open defaults — the values are not suppressed from you, they do not exist. Branch on this before reading the grade."
        },
        "detention_count": {
          "type": [
            "integer",
            "null"
          ],
          "description": "NULL when no port-state-control record was observed for this hull. It is not 0 in that case: \"no detentions\" and \"we hold no inspection history\" are different facts and this field reported them identically until 2026-09-06."
        },
        "vessel_age": {
          "type": [
            "integer",
            "null"
          ]
        },
        "score_floor": {
          "$ref": "#/$defs/ScreeningScoreFloor"
        },
        "factors": {
          "type": "array",
          "description": "The five weighted inputs behind the grade, as numbers rather than prose, so a threshold or chart can be built on them. A factor we could not observe reports score=null and observed=false — for example a hull whose most recent port-state-control record predates our plausibility horizon has no verifiable detention history, so detention_history is excluded from the grade rather than scored as clean. Threshold on observed before thresholding on score.",
          "items": {
            "type": "object",
            "required": [
              "factor",
              "score",
              "weight_pct",
              "observed"
            ],
            "properties": {
              "factor": {
                "type": "string",
                "enum": [
                  "detention_history",
                  "flag_performance",
                  "vessel_age",
                  "inspection_gap",
                  "ownership_opacity"
                ]
              },
              "score": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "0 = best, 100 = worst. null when observed=false."
              },
              "weight_pct": {
                "type": "integer",
                "description": "Weight in the composite. 0 when the factor was excluded and its weight redistributed."
              },
              "observed": {
                "type": "boolean",
                "description": "False when this factor is a model default or an excluded unknown, not a measurement."
              }
            }
          }
        }
      }
    },
    "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\")."
        }
      }
    }
  }
}