diff --git a/docs/api/SupplyChainService.openapi.json b/docs/api/SupplyChainService.openapi.json index 5012a32b8..5bab64629 100644 --- a/docs/api/SupplyChainService.openapi.json +++ b/docs/api/SupplyChainService.openapi.json @@ -1 +1 @@ -{"components":{"schemas":{"BypassOption":{"properties":{"activationThreshold":{"type":"string"},"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"bypassWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"},"capacityConstraintTonnage":{"format":"int64","type":"string"},"id":{"type":"string"},"liveScore":{"format":"double","type":"number"},"name":{"type":"string"},"notes":{"type":"string"},"suitableCargoTypes":{"items":{"type":"string"},"type":"array"},"type":{"type":"string"},"waypointChokepointIds":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ChokepointExposureEntry":{"description":"ChokepointExposureEntry holds per-chokepoint exposure data for a country.","properties":{"chokepointId":{"description":"Canonical chokepoint ID from the chokepoint registry.","type":"string"},"chokepointName":{"description":"Human-readable chokepoint name.","type":"string"},"coastSide":{"description":"Which ocean/basin side the country's ports face (atlantic, pacific, indian, med, multi, landlocked).","type":"string"},"exposureScore":{"description":"Exposure score 0–100; higher = more dependent on this chokepoint.","format":"double","type":"number"},"shockSupported":{"description":"Whether the shock model is supported for this chokepoint + hs2 combination.","type":"boolean"}},"type":"object"},"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"flowEstimate":{"$ref":"#/components/schemas/FlowEstimate"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"FlowEstimate":{"properties":{"baselineMbd":{"format":"double","type":"number"},"currentMbd":{"format":"double","type":"number"},"disrupted":{"type":"boolean"},"flowRatio":{"format":"double","type":"number"},"hazardAlertLevel":{"type":"string"},"hazardAlertName":{"type":"string"},"source":{"type":"string"}},"type":"object"},"GetBypassOptionsRequest":{"properties":{"cargoType":{"description":"container | tanker | bulk | roro (default: \"container\")","type":"string"},"chokepointId":{"type":"string"},"closurePct":{"description":"0-100, percent of capacity blocked (default: 100)","format":"int32","type":"integer"}},"required":["chokepointId"],"type":"object"},"GetBypassOptionsResponse":{"properties":{"cargoType":{"type":"string"},"chokepointId":{"type":"string"},"closurePct":{"format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"options":{"items":{"$ref":"#/components/schemas/BypassOption"},"type":"array"}},"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCountryChokepointIndexRequest":{"description":"GetCountryChokepointIndexRequest specifies the country and optional HS2 chapter.","properties":{"hs2":{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code (uppercase).","pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2"],"type":"object"},"GetCountryChokepointIndexResponse":{"description":"GetCountryChokepointIndexResponse returns exposure scores for all relevant chokepoints.","properties":{"exposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureEntry"},"type":"array"},"fetchedAt":{"description":"ISO timestamp of when this data was last seeded.","type":"string"},"hs2":{"description":"HS2 chapter used for the computation.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code echoed from the request.","type":"string"},"primaryChokepointId":{"description":"Canonical ID of the chokepoint with the highest exposure score.","type":"string"},"vulnerabilityIndex":{"description":"Composite vulnerability index 0–100 (weighted sum of top-3 exposures).","format":"double","type":"number"}},"type":"object"},"GetCountryCostShockRequest":{"properties":{"chokepointId":{"type":"string"},"hs2":{"description":"HS2 chapter (default: \"27\")","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","chokepointId"],"type":"object"},"GetCountryCostShockResponse":{"properties":{"chokepointId":{"type":"string"},"costIncreasePct":{"description":"Estimated annualized import cost increase as % of sector import value (HS 27 only; null for other sectors)","format":"double","type":"number"},"coverageDays":{"description":"Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors)","format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"hasEnergyModel":{"description":"Whether cost_increase_pct and coverage_days are modelled (true) or unavailable (false)","type":"boolean"},"hs2":{"type":"string"},"iso2":{"type":"string"},"unavailableReason":{"description":"Null/unavailable explanation for non-energy sectors","type":"string"},"warRiskPremiumBps":{"description":"War risk insurance premium in basis points for this chokepoint","format":"int32","type":"integer"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-bypass-options":{"get":{"description":"GetBypassOptions returns ranked bypass corridors for a chokepoint. PRO-gated.","operationId":"GetBypassOptions","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"container | tanker | bulk | roro (default: \"container\")","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}},{"description":"0-100, percent of capacity blocked (default: 100)","in":"query","name":"closurePct","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBypassOptionsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBypassOptions","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-chokepoint-index":{"get":{"description":"GetCountryChokepointIndex returns per-chokepoint exposure scores for a country. PRO-gated.","operationId":"GetCountryChokepointIndex","parameters":[{"description":"ISO 3166-1 alpha-2 country code (uppercase).","in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryChokepointIndexResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryChokepointIndex","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-cost-shock":{"get":{"description":"GetCountryCostShock returns cost shock and war risk data for a country+chokepoint. PRO-gated.","operationId":"GetCountryCostShock","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (default: \"27\")","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryCostShockResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryCostShock","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file +{"components":{"schemas":{"BypassOption":{"properties":{"activationThreshold":{"type":"string"},"addedCostMultiplier":{"format":"double","type":"number"},"addedTransitDays":{"format":"int32","type":"integer"},"bypassWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"},"capacityConstraintTonnage":{"format":"int64","type":"string"},"id":{"type":"string"},"liveScore":{"format":"double","type":"number"},"name":{"type":"string"},"notes":{"type":"string"},"suitableCargoTypes":{"items":{"type":"string"},"type":"array"},"type":{"type":"string"},"waypointChokepointIds":{"items":{"type":"string"},"type":"array"}},"type":"object"},"ChokepointExposureEntry":{"description":"ChokepointExposureEntry holds per-chokepoint exposure data for a country.","properties":{"chokepointId":{"description":"Canonical chokepoint ID from the chokepoint registry.","type":"string"},"chokepointName":{"description":"Human-readable chokepoint name.","type":"string"},"coastSide":{"description":"Which ocean/basin side the country's ports face (atlantic, pacific, indian, med, multi, landlocked).","type":"string"},"exposureScore":{"description":"Exposure score 0–100; higher = more dependent on this chokepoint.","format":"double","type":"number"},"shockSupported":{"description":"Whether the shock model is supported for this chokepoint + hs2 combination.","type":"boolean"}},"type":"object"},"ChokepointInfo":{"properties":{"activeWarnings":{"format":"int32","type":"integer"},"affectedRoutes":{"items":{"type":"string"},"type":"array"},"aisDisruptions":{"format":"int32","type":"integer"},"congestionLevel":{"type":"string"},"description":{"type":"string"},"directionalDwt":{"items":{"$ref":"#/components/schemas/DirectionalDwt"},"type":"array"},"directions":{"items":{"type":"string"},"type":"array"},"disruptionScore":{"format":"int32","type":"integer"},"flowEstimate":{"$ref":"#/components/schemas/FlowEstimate"},"id":{"type":"string"},"lat":{"format":"double","type":"number"},"lon":{"format":"double","type":"number"},"name":{"type":"string"},"status":{"type":"string"},"transitSummary":{"$ref":"#/components/schemas/TransitSummary"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"CriticalMineral":{"properties":{"globalProduction":{"format":"double","type":"number"},"hhi":{"format":"double","type":"number"},"mineral":{"type":"string"},"riskRating":{"type":"string"},"topProducers":{"items":{"$ref":"#/components/schemas/MineralProducer"},"type":"array"},"unit":{"type":"string"}},"type":"object"},"DirectionalDwt":{"properties":{"direction":{"type":"string"},"dwtThousandTonnes":{"format":"double","type":"number"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"FlowEstimate":{"properties":{"baselineMbd":{"format":"double","type":"number"},"currentMbd":{"format":"double","type":"number"},"disrupted":{"type":"boolean"},"flowRatio":{"format":"double","type":"number"},"hazardAlertLevel":{"type":"string"},"hazardAlertName":{"type":"string"},"source":{"type":"string"}},"type":"object"},"GetBypassOptionsRequest":{"properties":{"cargoType":{"description":"container | tanker | bulk | roro (default: \"container\")","type":"string"},"chokepointId":{"type":"string"},"closurePct":{"description":"0-100, percent of capacity blocked (default: 100)","format":"int32","type":"integer"}},"required":["chokepointId"],"type":"object"},"GetBypassOptionsResponse":{"properties":{"cargoType":{"type":"string"},"chokepointId":{"type":"string"},"closurePct":{"format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"options":{"items":{"$ref":"#/components/schemas/BypassOption"},"type":"array"},"primaryChokepointWarRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetChokepointStatusRequest":{"type":"object"},"GetChokepointStatusResponse":{"properties":{"chokepoints":{"items":{"$ref":"#/components/schemas/ChokepointInfo"},"type":"array"},"fetchedAt":{"type":"string"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetCountryChokepointIndexRequest":{"description":"GetCountryChokepointIndexRequest specifies the country and optional HS2 chapter.","properties":{"hs2":{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code (uppercase).","pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2"],"type":"object"},"GetCountryChokepointIndexResponse":{"description":"GetCountryChokepointIndexResponse returns exposure scores for all relevant chokepoints.","properties":{"exposures":{"items":{"$ref":"#/components/schemas/ChokepointExposureEntry"},"type":"array"},"fetchedAt":{"description":"ISO timestamp of when this data was last seeded.","type":"string"},"hs2":{"description":"HS2 chapter used for the computation.","type":"string"},"iso2":{"description":"ISO 3166-1 alpha-2 country code echoed from the request.","type":"string"},"primaryChokepointId":{"description":"Canonical ID of the chokepoint with the highest exposure score.","type":"string"},"vulnerabilityIndex":{"description":"Composite vulnerability index 0–100 (weighted sum of top-3 exposures).","format":"double","type":"number"}},"type":"object"},"GetCountryCostShockRequest":{"properties":{"chokepointId":{"type":"string"},"hs2":{"description":"HS2 chapter (default: \"27\")","type":"string"},"iso2":{"pattern":"^[A-Z]{2}$","type":"string"}},"required":["iso2","chokepointId"],"type":"object"},"GetCountryCostShockResponse":{"properties":{"chokepointId":{"type":"string"},"coverageDays":{"description":"Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors or net exporters)","format":"int32","type":"integer"},"fetchedAt":{"type":"string"},"hasEnergyModel":{"description":"Whether supply_deficit_pct and coverage_days are modelled (true) or unavailable (false)","type":"boolean"},"hs2":{"type":"string"},"iso2":{"type":"string"},"supplyDeficitPct":{"description":"Average refined-product supply deficit % under full closure (Gasoline/Diesel/Jet fuel/LPG average; HS 27 only)","format":"double","type":"number"},"unavailableReason":{"description":"Null/unavailable explanation for non-energy sectors","type":"string"},"warRiskPremiumBps":{"description":"War risk insurance premium in basis points for this chokepoint","format":"int32","type":"integer"},"warRiskTier":{"description":"*\n War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification.\n This is a FREE field (no PRO gate) — it exposes the existing server-internal\n threatLevel from ChokepointConfig, making it available to clients for badges\n and bypass corridor scoring.","enum":["WAR_RISK_TIER_UNSPECIFIED","WAR_RISK_TIER_NORMAL","WAR_RISK_TIER_ELEVATED","WAR_RISK_TIER_HIGH","WAR_RISK_TIER_CRITICAL","WAR_RISK_TIER_WAR_ZONE"],"type":"string"}},"type":"object"},"GetCriticalMineralsRequest":{"type":"object"},"GetCriticalMineralsResponse":{"properties":{"fetchedAt":{"type":"string"},"minerals":{"items":{"$ref":"#/components/schemas/CriticalMineral"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingRatesRequest":{"type":"object"},"GetShippingRatesResponse":{"properties":{"fetchedAt":{"type":"string"},"indices":{"items":{"$ref":"#/components/schemas/ShippingIndex"},"type":"array"},"upstreamUnavailable":{"type":"boolean"}},"type":"object"},"GetShippingStressRequest":{"type":"object"},"GetShippingStressResponse":{"properties":{"carriers":{"items":{"$ref":"#/components/schemas/ShippingStressCarrier"},"type":"array"},"fetchedAt":{"description":"Warning: Values \u003e 2^53 may lose precision in JavaScript","format":"int64","type":"integer"},"stressLevel":{"description":"\"low\" | \"moderate\" | \"elevated\" | \"critical\".","type":"string"},"stressScore":{"description":"Composite stress score 0–100 (higher = more disruption).","format":"double","type":"number"},"upstreamUnavailable":{"description":"Set to true when upstream data source is unavailable and cached data is stale.","type":"boolean"}},"type":"object"},"MineralProducer":{"properties":{"country":{"type":"string"},"countryCode":{"type":"string"},"productionTonnes":{"format":"double","type":"number"},"sharePct":{"format":"double","type":"number"}},"type":"object"},"ShippingIndex":{"properties":{"changePct":{"format":"double","type":"number"},"currentValue":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/ShippingRatePoint"},"type":"array"},"indexId":{"type":"string"},"name":{"type":"string"},"previousValue":{"format":"double","type":"number"},"spikeAlert":{"type":"boolean"},"unit":{"type":"string"}},"type":"object"},"ShippingRatePoint":{"properties":{"date":{"type":"string"},"value":{"format":"double","type":"number"}},"type":"object"},"ShippingStressCarrier":{"description":"ShippingStressCarrier represents market stress data for a carrier or shipping index.","properties":{"carrierType":{"description":"Carrier type: \"etf\" | \"carrier\" | \"index\".","type":"string"},"changePct":{"description":"Percentage change from previous close.","format":"double","type":"number"},"name":{"description":"Human-readable name.","type":"string"},"price":{"description":"Current price.","format":"double","type":"number"},"sparkline":{"items":{"description":"30-day price sparkline.","format":"double","type":"number"},"type":"array"},"symbol":{"description":"Ticker or identifier (e.g., \"BDRY\", \"ZIM\").","type":"string"}},"type":"object"},"TransitDayCount":{"properties":{"capContainer":{"format":"double","type":"number"},"capDryBulk":{"format":"double","type":"number"},"capGeneralCargo":{"format":"double","type":"number"},"capRoro":{"format":"double","type":"number"},"capTanker":{"format":"double","type":"number"},"cargo":{"format":"int32","type":"integer"},"container":{"format":"int32","type":"integer"},"date":{"type":"string"},"dryBulk":{"format":"int32","type":"integer"},"generalCargo":{"format":"int32","type":"integer"},"other":{"format":"int32","type":"integer"},"roro":{"format":"int32","type":"integer"},"tanker":{"format":"int32","type":"integer"},"total":{"format":"int32","type":"integer"}},"type":"object"},"TransitSummary":{"properties":{"disruptionPct":{"format":"double","type":"number"},"history":{"items":{"$ref":"#/components/schemas/TransitDayCount"},"type":"array"},"incidentCount7d":{"format":"int32","type":"integer"},"riskLevel":{"type":"string"},"riskReportAction":{"type":"string"},"riskSummary":{"type":"string"},"todayCargo":{"format":"int32","type":"integer"},"todayOther":{"format":"int32","type":"integer"},"todayTanker":{"format":"int32","type":"integer"},"todayTotal":{"format":"int32","type":"integer"},"wowChangePct":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"SupplyChainService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/supply-chain/v1/get-bypass-options":{"get":{"description":"GetBypassOptions returns ranked bypass corridors for a chokepoint. PRO-gated.","operationId":"GetBypassOptions","parameters":[{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"container | tanker | bulk | roro (default: \"container\")","in":"query","name":"cargoType","required":false,"schema":{"type":"string"}},{"description":"0-100, percent of capacity blocked (default: 100)","in":"query","name":"closurePct","required":false,"schema":{"format":"int32","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetBypassOptionsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetBypassOptions","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-chokepoint-status":{"get":{"operationId":"GetChokepointStatus","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetChokepointStatusResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetChokepointStatus","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-chokepoint-index":{"get":{"description":"GetCountryChokepointIndex returns per-chokepoint exposure scores for a country. PRO-gated.","operationId":"GetCountryChokepointIndex","parameters":[{"description":"ISO 3166-1 alpha-2 country code (uppercase).","in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (2-digit string). Defaults to \"27\" (energy/mineral fuels) when absent.","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryChokepointIndexResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryChokepointIndex","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-country-cost-shock":{"get":{"description":"GetCountryCostShock returns cost shock and war risk data for a country+chokepoint. PRO-gated.","operationId":"GetCountryCostShock","parameters":[{"in":"query","name":"iso2","required":false,"schema":{"type":"string"}},{"in":"query","name":"chokepointId","required":false,"schema":{"type":"string"}},{"description":"HS2 chapter (default: \"27\")","in":"query","name":"hs2","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCountryCostShockResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCountryCostShock","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-critical-minerals":{"get":{"operationId":"GetCriticalMinerals","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetCriticalMineralsResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetCriticalMinerals","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-rates":{"get":{"operationId":"GetShippingRates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingRatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingRates","tags":["SupplyChainService"]}},"/api/supply-chain/v1/get-shipping-stress":{"get":{"description":"GetShippingStress returns carrier market data and a composite stress index.","operationId":"GetShippingStress","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetShippingStressResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetShippingStress","tags":["SupplyChainService"]}}}} \ No newline at end of file diff --git a/docs/api/SupplyChainService.openapi.yaml b/docs/api/SupplyChainService.openapi.yaml index 1ea469948..bbdcaf54d 100644 --- a/docs/api/SupplyChainService.openapi.yaml +++ b/docs/api/SupplyChainService.openapi.yaml @@ -672,6 +672,21 @@ components: $ref: '#/components/schemas/BypassOption' fetchedAt: type: string + primaryChokepointWarRiskTier: + type: string + enum: + - WAR_RISK_TIER_UNSPECIFIED + - WAR_RISK_TIER_NORMAL + - WAR_RISK_TIER_ELEVATED + - WAR_RISK_TIER_HIGH + - WAR_RISK_TIER_CRITICAL + - WAR_RISK_TIER_WAR_ZONE + description: |- + * + War risk tier derived from Lloyd's JWC Listed Areas + OSINT threat classification. + This is a FREE field (no PRO gate) — it exposes the existing server-internal + threatLevel from ChokepointConfig, making it available to clients for badges + and bypass corridor scoring. BypassOption: type: object properties: @@ -743,14 +758,14 @@ components: type: string hs2: type: string - costIncreasePct: + supplyDeficitPct: type: number format: double - description: Estimated annualized import cost increase as % of sector import value (HS 27 only; null for other sectors) + description: Average refined-product supply deficit % under full closure (Gasoline/Diesel/Jet fuel/LPG average; HS 27 only) coverageDays: type: integer format: int32 - description: Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors) + description: Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors or net exporters) warRiskPremiumBps: type: integer format: int32 @@ -772,7 +787,7 @@ components: and bypass corridor scoring. hasEnergyModel: type: boolean - description: Whether cost_increase_pct and coverage_days are modelled (true) or unavailable (false) + description: Whether supply_deficit_pct and coverage_days are modelled (true) or unavailable (false) unavailableReason: type: string description: Null/unavailable explanation for non-energy sectors diff --git a/proto/worldmonitor/supply_chain/v1/get_bypass_options.proto b/proto/worldmonitor/supply_chain/v1/get_bypass_options.proto index 495c2097e..98f544b7f 100644 --- a/proto/worldmonitor/supply_chain/v1/get_bypass_options.proto +++ b/proto/worldmonitor/supply_chain/v1/get_bypass_options.proto @@ -37,4 +37,5 @@ message GetBypassOptionsResponse { int32 closure_pct = 3; repeated BypassOption options = 4; // ranked by live_score asc string fetched_at = 5; + WarRiskTier primary_chokepoint_war_risk_tier = 6; // war risk tier of the queried chokepoint } diff --git a/proto/worldmonitor/supply_chain/v1/get_country_cost_shock.proto b/proto/worldmonitor/supply_chain/v1/get_country_cost_shock.proto index 1b7feed20..a616978c3 100644 --- a/proto/worldmonitor/supply_chain/v1/get_country_cost_shock.proto +++ b/proto/worldmonitor/supply_chain/v1/get_country_cost_shock.proto @@ -24,14 +24,14 @@ message GetCountryCostShockResponse { string iso2 = 1; string chokepoint_id = 2; string hs2 = 3; - // Estimated annualized import cost increase as % of sector import value (HS 27 only; null for other sectors) - double cost_increase_pct = 4; - // Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors) + // Average refined-product supply deficit % under full closure (Gasoline/Diesel/Jet fuel/LPG average; HS 27 only) + double supply_deficit_pct = 4; + // Energy stockpile coverage in days (IEA data, HS 27 only; 0 for non-energy sectors or net exporters) int32 coverage_days = 5; // War risk insurance premium in basis points for this chokepoint int32 war_risk_premium_bps = 6; WarRiskTier war_risk_tier = 7; - // Whether cost_increase_pct and coverage_days are modelled (true) or unavailable (false) + // Whether supply_deficit_pct and coverage_days are modelled (true) or unavailable (false) bool has_energy_model = 8; // Null/unavailable explanation for non-energy sectors string unavailable_reason = 9; diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs index bfdee4a51..27a65a866 100644 --- a/scripts/ais-relay.cjs +++ b/scripts/ais-relay.cjs @@ -9664,7 +9664,10 @@ aviation: get-airport-ops-summary (params: airport_code), get-carrier-ops (param intelligence: get-country-intel-brief (params: country_code), get-country-facts (params: country_code), get-social-velocity health: list-disease-outbreaks -supply-chain: get-shipping-stress, get-country-chokepoint-index (params: iso2 required, hs2 default '27'; PRO-gated — returns exposures[], vulnerabilityIndex 0-100, primaryChokepointId) +supply-chain: get-shipping-stress, + get-country-chokepoint-index (params: iso2 required, hs2 default '27'; PRO-gated — returns exposures[], vulnerabilityIndex 0-100, primaryChokepointId), + get-bypass-options (params: chokepointId required, cargoType default 'container', closurePct default 100; PRO-gated — returns options[] sorted by liveScore asc, each with addedTransitDays/addedCostMultiplier/bypassWarRiskTier; also primaryChokepointWarRiskTier), + get-country-cost-shock (params: iso2 required, chokepointId required, hs2 default '27'; PRO-gated — returns supplyDeficitPct 0-100%, coverageDays, warRiskPremiumBps, warRiskTier; hasEnergyModel=true only for HS 27 + Hormuz/Suez/Malacca/BEM) conflict: list-acled-events, get-humanitarian-summary (params: country_code) market: get-country-stock-index (params: country_code), list-earnings-calendar, get-cot-positioning consumer-prices: list-retailer-price-spreads @@ -10245,7 +10248,10 @@ aviation: get-airport-ops-summary (params: airport_code), get-carrier-ops (param intelligence: get-country-intel-brief (params: country_code), get-country-facts (params: country_code), get-social-velocity health: list-disease-outbreaks -supply-chain: get-shipping-stress, get-country-chokepoint-index (params: iso2 required, hs2 default '27'; PRO-gated — returns exposures[], vulnerabilityIndex 0-100, primaryChokepointId) +supply-chain: get-shipping-stress, + get-country-chokepoint-index (params: iso2 required, hs2 default '27'; PRO-gated — returns exposures[], vulnerabilityIndex 0-100, primaryChokepointId), + get-bypass-options (params: chokepointId required, cargoType default 'container', closurePct default 100; PRO-gated — returns options[] sorted by liveScore asc, each with addedTransitDays/addedCostMultiplier/bypassWarRiskTier; also primaryChokepointWarRiskTier), + get-country-cost-shock (params: iso2 required, chokepointId required, hs2 default '27'; PRO-gated — returns supplyDeficitPct 0-100%, coverageDays, warRiskPremiumBps, warRiskTier; hasEnergyModel=true only for HS 27 + Hormuz/Suez/Malacca/BEM) conflict: list-acled-events, get-humanitarian-summary (params: country_code) market: get-country-stock-index (params: country_code), list-earnings-calendar, get-cot-positioning consumer-prices: list-retailer-price-spreads diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts index bb62a4b11..670ef3e7b 100644 --- a/server/_shared/cache-keys.ts +++ b/server/_shared/cache-keys.ts @@ -78,6 +78,11 @@ export const CHOKEPOINT_EXPOSURE_SEED_META_KEY = 'seed-meta:supply_chain:chokepo export const COST_SHOCK_KEY = (iso2: string, chokepointId: string) => `supply-chain:cost-shock:${iso2}:${chokepointId}:v1` as const; +/** + * Shared chokepoint status cache key — written by get-chokepoint-status, read by bypass-options and cost-shock handlers. + */ +export const CHOKEPOINT_STATUS_KEY = 'supply_chain:chokepoints:v4' as const; + /** * Static cache keys for the bootstrap endpoint. * Only keys with NO request-varying suffixes are included. diff --git a/server/worldmonitor/supply-chain/v1/_insurance-tier.ts b/server/worldmonitor/supply-chain/v1/_insurance-tier.ts index 280a16c15..b5c6e697b 100644 --- a/server/worldmonitor/supply-chain/v1/_insurance-tier.ts +++ b/server/worldmonitor/supply-chain/v1/_insurance-tier.ts @@ -1,3 +1,5 @@ +import type { WarRiskTier } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; + export type ThreatLevel = 'war_zone' | 'critical' | 'high' | 'elevated' | 'normal'; /** @@ -12,5 +14,54 @@ export function threatLevelToInsurancePremiumBps(threatLevel: ThreatLevel): numb case 'high': return 50; // 0.5% case 'elevated': return 20; // 0.2% case 'normal': return 5; // 0.05% + default: { + ((_: never) => {})(threatLevel); + return 5; + } } } + +/** + * Direct tier string → insurance premium bps (no ThreatLevel intermediate). + * Use in handlers where warRiskTier is read directly from Redis. + */ +export function warRiskTierToInsurancePremiumBps(tier: string): number { + switch (tier) { + case 'WAR_RISK_TIER_WAR_ZONE': return 300; + case 'WAR_RISK_TIER_CRITICAL': return 100; + case 'WAR_RISK_TIER_HIGH': return 50; + case 'WAR_RISK_TIER_ELEVATED': return 20; + default: return 5; + } +} + +/** + * Maps ThreatLevel (internal) → WarRiskTier proto enum string. + * Canonical mapping used by get-chokepoint-status and supply-chain handlers. + */ +export function threatLevelToWarRiskTier(tl: ThreatLevel): WarRiskTier { + switch (tl) { + case 'war_zone': return 'WAR_RISK_TIER_WAR_ZONE'; + case 'critical': return 'WAR_RISK_TIER_CRITICAL'; + case 'high': return 'WAR_RISK_TIER_HIGH'; + case 'elevated': return 'WAR_RISK_TIER_ELEVATED'; + case 'normal': return 'WAR_RISK_TIER_NORMAL'; + default: { + ((_: never) => {})(tl); + return 'WAR_RISK_TIER_NORMAL'; + } + } +} + +/** + * Tier rank for sorting/comparing war risk tiers. Higher = more severe. + * Exported here so handlers don't allocate it inline on every request. + */ +export const TIER_RANK: Record = { + WAR_RISK_TIER_WAR_ZONE: 5, + WAR_RISK_TIER_CRITICAL: 4, + WAR_RISK_TIER_HIGH: 3, + WAR_RISK_TIER_ELEVATED: 2, + WAR_RISK_TIER_NORMAL: 1, + WAR_RISK_TIER_UNSPECIFIED: 0, +}; diff --git a/server/worldmonitor/supply-chain/v1/get-bypass-options.ts b/server/worldmonitor/supply-chain/v1/get-bypass-options.ts index 1bc570d88..891b7ea67 100644 --- a/server/worldmonitor/supply-chain/v1/get-bypass-options.ts +++ b/server/worldmonitor/supply-chain/v1/get-bypass-options.ts @@ -3,11 +3,19 @@ import type { GetBypassOptionsRequest, GetBypassOptionsResponse, BypassOption, + ChokepointInfo, } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; import { isCallerPremium } from '../../../_shared/premium-check'; import { BYPASS_CORRIDORS_BY_CHOKEPOINT } from '../../../../src/config/bypass-corridors'; import { getCachedJson } from '../../../_shared/redis'; +import { CHOKEPOINT_STATUS_KEY } from '../../../_shared/cache-keys'; +import { TIER_RANK } from './_insurance-tier'; + +// Scoring: disruption risk dominates (60%), cost premium secondary (40%). +// Risk: 0-100 from disruptionScore. Cost: 0-55 from addedCostMultiplier (max ~1.55 → 55pts). +const SCORE_RISK_WEIGHT = 0.6; +const SCORE_COST_WEIGHT = 0.4; export async function getBypassOptions( ctx: ServerContext, @@ -17,8 +25,9 @@ export async function getBypassOptions( const empty: GetBypassOptionsResponse = { chokepointId: req.chokepointId, cargoType: req.cargoType || 'container', - closurePct: req.closurePct || 100, + closurePct: req.closurePct ?? 100, options: [], + primaryChokepointWarRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', fetchedAt: new Date().toISOString(), }; if (!isPro) return empty; @@ -38,8 +47,7 @@ export async function getBypassOptions( return true; }); - type ChokepointStatusCacheEntry = { id: string; warRiskTier?: string; disruptionScore?: number }; - const statusRaw = await getCachedJson('supply_chain:chokepoints:v4').catch(() => null) as { chokepoints?: ChokepointStatusCacheEntry[] } | null; + const statusRaw = await getCachedJson(CHOKEPOINT_STATUS_KEY).catch(() => null) as { chokepoints?: ChokepointInfo[] } | null; const tierMap: Record = {}; const scoreMap: Record = {}; for (const cp of statusRaw?.chokepoints ?? []) { @@ -47,17 +55,16 @@ export async function getBypassOptions( if (typeof cp.disruptionScore === 'number') scoreMap[cp.id] = cp.disruptionScore; } - const TIER_RANK: Record = { - WAR_RISK_TIER_WAR_ZONE: 5, WAR_RISK_TIER_CRITICAL: 4, WAR_RISK_TIER_HIGH: 3, - WAR_RISK_TIER_ELEVATED: 2, WAR_RISK_TIER_NORMAL: 1, WAR_RISK_TIER_UNSPECIFIED: 0, - }; + const primaryChokepointWarRiskTier = (tierMap[chokepointId] ?? 'WAR_RISK_TIER_UNSPECIFIED') as BypassOption['bypassWarRiskTier']; const options: BypassOption[] = relevant.map(c => { const waypointScores = c.waypointChokepointIds.map(id => scoreMap[id] ?? 0); const avgWaypointScore = waypointScores.length > 0 ? waypointScores.reduce((a, b) => a + b, 0) / waypointScores.length : 0; - const liveScore = Math.min(100, avgWaypointScore * 0.6 + (c.addedCostMultiplier - 1) * 100 * 0.4); + const liveScore = Math.max(0, Math.min(100, + avgWaypointScore * SCORE_RISK_WEIGHT + (c.addedCostMultiplier - 1) * 100 * SCORE_COST_WEIGHT + )); const maxTierKey = c.waypointChokepointIds.reduce((best, id) => { const t = tierMap[id] ?? 'WAR_RISK_TIER_UNSPECIFIED'; @@ -87,6 +94,7 @@ export async function getBypassOptions( cargoType, closurePct, options, + primaryChokepointWarRiskTier, fetchedAt: new Date().toISOString(), }; } diff --git a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts index 513ad75c9..7a0c0c77d 100644 --- a/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts +++ b/server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts @@ -3,7 +3,6 @@ import type { GetChokepointStatusRequest, GetChokepointStatusResponse, ChokepointInfo, - WarRiskTier, } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; import type { @@ -20,8 +19,8 @@ import type { PortWatchData } from './_portwatch-upstream'; import { CANONICAL_CHOKEPOINTS } from './_chokepoint-ids'; // @ts-expect-error — .mjs module, no declaration file import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE, THREAT_LEVEL, detectTrafficAnomaly } from './_scoring.mjs'; - -const REDIS_CACHE_KEY = 'supply_chain:chokepoints:v4'; +import { type ThreatLevel, threatLevelToWarRiskTier } from './_insurance-tier'; +import { CHOKEPOINT_STATUS_KEY as REDIS_CACHE_KEY } from '../../../_shared/cache-keys'; const TRANSIT_SUMMARIES_KEY = 'supply_chain:transit-summaries:v1'; const PORTWATCH_FALLBACK_KEY = 'supply_chain:portwatch:v1'; const CORRIDORRISK_FALLBACK_KEY = 'supply_chain:corridorrisk:v1'; @@ -32,19 +31,8 @@ const THREAT_CONFIG_MAX_AGE_DAYS = 120; const NEARBY_CHOKEPOINT_RADIUS_KM = 300; const THREAT_CONFIG_STALE_NOTE = `Threat baseline last reviewed > ${THREAT_CONFIG_MAX_AGE_DAYS} days ago — review recommended`; -type ThreatLevel = 'war_zone' | 'critical' | 'high' | 'elevated' | 'normal'; type GeoCoordinates = { latitude: number; longitude: number }; -function threatLevelToWarRiskTier(threatLevel: ThreatLevel): WarRiskTier { - switch (threatLevel) { - case 'war_zone': return 'WAR_RISK_TIER_WAR_ZONE'; - case 'critical': return 'WAR_RISK_TIER_CRITICAL'; - case 'high': return 'WAR_RISK_TIER_HIGH'; - case 'elevated': return 'WAR_RISK_TIER_ELEVATED'; - case 'normal': return 'WAR_RISK_TIER_NORMAL'; - } -} - interface ChokepointConfig { id: string; name: string; diff --git a/server/worldmonitor/supply-chain/v1/get-country-cost-shock.ts b/server/worldmonitor/supply-chain/v1/get-country-cost-shock.ts index b6595329c..7c21cddd6 100644 --- a/server/worldmonitor/supply-chain/v1/get-country-cost-shock.ts +++ b/server/worldmonitor/supply-chain/v1/get-country-cost-shock.ts @@ -2,35 +2,16 @@ import type { ServerContext, GetCountryCostShockRequest, GetCountryCostShockResponse, + ChokepointInfo, WarRiskTier, } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server'; import { isCallerPremium } from '../../../_shared/premium-check'; -import { getCachedJson } from '../../../_shared/redis'; +import { cachedFetchJson, getCachedJson } from '../../../_shared/redis'; import { CHOKEPOINT_REGISTRY } from '../../../../src/config/chokepoint-registry'; import { computeEnergyShockScenario } from '../../intelligence/v1/compute-energy-shock'; -import { threatLevelToInsurancePremiumBps } from './_insurance-tier'; -import type { ThreatLevel } from './_insurance-tier'; - -function warRiskTierToThreatLevel(tier: string): ThreatLevel { - switch (tier) { - case 'WAR_RISK_TIER_WAR_ZONE': return 'war_zone'; - case 'WAR_RISK_TIER_CRITICAL': return 'critical'; - case 'WAR_RISK_TIER_HIGH': return 'high'; - case 'WAR_RISK_TIER_ELEVATED': return 'elevated'; - default: return 'normal'; - } -} - -function threatLevelToWarRiskTier(tl: ThreatLevel): WarRiskTier { - switch (tl) { - case 'war_zone': return 'WAR_RISK_TIER_WAR_ZONE'; - case 'critical': return 'WAR_RISK_TIER_CRITICAL'; - case 'high': return 'WAR_RISK_TIER_HIGH'; - case 'elevated': return 'WAR_RISK_TIER_ELEVATED'; - default: return 'WAR_RISK_TIER_NORMAL'; - } -} +import { warRiskTierToInsurancePremiumBps } from './_insurance-tier'; +import { COST_SHOCK_KEY, CHOKEPOINT_STATUS_KEY } from '../../../_shared/cache-keys'; export async function getCountryCostShock( ctx: ServerContext, @@ -41,7 +22,7 @@ export async function getCountryCostShock( iso2: req.iso2, chokepointId: req.chokepointId, hs2: req.hs2 || '27', - costIncreasePct: 0, + supplyDeficitPct: 0, coverageDays: 0, warRiskPremiumBps: 0, warRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', @@ -59,20 +40,21 @@ export async function getCountryCostShock( return { ...empty, iso2: iso2 ?? '', chokepointId: chokepointId ?? '' }; } + if (!/^\d{1,2}$/.test(hs2)) { + return { ...empty, iso2: iso2 ?? '', chokepointId: chokepointId ?? '' }; + } + const registry = CHOKEPOINT_REGISTRY.find(c => c.id === chokepointId); - type CpEntry = { id: string; warRiskTier?: string }; - const statusRaw = await getCachedJson('supply_chain:chokepoints:v4').catch(() => null) as { chokepoints?: CpEntry[] } | null; + const statusRaw = await getCachedJson(CHOKEPOINT_STATUS_KEY).catch(() => null) as { chokepoints?: ChokepointInfo[] } | null; const cpStatus = statusRaw?.chokepoints?.find(c => c.id === chokepointId); - const warRiskTierStr = cpStatus?.warRiskTier ?? 'WAR_RISK_TIER_NORMAL'; - const threatLevel = warRiskTierToThreatLevel(warRiskTierStr); - const premiumBps = threatLevelToInsurancePremiumBps(threatLevel); - const warRiskTier = threatLevelToWarRiskTier(threatLevel); + const warRiskTier = (cpStatus?.warRiskTier ?? 'WAR_RISK_TIER_NORMAL') as WarRiskTier; + const premiumBps = warRiskTierToInsurancePremiumBps(warRiskTier); const isEnergy = hs2 === '27'; const hasEnergyModel = isEnergy && (registry?.shockModelSupported ?? false); - let costIncreasePct = 0; + let supplyDeficitPct = 0; let coverageDays = 0; let unavailableReason = ''; @@ -81,19 +63,23 @@ export async function getCountryCostShock( } else if (!hasEnergyModel) { unavailableReason = `Cost shock modelling for ${registry?.displayName ?? chokepointId} is not yet supported. Only Suez, Hormuz, Malacca, and Bab el-Mandeb have energy models in v1.`; } else { - // Call computeEnergyShockScenario directly — it handles its own v2 cache internally. - // Use 100% disruption (full closure scenario) and oil mode for HS 27. - const shock = await computeEnergyShockScenario(ctx, { - countryCode: iso2, - chokepointId, - disruptionPct: 100, - fuelMode: 'oil', - }).catch(() => null); - coverageDays = shock?.effectiveCoverDays ?? 0; - // No 'crude' product entry — compute average deficit across refined products (Gasoline, Diesel, Jet fuel, LPG). - // This represents the fraction of refined product demand unmet under full Hormuz/Suez/Malacca/BEM closure. - const productDeficits = shock?.products?.map((p: { product: string; deficitPct: number }) => p.deficitPct).filter((d: number) => d > 0) ?? []; - costIncreasePct = productDeficits.length > 0 + // Outer cache collapses 3 serial Redis reads → 1 on warm path; cachedFetchJson coalesces concurrent cold misses. + const outerKey = COST_SHOCK_KEY(iso2, chokepointId); + const shock = await cachedFetchJson(outerKey, 300, () => + computeEnergyShockScenario(ctx, { + countryCode: iso2, + chokepointId, + disruptionPct: 100, + fuelMode: 'oil', + }).catch(() => null) + ).catch(() => null); + + coverageDays = Math.max(0, shock?.effectiveCoverDays ?? 0); + // Average deficit across all modelled products (Gasoline, Diesel, Jet fuel, LPG) with demand > 0. + // computeEnergyShockScenario already filters to products with demand; zero-deficit products + // are valid data points (demand exists but disruption causes no shortage) and must stay in the denominator. + const productDeficits = shock?.products?.map((p: { product: string; deficitPct: number }) => p.deficitPct) ?? []; + supplyDeficitPct = productDeficits.length > 0 ? productDeficits.reduce((a: number, b: number) => a + b, 0) / productDeficits.length : 0; } @@ -102,7 +88,7 @@ export async function getCountryCostShock( iso2, chokepointId, hs2, - costIncreasePct: Math.round(costIncreasePct * 10) / 10, + supplyDeficitPct: Math.round(supplyDeficitPct * 10) / 10, coverageDays, warRiskPremiumBps: premiumBps, warRiskTier, diff --git a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts index 73f191440..bc4c8f562 100644 --- a/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts +++ b/src/generated/client/worldmonitor/supply_chain/v1/service_client.ts @@ -181,6 +181,7 @@ export interface GetBypassOptionsResponse { closurePct: number; options: BypassOption[]; fetchedAt: string; + primaryChokepointWarRiskTier: WarRiskTier; } export interface BypassOption { @@ -208,7 +209,7 @@ export interface GetCountryCostShockResponse { iso2: string; chokepointId: string; hs2: string; - costIncreasePct: number; + supplyDeficitPct: number; coverageDays: number; warRiskPremiumBps: number; warRiskTier: WarRiskTier; diff --git a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts index e7fa44e0a..1c2c473bf 100644 --- a/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts +++ b/src/generated/server/worldmonitor/supply_chain/v1/service_server.ts @@ -181,6 +181,7 @@ export interface GetBypassOptionsResponse { closurePct: number; options: BypassOption[]; fetchedAt: string; + primaryChokepointWarRiskTier: WarRiskTier; } export interface BypassOption { @@ -208,7 +209,7 @@ export interface GetCountryCostShockResponse { iso2: string; chokepointId: string; hs2: string; - costIncreasePct: number; + supplyDeficitPct: number; coverageDays: number; warRiskPremiumBps: number; warRiskTier: WarRiskTier; diff --git a/src/services/supply-chain/index.ts b/src/services/supply-chain/index.ts index b8adb7e91..d2a5eedbd 100644 --- a/src/services/supply-chain/index.ts +++ b/src/services/supply-chain/index.ts @@ -1,4 +1,5 @@ import { getRpcBaseUrl } from '@/services/rpc-client'; +import type { CargoType } from '@/config/bypass-corridors'; import { SupplyChainServiceClient, type GetShippingRatesResponse, @@ -120,10 +121,10 @@ export async function fetchCountryChokepointIndex( export async function fetchBypassOptions( chokepointId: string, - cargoType = 'container', + cargoType: CargoType = 'container', closurePct = 100, ): Promise { - const empty: GetBypassOptionsResponse = { chokepointId, cargoType, closurePct, options: [], fetchedAt: '' }; + const empty: GetBypassOptionsResponse = { chokepointId, cargoType, closurePct, options: [], primaryChokepointWarRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', fetchedAt: '' }; try { return await client.getBypassOptions({ chokepointId, cargoType, closurePct }); } catch { @@ -138,7 +139,7 @@ export async function fetchCountryCostShock( ): Promise { const empty: GetCountryCostShockResponse = { iso2, chokepointId, hs2, - costIncreasePct: 0, coverageDays: 0, warRiskPremiumBps: 0, + supplyDeficitPct: 0, coverageDays: 0, warRiskPremiumBps: 0, warRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', hasEnergyModel: false, unavailableReason: '', fetchedAt: '', }; diff --git a/tests/supply-chain-sprint2.test.mjs b/tests/supply-chain-sprint2.test.mjs index 7ea03b4f8..86def5671 100644 --- a/tests/supply-chain-sprint2.test.mjs +++ b/tests/supply-chain-sprint2.test.mjs @@ -180,7 +180,7 @@ describe('get-bypass-options handler source code', () => { }); it('reads chokepoint status cache via getCachedJson', () => { - assert.match(src, /getCachedJson\('supply_chain:chokepoints:v4'\)/); + assert.match(src, /getCachedJson\(CHOKEPOINT_STATUS_KEY\)/); }); it('sorts options by liveScore ascending', () => { @@ -204,12 +204,12 @@ describe('get-country-cost-shock handler source code', () => { assert.match(src, /if \(!isPro\) return empty/); }); - it('uses threatLevelToInsurancePremiumBps for premium calculation', () => { - assert.match(src, /threatLevelToInsurancePremiumBps/); + it('uses warRiskTierToInsurancePremiumBps for premium calculation', () => { + assert.match(src, /warRiskTierToInsurancePremiumBps/); }); it('reads chokepoint status cache via getCachedJson', () => { - assert.match(src, /getCachedJson\('supply_chain:chokepoints:v4'\)/); + assert.match(src, /getCachedJson\(CHOKEPOINT_STATUS_KEY\)/); }); it('returns unavailableReason for non-energy sectors', () => { @@ -229,6 +229,20 @@ describe('get-country-cost-shock handler source code', () => { assert.match(src, /productDeficits/); assert.doesNotMatch(src, /product.*===.*'crude'/); }); + + it('productDeficits must NOT filter before averaging — zero-deficit products must stay in denominator', () => { + assert.ok( + !src.includes('.filter((d: number) => d > 0)') && !src.includes('.filter((d) => d > 0)'), + 'productDeficits must NOT filter before averaging — zero-deficit products must stay in denominator' + ); + }); + + it('coverageDays must clamp negative sentinel for net exporters', () => { + assert.ok( + src.includes('Math.max(0, shock?.effectiveCoverDays'), + 'coverageDays must clamp negative sentinel for net exporters' + ); + }); }); // ======================================================================== diff --git a/tests/supply-chain-v2.test.mjs b/tests/supply-chain-v2.test.mjs index 90d6c96c2..d95c260d0 100644 --- a/tests/supply-chain-v2.test.mjs +++ b/tests/supply-chain-v2.test.mjs @@ -165,6 +165,7 @@ describe('Cache keys bumped to v2', () => { }); it('cache-keys.ts chokepoints key is v4', () => { + // BOOTSTRAP_CACHE_KEYS must keep the raw string literal (bootstrap.test.mjs enforces string-literal values) assert.match(cacheKeysSrc, /chokepoints:\s*'supply_chain:chokepoints:v4'/); }); @@ -172,8 +173,9 @@ describe('Cache keys bumped to v2', () => { assert.match(cacheKeysSrc, /minerals:\s*'supply_chain:minerals:v2'/); }); - it('chokepoint handler uses v4 redis key', () => { - assert.match(chokepointSrc, /REDIS_CACHE_KEY\s*=\s*'supply_chain:chokepoints:v4'/); + it('chokepoint handler uses CHOKEPOINT_STATUS_KEY constant', () => { + // Handler now imports CHOKEPOINT_STATUS_KEY from cache-keys.ts instead of defining a local duplicate + assert.match(chokepointSrc, /CHOKEPOINT_STATUS_KEY\s+as\s+REDIS_CACHE_KEY/); }); it('minerals handler uses v2 redis key', () => {