# Salmon Radar — data schema contract

This document specifies the public JSON files served from
`https://salmonradar.com/data/`. It is the authoritative
contract for analysts and downstream pipelines that consume the
dataset programmatically.

**Schema version: 2.3**

---

## Stability promises

- **Snapshots are immutable.** Files under
  `/data/snapshots/{origin}/{period}/` (schema 2.0+) or
  `/data/snapshots/{period}/` (pre-2.0) are written exactly once when
  that period's pipeline completes and never modified afterwards. The
  bytes served from a snapshot URL today are bit-for-bit the bytes
  that will be served from it next year. A SHA-256 `manifest.json`
  accompanies every snapshot to make this verifiable.
- **The snapshot index is append-only within each origin.** Entries
  are only ever added to `/data/snapshots/index.json`. Past entries
  are not removed, reordered, or rewritten.
- **Field semantics are version-stable.** Within a major schema
  version, a field name will keep the same meaning, unit, and type.
  New fields may be added (minor bump); existing fields will not be
  removed, renamed, or have their meaning changed.
- **Breaking changes bump the major version.** Removing fields,
  changing units, or changing field types is a major change. While
  this is a pre-production hobby project, major bumps ship as a clean
  break without a dual-serve deprecation window — consumers should
  pin to a schema major and re-handshake on every bump.

The live (non-snapshot) files at `/data/{file}.json` are updated
weekly by the GitHub Actions cron. Each refresh fully overwrites the
file; consumers wanting a stable timeseries should poll the snapshot
index and pull from `/data/snapshots/{period}/` instead.

---

## Versioning

Every JSON envelope carries an optional `schemaVersion: string` field.
Format: `MAJOR.MINOR` (e.g. `"1.0"`).

- **MINOR** — additive changes (new optional fields, new files,
  new enum values that consumers can safely ignore).
- **MAJOR** — breaking changes (field removal, rename, type change,
  enum value removal, unit change). Bumps happen alongside a 12-week
  deprecation window during which both majors are served in parallel.

Consumers should compare against `MAJOR` and accept any `MINOR`. A
sample guard:

```ts
const file = await (await fetch(url)).json()
const [major] = (file.schemaVersion ?? '1.0').split('.')
if (major !== '1') throw new Error('Unsupported salmon-radar schema major')
```

---

## CORS and caching

- All `/data/*` JSON and CSV responses carry
  `Access-Control-Allow-Origin: *`, so browser-side fetches work
  without a proxy.
- `Cache-Control: public, max-age=0, must-revalidate` is set, with
  ETags populated. Use conditional GET via `If-None-Match` to avoid
  re-downloading unchanged files.
- The full pipeline runs Wednesday ~08:30 UTC. There is no documented
  rate limit at this time; please poll the snapshot index hourly at
  most.

---

## File index

| URL | Purpose |
|---|---|
| `/data/latest.json` | Top-level AI/agent entry point: curated digest of the NO headline (NOK + USD price, volume, deltas, coverage window) + named sources, downloads, and docs link (2.3) |
| `/data/{origin}/latest.json` | KPI totals + WoW deltas + trailing-52w z-scores for the latest period |
| `/data/{origin}/history.json` | 260-period weekly (or monthly) volume + price series with rolling z-scores |
| `/data/{origin}/flows.json` | Top-10 destinations in latest available month with MoM deltas |
| `/data/{origin}/destinations-all.json` | Every partner country in latest available month |
| `/data/{origin}/country-history.json` | Annual per-partner volume/price series (back to ~2014) |
| `/data/{origin}/concentration.json` | Annual Herfindahl-Hirschman index for export destinations |
| `/data/{origin}/movers.json` | Movers tiles (5y breakout + MoM extremes) |
| `/data/{origin}/weekly-brief.json` | Pre-rendered headline numbers + lead sentence for the latest week (NO only) |
| `/data/{origin}/insights.json` | Mechanically derived narrative facts for the latest period |
| `/data/{origin}/metadata.json` | Pipeline-run timestamp + source labels + FX snapshot + cadence + `pendingData` flag + `freshness` provenance block (2.1) |
| `/data/{origin}/stocks.json` | Daily prices for the origin's listed salmon-equities basket, basket index, 52w rolling correlation with spot $/kg |
| `/data/events.json` | Hand-curated policy / biological / FX / trade event markers (cross-origin; entries carry `origin: 'NO' \| 'CL' \| 'global'`) |
| `/data/competitors.json` | UN Comtrade monthly volumes for Norway, Chile, UK, Faroe (cross-producer; HS 030214 + 030314) |
| `/data/snapshots/index.json` | Per-origin map of archived snapshots |
| `/data/snapshots/{origin}/{period}/...` | Immutable copy of that origin's files as published that period |
| `/data/snapshots/{origin}/{period}/manifest.json` | SHA-256 hash of every file in this snapshot |

