Files
worldmonitor/docs/api/ResilienceService.openapi.json
Elie Habib 39d5199ae0 feat(resilience): three-pillar schema + schemaVersion v2.0 feature flag (Phase 2 T2.1) (#2977)
* feat(resilience): three-pillar schema + schemaVersion v2.0 feature flag (Phase 2 T2.1)

Ships the Phase 2 T2.1 schema slice of the country-resilience reference
grade upgrade plan. Adds the three-pillar response shape
(StructuralReadiness, LiveShockExposure, RecoveryCapacity) as a new
`pillars` field on GetResilienceScoreResponse alongside a `schemaVersion`
string field, both gated behind the RESILIENCE_SCHEMA_V2_ENABLED env
flag (default false).

This PR is schema + plumbing only. Pillars ship with score=0,
coverage=0; real aggregation lands in PR 4 (T2.3). No behavior change
at the v1 default, which preserves widget / map / Country Brief
compatibility for one release cycle per the plan.

What this PR commits

- Proto: new ResiliencePillar message (id, score, weight, coverage,
  domains). New `pillars` repeated field + `schema_version` string
  field on GetResilienceScoreResponse. No renumbering or mutation of
  existing fields.
- Generated TS: regenerated service_server.ts, service_client.ts, and
  OpenAPI JSON/YAML via `make generate`.
- New module server/worldmonitor/resilience/v1/_pillar-membership.ts:
  declarative PILLAR_DOMAINS map + PILLAR_WEIGHTS map + ordered
  iteration list. Single source of truth for the pillar structure that
  PR 4 will import. Note: pillar membership uses the runtime
  ResilienceDomainId values (kebab-case domain ids that already ship in
  v1), not the long-form pillar names from the plan example.
  - structural-readiness (0.40): economic, infrastructure, social-governance
  - live-shock-exposure  (0.35): energy, health-food
  - recovery-capacity    (0.25): empty until PR 3 adds the new dimensions
- Response builder: new buildPillarList helper emits shaped-but-empty
  pillars when the v2 flag is on, empty array when off. Response
  literal fallback paths in _shared.ts and the LOCKED_PREVIEW fixture
  in resilience-widget-utils.ts updated to include pillars: [] and
  schemaVersion: '1.0' to satisfy the generated TS types.
- Tests: 13 new pillar-schema unit cases (membership invariants,
  weight sum=1.0, disjoint sets, empty recovery pillar, buildPillarList
  flag-off / flag-on / shuffled-order / partial-domain-set) + 3
  response-shape cases on the release-gate test pinning the v1 default
  shape and the new field presence on the wire.

What is deliberately NOT in this PR

- No aggregation logic: score/coverage on pillars stay 0 until PR 4.
- No cache key bump: schema is additive with proto3 defaults.
- No changes to overallScore/baselineScore/stressScore (parallel for
  one release cycle).
- No new seeders or dimensions (PR 3 / T2.2b).
- No tiering registry changes (PR 2 / T2.2a).
- No widget rendering (Phase 3 T3.6).

Verified

- make generate clean, new ResiliencePillar interface in regenerated
  client + server + OpenAPI artifacts
- typecheck + typecheck:api clean
- tests/resilience-pillar-schema.test.mts: 13/13 passing
- tests/resilience-release-gate.test.mts: 14/14 passing (3 new T2.1
  cases + 11 prior)
- full resilience suite: 283/283 passing
- npm run test:data: 4539/4539 passing
- npm run lint: exit 0

* fix(resilience): cache-flag decoupling + freshness error-status guard (#2977 P1+P2)

Two Greptile review findings addressed:

P1: RESILIENCE_SCHEMA_V2_ENABLED changed the cached response shape
but the cache key did not encode the flag state. Flipping the flag
on a warm cache served stale v1.0 payloads until the 6h TTL expired.

Fix: always compute and cache the v2 superset (with pillars and
schemaVersion='2.0'). Apply the flag as a response-time gate: when
off, strip pillars to [] and downgrade schemaVersion to '1.0' before
returning. This decouples the cache from the flag and makes flag
flips take effect immediately without waiting for TTL expiry.

P2: readFreshnessMap in _dimension-freshness.ts trusted fetchedAt
without checking status. The resilience-static seeder writes
fetchedAt: Date.now() on BOTH success and error paths (status: 'ok'
vs 'error'), so a failed seed run that preserved old data via
extendExistingTtl made the freshness badges show 'fresh' for what
is actually stale data.

Fix: skip seed-meta entries where status !== 'ok'. When the meta
is skipped, the dimension has no freshness data and classifies as
stale, matching api/health.js behavior. Added a test case that
verifies error-status entries are excluded from the freshness map.
2026-04-12 09:51:54 +04:00

1 line
6.7 KiB
JSON

{"components":{"schemas":{"DimensionFreshness":{"properties":{"lastObservedAtMs":{"description":"Unix milliseconds when the oldest constituent signal in this\n dimension was last observed (min fetchedAt across INDICATOR_REGISTRY\n entries for this dimension). 0 when no signal has ever been\n observed.","format":"int64","type":"string"},"staleness":{"description":"Worst staleness level across the dimension's constituent signals,\n classified by classifyStaleness against each signal's cadence.\n One of: \"fresh\", \"aging\", \"stale\". Empty string when no signals.","type":"string"}},"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"},"GetResilienceRankingRequest":{"type":"object"},"GetResilienceRankingResponse":{"properties":{"greyedOut":{"items":{"$ref":"#/components/schemas/ResilienceRankingItem"},"type":"array"},"items":{"items":{"$ref":"#/components/schemas/ResilienceRankingItem"},"type":"array"}},"type":"object"},"GetResilienceScoreRequest":{"properties":{"countryCode":{"type":"string"}},"type":"object"},"GetResilienceScoreResponse":{"properties":{"baselineScore":{"format":"double","type":"number"},"change30d":{"format":"double","type":"number"},"countryCode":{"type":"string"},"dataVersion":{"type":"string"},"domains":{"items":{"$ref":"#/components/schemas/ResilienceDomain"},"type":"array"},"imputationShare":{"format":"double","type":"number"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallScore":{"format":"double","type":"number"},"pillars":{"items":{"$ref":"#/components/schemas/ResiliencePillar"},"type":"array"},"schemaVersion":{"description":"Phase 2 T2.1: \"1.0\" (default, preserves the current response shape)\n or \"2.0\" (adds pillars; keeps overall_score / baseline_score / etc.\n populated for one release cycle for backward compat). Controlled at\n response build time by the RESILIENCE_SCHEMA_V2_ENABLED env flag.","type":"string"},"scoreInterval":{"$ref":"#/components/schemas/ScoreInterval"},"stressFactor":{"format":"double","type":"number"},"stressScore":{"format":"double","type":"number"},"trend":{"type":"string"}},"type":"object"},"ResilienceDimension":{"properties":{"coverage":{"format":"double","type":"number"},"freshness":{"$ref":"#/components/schemas/DimensionFreshness"},"id":{"type":"string"},"imputationClass":{"description":"Four-class imputation taxonomy (Phase 1 T1.7). Empty string when the\n dimension has any observed data. One of: \"stable-absence\", \"unmonitored\",\n \"source-failure\", \"not-applicable\". See docs/methodology/country-resilience-index.mdx.","type":"string"},"imputedWeight":{"format":"double","type":"number"},"observedWeight":{"format":"double","type":"number"},"score":{"format":"double","type":"number"}},"type":"object"},"ResilienceDomain":{"properties":{"dimensions":{"items":{"$ref":"#/components/schemas/ResilienceDimension"},"type":"array"},"id":{"type":"string"},"score":{"format":"double","type":"number"},"weight":{"format":"double","type":"number"}},"type":"object"},"ResiliencePillar":{"description":"Phase 2 T2.1 of the country-resilience reference-grade upgrade plan.\n Three-pillar response shape that regroups the existing 5 domains into\n long-run capacity, current shock pressure, and recovery capability.\n Shipped as a shaped-but-empty payload in T2.1 (score=0, coverage=0);\n real aggregation lands in T2.3 / PR 4 of the Phase 2 rebuild.","properties":{"coverage":{"description":"Coverage in [0, 1]. 0 when shipped empty (T2.1).","format":"double","type":"number"},"domains":{"items":{"$ref":"#/components/schemas/ResilienceDomain"},"type":"array"},"id":{"description":"\"structural-readiness\" | \"live-shock-exposure\" | \"recovery-capacity\".","type":"string"},"score":{"description":"Pillar score in [0, 100]. 0 when shipped empty (T2.1).","format":"double","type":"number"},"weight":{"description":"Pillar weight in the overall combine. Per the plan: 0.40 / 0.35 / 0.25.","format":"double","type":"number"}},"type":"object"},"ResilienceRankingItem":{"properties":{"countryCode":{"type":"string"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallCoverage":{"format":"double","type":"number"},"overallScore":{"format":"double","type":"number"},"rankStable":{"type":"boolean"}},"type":"object"},"ScoreInterval":{"properties":{"p05":{"format":"double","type":"number"},"p95":{"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":"ResilienceService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/resilience/v1/get-resilience-ranking":{"get":{"operationId":"GetResilienceRanking","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceRankingResponse"}}},"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":"GetResilienceRanking","tags":["ResilienceService"]}},"/api/resilience/v1/get-resilience-score":{"get":{"operationId":"GetResilienceScore","parameters":[{"in":"query","name":"countryCode","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceScoreResponse"}}},"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":"GetResilienceScore","tags":["ResilienceService"]}}}}