`{origin}` is an ISO-3166-1 alpha-2 code; supported values are `NO`
(weekly, SSB) and `CL` (monthly, UN Comtrade). CSV companions live
beside their JSON files at `/data/{origin}/{name}.csv`.

---

## TypeScript interfaces

The canonical types live in `src/data/types.ts` in the repository.
They are reproduced here for offline reference; the in-repo file is
authoritative if the two ever drift.

```ts
type PeriodType = 'week' | 'month'
type CurrencyCode = 'USD' | 'NOK' | 'EUR' | 'JPY'
type EventCategory = 'policy' | 'biological' | 'fx' | 'trade'
type ConcentrationLevel = 'unconcentrated' | 'moderate' | 'high'

interface SchemaVersioned {
  schemaVersion?: string  // "1.0"
}

interface KpiDelta {
  changePercent: number  // signed, vs previous period
  direction: 'up' | 'down' | 'flat'
}

// /data/latest.json
interface LatestFile extends SchemaVersioned {
  period: string         // ISO week, e.g. "2026-W19"
  updatedAt: string      // ISO 8601 timestamp
  // For weekly origins (NO) these are the fresh/chilled benchmark slice
  // from 2.2 onward (HS 0302.14). volume/value/price are internally
  // consistent (price = value / volume) for that grade.
  totals: {
    volumeKg: number
    volumeTons: number
    valueUsd: number
    avgPriceUsdPerKg: number      // period-matched USD (2.3+): NOK ÷ fxRateUsd
    avgPriceNokPerKg?: number     // 2.3+ (NO): canonical SSB NOK/kg
    fxRateUsd?: number            // 2.3+ (NO): week's avg NOK/USD used to convert
    freshShare: number   // 0..1 — fresh/chilled share of ALL salmon (fresh+frozen)
  }
  deltas: {
    volume: KpiDelta              // FX-free (physical volume)
    value: KpiDelta               // 2.3+ (NO): computed on NOK value (FX-independent)
    price: KpiDelta               // 2.3+ (NO): computed on NOK price (FX-independent)
    freshShare: KpiDelta
  }
  zScores: {
    price: number | null   // trailing-52w; null until window warm
    volume: number | null
  }
}

// /data/history.json
interface HistoryPoint {
  period: string
  volumeKg: number
  volumeTons: number
  valueUsd: number
  avgPriceUsdPerKg: number       // period-matched USD (2.3+): NOK ÷ week's FX
  avgPriceNokPerKg?: number      // 2.3+ (NO): canonical SSB NOK/kg (also a history.csv column)
  priceZScore: number | null
  volumeZScore: number | null
}

interface HistoryFile extends SchemaVersioned {
  product: string         // human-readable product label
  origin: string          // ISO-2 country code, currently always "NO"
  points: HistoryPoint[]  // 260 weekly points, ascending — canonical headline
                          // series = fresh/chilled (2.2+); pre-2.2 = combined
  // Secondary slices keyed by product. 2.2+ (NO): { combined, frozen }.
  // The key matching `points` is omitted; consumers fall back to `points`.
  productSplit?: Partial<Record<'combined' | 'fresh' | 'frozen', HistoryPoint[]>>
}

// /data/snapshots/index.json
interface SnapshotIndexEntry {
  period: string         // "YYYY-Www"
  generatedAt: string    // ISO 8601 — when this snapshot's pipeline ran
  path: string           // relative, e.g. "snapshots/2026-W19/"
}

interface SnapshotIndexFile extends SchemaVersioned {
  updatedAt: string
  snapshots: SnapshotIndexEntry[]  // ascending by period
}

// /data/snapshots/{period}/manifest.json
interface SnapshotManifest {
  schemaVersion: string
  period: string
  generatedAt: string
  files: Array<{
    file: string         // basename, e.g. "latest.json"
    bytes: number
    sha256: string       // hex
  }>
}
```

For the remaining file shapes (`FlowsFile`, `DestinationsAllFile`,
`CountryHistoryFile`, `ConcentrationFile`, `MoversFile`,
`WeeklyBriefFile`, `EventsFile`, `CompetitorsFile`, `InsightsFile`,
`MetadataFile`) see `src/data/types.ts` in the repository — each is
exported with full JSDoc field annotations.

---

## Polling pattern

For consumers building a downstream timeseries archive against
Salmon Radar, the recommended pattern is:

```ts
// 1. Pull the index
const index = await fetch('https://salmonradar.com/data/snapshots/index.json', {
  headers: lastEtag ? { 'If-None-Match': lastEtag } : {},
}).then((r) => (r.status === 304 ? null : r.json()))

if (!index) return  // nothing new

// 2. Diff against your last-seen period set
const seen = new Set(await loadSeenPeriodsFromYourDb())
const fresh = index.snapshots.filter((s) => !seen.has(s.period))

// 3. For each fresh period, fetch the snapshot's files
for (const s of fresh) {
  const base = `https://salmonradar.com/data/${s.path}`
  const manifest = await fetch(`${base}manifest.json`).then((r) => r.json())
  for (const f of manifest.files) {
    const buf = await fetch(`${base}${f.file}`).then((r) => r.arrayBuffer())
    verifySha256(buf, f.sha256)
    await persistToYourDb(s.period, f.file, buf)
  }
}
```

The snapshot URLs themselves are guaranteed immutable, so once a
period is in your archive you never need to re-fetch it.

---

## Changelog

- **2.3** — Number integrity (Norway weekly). Weekly USD is now **period-matched**:
  each ISO week's SSB NOK/kg is converted at that same week's average Norges Bank
  NOK/USD rate, not a single current spot rate applied to all 260 weeks — so
  historical USD prices and re-baked snapshots are reproducible (archive == live).
  Headline **price and value % deltas are computed on the canonical NOK series**
  (FX-independent), removing FX-driven drift and the prior double-rounding between
  the headline and the recent-weeks table. Additive fields: `latest.json` totals
  gain `avgPriceNokPerKg` + `fxRateUsd`; `weekly-brief.json` gains `priceNokPerKg`
  + `fxRateUsd`; `history.json` points gain `avgPriceNokPerKg` (plus an
  `avgPriceNokPerKg` column in `history.csv`). All additive — pre-2.3 consumers
  ignore the new fields. Monthly/annual per-country USD remains spot-converted.

- **2.2** — Benchmark scope (Norway). The headline is now the **fresh/chilled**
  salmon slice only (SSB commodity group "fresh or chilled", HS 0302.14, ~97%
  of weekly volume) instead of the fresh+frozen volume-weighted blend.
  `latest.json` totals/deltas, `weekly-brief.json`, and `history.points` all
  carry the fresh slice. `history.productSplit` changes from `{ fresh, frozen }`
  to `{ combined, frozen }` — the fresh series now lives in `points`, and the
  fresh+frozen combined total and frozen-only series remain available as
  secondary slices. Back-compatible for the product picker: a reader requesting
  the slice that equals `points` finds the key absent and falls back to `points`.
- **2.1** — Additive (F5 / freshness & provenance). `metadata.json` gains
  an optional `freshness` block on weekly origins (`NO`), carrying distinct
  provenance timestamps so consumers can tell *what the data covers* apart
  from *when the source released it* and *when the page was generated*:

  ```json
  "freshness": {
    "coverageStart": "2026-05-18",
    "coverageEnd": "2026-05-24",
    "sourceUpdated": "2026-05-28T06:00:00Z",
    "nextReleaseExpected": "2026-06-04T06:00:00.000Z"
  }
  ```

  `coverageStart`/`coverageEnd` are the inclusive Mon→Sun calendar window of
  the latest ISO week. `sourceUpdated` is the real `updated` timestamp from
  SSB's JSON-stat response (null on backfilled snapshots, where the live
  fetch time wouldn't describe a past week). `nextReleaseExpected` is
  `sourceUpdated` + one weekly cadence — an estimate, not read from an
  official release calendar; null when `sourceUpdated` is null. Fully
  backward-compatible: consumers that ignore the field are unaffected.

- **2.0** — Breaking (F2 / origin-aware pipeline). Every origin-scoped
  file moves from `/data/{name}.json` to `/data/{origin}/{name}.json`.
  Supported origins at 2.0: `NO` (weekly), `CL` (monthly). Cross-origin
  files (`events.json`, `competitors.json`) stay at `/data/` root.
  `metadata.json` envelope gains required `origin` and `cadence` fields,
  plus an optional `pendingData: true` flag set when the origin's data
  pipeline has not yet produced real dashboard files (only the metadata
  stub is published). The snapshot index at `/data/snapshots/index.json`
  changes shape from `{ updatedAt, snapshots: [...] }` to
  `{ updatedAt, origins: { [iso]: { cadence, latestPeriod, snapshots: [...] } } }`,
  and snapshot directories become `/data/snapshots/{origin}/{period}/`.
  `snapshots/index.json` is written atomically (temp-then-rename) so
  concurrent readers never observe a half-written file. This is a
  clean break — no legacy aliases are served. Consumers pinned to
  `1.x` must re-handshake and update fetch paths.

  `metadata.json` also gains an optional `reconciliation` block on
  origins that have an industry-association reference source wired up
  (currently only `CL`, comparing against SalmonChile's monthly
  bulletins):
  ```jsonc
  "reconciliation": {
    "passed": true | false | null,   // null when no reference for the period
    "ourTons": 41654,                 // our Comtrade-derived monthly gross tons
    "referenceTons": 41000,           // industry-association figure, or null
    "deltaPct": 1.59,                 // signed % delta vs reference, or null
    "thresholdPct": 5,                // |deltaPct| > this flips `passed` false
    "source": "https://…"             // primary-source URL, or null
  }
  ```
  Reference figures live in `scripts/lib/salmonchile-reference.json`
  and are maintained manually until a future scraper bundle.
- **1.4** — Additive (F0 / hub overlay + Chile events corpus).
  `flows.json` and `destinations-all.json` each gain two optional
  fields on every row:
  - `reexportSharePct: number | null` — approximate share of inbound
    salmon that is re-exported onwards, per a static hub lookup. Null
    when no lookup entry exists.
  - `transitLikely: boolean` — true when the destination is a known
    logistics hub (Poland, Netherlands, Lithuania, Denmark on the
    Norway side; Brazil, U.S., Panama on the Chile side once F2
    lands).
  `events.json` gains an optional `origin: 'NO' | 'CL' | 'global'`
  field on every entry; pre-1.4 entries without the field are
  treated as Norway-implicit by the frontend. The corpus also gains
  6 Chile-tagged biological/policy entries (ISA 2007, ISA collapse
  2009, court-ordered antibiotic disclosure 2016, Marine Harvest
  Punta Redonda escape 2018, Heterosigma akashiwo bloom 2021,
  Sernapesca 2024 antimicrobial-use bulletin). The `destinations-all.csv`
  companion gains matching `reexportSharePct` + `transitLikely`
  columns. Backward-compatible: readers that ignore the new fields
  still see a valid file.
- **1.3** — Additive. New file `/data/NO/stocks.json` (also served as
  `/data/stocks.json` until the F-bundle namespacing pass). Carries
  the Norway-listed salmon-equities basket (5 tickers) at daily
  cadence: per-ticker latest close + day/week/YTD deltas, an
  equal-weighted basket index rebased to 100 on the first common
  trading day, and a trailing 52w rolling Pearson correlation between
  basket weekly returns and spot $/kg weekly returns. Listing
  currency is NOK; the frontend converts via the existing Norges Bank
  FX snapshot in `metadata.json`. See `StocksFile` in `src/data/types.ts`
  for the full shape. No other files change.
- **1.2** — Additive. `flows.json` gains optional
  `longTail: Flow[]` carrying ranks 11..N destinations that have map
  coordinates. Same `Flow` shape as `flows`. The dashboard map renders
  these as dimmer/thinner arcs so smaller partners (Israel, Vietnam,
  Korea, Thailand, …) are visible without crowding out the headline
  top-10. Consumers that only read `flows` are unaffected.
- **1.1** — Additive. `history.json` gains optional
  `productSplit: { fresh: HistoryPoint[]; frozen: HistoryPoint[] }`
  aligned by period to `points`. Each `CompetitorReporter` gains
  optional `productSplit: { fresh; frozen; combined }`, and
  `competitors.points` is now the combined fresh+frozen sum (HS
  030214 + 030314) — pre-1.1 it was fresh-only. `competitors.cmdCode`
  becomes the comma-joined list `"030214,030314"`. The 1.1 shape is
  fully backward-compatible: readers that only know about `points`
  still see a valid timeseries, just without the fresh/frozen split.
- **1.0** — Initial public schema. Adds `schemaVersion` field to all
  envelopes, per-snapshot `manifest.json` with SHA-256 hashes, and
  the documented stability promises above.
