# Changelog

All notable changes to the public API and MCP server. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions are the
`api` crate's, which is also `info.version` in `/openapi.json`.

## [0.7.0] - unreleased

Credits: every call is priced from one published table, checked and reserved before
it runs, and charged the same on every surface.

### Breaking

- `request_units` is `credits_charged`, and `x-request-units` is
  `x-credits-charged`.
- `x-quota-limit` / `-used` / `-remaining` / `-reset` are `x-credits-limit` /
  `-used` / `-remaining` / `-reset`.
- `x-ratelimit-limit` / `-remaining` / `-reset` are `x-credits-per-minute` /
  `-per-minute-remaining` / `-per-minute-reset`, and count credits.
- Error code `quota_exceeded` is `insufficient_credits`, and `rate_limited` is
  `credit_rate_limited`, or `message_rate_limited` for free calls: MCP messages,
  previews, `/account`, and requests refused before they ran. Refusal bodies carry
  the call's `credits` range and the figures that refused it.
- `/account` and `account_status` report `credits_limit`, `credits_used`,
  `credits_remaining`, `credits_per_minute`, `credits_reset_secs` and
  `credits_charged` (0); `account_status` adds `credits_this_session`. The control
  API's tenant adds `calls_this_period`.
- Plans report `monthly_credits`, `credits_per_minute` and
  `overage_cents_per_1k_credits`; `/v1/plans` adds `credit_costs`.
- The control API's usage reports `credits` and `calls` per day and per operation.
- The Stripe billing meter is `credits`.
- Watch status `paused_quota` is `paused_credits`.
- `/stories` refuses an `offset` above 1,000.

### Added

- `estimate=true` on every endpoint and tool returns the call's credit range, a
  breakdown and whether it would run, without running it, for 0 credits.
- `max_credits` on every endpoint and tool refuses a call whose worst case costs
  more (`credit_budget_exceeded`, 400), at no cost.
- Every tool description and every endpoint in `/openapi.json` ends with its price;
  `/docs/credits.md` has the table.
- Webhook delivery status `held_credits`: a hard-capped key that runs out holds its
  pending deliveries, retried hourly, until credits return; a held event that
  reaches its seven-day retention fails.
- A create preview (`POST /watches?estimate=true`, `create_watch` with `estimate`)
  adds `per_run`, `runs_per_month` and `webhook_attempts_per_event_max`.
- Query analytics events record `credits`, and the rollups carry a `credits`
  column (rollup format 2).

### Changed

- A hard-capped key is refused before a call whose worst case would not fit, rather
  than overshooting its allocation.
- The per-minute limit counts credits. A call larger than a minute's credits waits
  for a full minute instead of being refused.
- Semantic ranking costs 2, hybrid 3, `find_related` 2. Offsets cost 1 per full
  1,000 rows on `/search` and `/top-headlines` and 1 per full 100 on `/stories`.
- A watch run costs the credits its query takes, not 1. Every webhook delivery
  attempt costs 1, retries included.
- A story expansion costs 2 on every surface; the gateway charged 1 over REST. The
  gateway's percentile pass costs 1, and a network comparison a flat 3.
- `account_status`, `/account`, MCP `initialize` and `tools/list` are free, and
  take a message token instead. A suspended key's refusal takes one too.
- What a call is charged, by outcome: a 2xx what it took, held inside its
  estimate; a 404 the operation's minimum; a refusal, an error or a call stopped at
  the request timeout (408), nothing.
- `search_news` over MCP defaults to keyword ranking on every host.

## [0.6.0] - unreleased

Geographic search, event classes and numeric amounts, from fields derived out of
records every GKG document already carries. They reach a shard when its splits
are next rebuilt; until then a filter that needs them says so.

### Added

- `near=latitude,longitude` with `radius_km` (0.1-2000), or
  `bbox=south,west,north,east`, on `/search`, `/stories`, `/top-headlines` and
  their tools: articles mentioning a city in the area. The index narrows to the
  geohash cells covering it and every mentioned city is then checked exactly.
  Hits carry `matched_locations` (name, country, coordinates and, for `near`,
  `distance_km`), nearest first. Cities only: region and country coordinates
  are centroids. A box whose west is greater than its east crosses the
  antimeridian; an area needing more than 256 cells is refused.
- `event_type` on the same endpoints and `/aggregate`, a facet of the same
  name, and `event_types` on every article: seventeen classes (`events-v1`)
  derived from GKG themes, among them `bankruptcy`, `earnings_report`, `ipo`,
  `cyber_attack`, `outage`, `executive_change`, `labor_strike` and
  `supply_shortage`. Each class names the themes it needs, checked against
  GDELT's theme vocabulary; where a theme alone is ambiguous a second theme is
  required (an appointment is an `executive_change` only with a chief
  executive). Acquisitions, funding rounds, layoffs, lawsuits, contract awards,
  product launches and factory openings have no reliable theme and are not
  classes.
- `amount_min` and `amount_max`: articles mentioning a numeric amount within
  the bounds. One amount must satisfy every bound, and with `amount_object` that
  amount must describe the object. `amounts[].number` on `/doc/{id}` is the
  value compared.
- `coverage: field_unavailable`: a filter needs a field part of the index was
  built without. The answer is `partial` and covers only the rebuilt part; the
  gateway keeps it through the merge.

### Changed

- A geographic or amount filter on a keyword or browse query reads candidates
  in batches of 500 (up to 2,000) and checks each one; `request_units` counts
  the batches. When the candidates outnumber the scan, `total` is estimated and
  `total_relation` is `approximate`, and facets, which count candidates, are
  marked approximate.

### Fixed

- `x-request-units` is documented among the response headers in
  `/openapi.json`; it was sent but not listed.
- A graph edge's `share` is measured from the entity the edge is seen from:
  the starting entity, or the end entity on a path's second hop, which used to
  divide its co-mentions by themselves. A one-hop `connection_path` counts the
  starting entity, so its direct edge's `share` is no longer 0.
- A graph edge's `type` lists the seven entity fields neighbours come from, not
  all nine.
- `account_status` costs one request unit, as every MCP request does; its
  description called it free.
- A geographic or amount filter serves matches only from the candidates it
  checked, and `has_more` is false once those run out instead of promising a
  page it cannot serve. `cursor` cannot be combined with these filters.
- The gateway keeps a shard's `approximate` total, so a scan that ran out of
  candidates is not reported as `exact`.
- A filter on a derived field costs one extra request unit wherever the check
  for the field runs: `/stories`, semantic and hybrid `/search`, `/aggregate`.
- `amount_min` and `amount_max` refuse negative values, as documented.

## [0.5.0] - unreleased

Relationships answered at query time, entity ids as filters, and calls metered by
the index queries they take.

### Added

- `entity_id` on `/search`, `/stories`, `/top-headlines`, `/aggregate` and their
  tools: up to five ids from `/entities` (comma separated, or a repeated
  parameter over REST). Each matches every spelling in its alias group within
  the request window, and several ids must all match. The response's
  `resolved` lists the spellings each id used; an id with none in the window
  matches nothing and says so with an empty `aliases_used`.
- `GET /graph/related` and `related_entities`: the organizations, people,
  places and topics co-mentioned with one entity, strongest first, each with
  `co_mentions`, `share`, and for the strongest (`evidence`, default 3) the
  newest shared articles with distinct `sources`, `stories`, `first_seen` and
  `last_seen`.
- `GET /graph/path` and `connection_path`: the direct co-mentions of two
  entities and up to five paths through one shared neighbour (`max_hops` 1 or
  2).
- `GET /graph/network` and `entity_network`: neighbours grouped by type, and
  with `compare_from`/`compare_to` the neighbours that are new or gone and the
  ties whose share grew by half or fell by a third.
- `GET /graph/exposures` and `shared_exposures`: what up to five entities have
  in common, as shared articles, stories, sources, the requested facets and
  signal statistics (`signal_stats`), with the newest shared articles as
  evidence.
- Every graph answer carries `note` (co-mention in reporting is association,
  not ownership, partnership, causation or exposure), `indexed_through`,
  `entity_resolution_version`, `request_units`, `partial` and `coverage`. The
  corpus can be scoped by `language`, `publisher_country`, `source_type` and
  `country`. Bounds: two hops, 50 neighbours (20 per type on a network), 10
  evidence articles and 16 index queries per index.
- `request_units` in search, story, entity, aggregate and graph responses, and
  the `x-request-units` header on every authenticated REST response. A call is
  charged the index queries it issued: a search costs 1; resolving an
  `entity_id`, a `signal_percentile_min` threshold, any cursor page, a story
  expansion and every graph operation cost more and say so. A document lookup
  costs 1 and reports it in the header only.
  Across indexes a call is charged what the most expensive index needed, and a
  network comparison both of its passes.
- `get_watch`, the MCP tool for `GET /watches/{id}`.
- The webhook body is published as the `WatchWebhook` schema under `webhooks`
  in `/openapi.json`; watch events carry `recorded_at`.

### Changed

- A watch's label is `label`. In 0.4.0 it was `name`, which is also the
  proper-name filter a watch's query accepts, so a labelled watch saved its
  label as a filter and matched nothing. `name` in a watch is now that filter.
- Watch event ids begin with when the run that recorded them was due, so the
  feed read newest first is the order events were recorded in. In 0.4.0 they
  began with the story's newest article time, which let a late-indexed story's
  event sort below events an agent had already read.
- Webhook bodies omit `previous_outlets` and `current_outlets` on `new_story`
  rather than sending `null`.
- Watch routes document `500` (the watch store failed) instead of `503`, which
  they cannot return.

### Fixed

- `paused_quota` is described as the code behaves: only a key that stops at its
  quota pauses its watches; a key that bills overage keeps running.

## [0.4.0] - unreleased

Watches: the index runs a story query on an interval and records what changed,
so a client stops polling.

### Added

- `POST /watches`, `GET /watches`, `GET /watches/{id}`, `DELETE /watches/{id}`,
  `GET /watches/{id}/events`, and the `create_watch`, `list_watches`,
  `delete_watch` and `watch_events` tools. A watch is a `/stories` query (the
  filters, `q`, `mode` and signals it may use), `interval_minutes`,
  `minimum_outlets` (default 2), `notify_on` (`new_story`, `story_growth`) and
  an optional `webhook_url`.
- `new_story` fires when a story first reaches `minimum_outlets`;
  `story_growth` when it crosses 5, 15, 40 or 100 outlets, or grows by 10 since
  its last event. Events are kept seven days and page with `after`.
- Webhooks: https to public hosts only (loopback, private, link-local,
  carrier-grade NAT, documentation and IPv4-mapped addresses are refused, at
  creation and at every delivery, and the connection is pinned to the checked
  address). Each event is POSTed as JSON with `x-unzoi-event`,
  `x-unzoi-delivery` and `x-unzoi-signature: sha256=<HMAC-SHA256 of the body>`
  under the `webhook_secret` returned once at creation; failed deliveries are
  retried after 1, 5 and 25 minutes.
- Each run is charged as one request to the key that created the watch. A run
  whose answer is `partial` records what it saw and asks the same window again.
  A revoked or suspended key pauses its watches (`paused_key`); a spent
  allowance pauses them hourly (`paused_quota`).
- Plans cap watches and their interval: free 2 at 60 minutes, build 25 at 15,
  scale 200 at 5, archive 1,000 at 1 (`plans.json` carries `watches` and
  `min_watch_interval_minutes`). Over the cap is `400` with `code: watch_limit`.
- Error codes `watch_limit` and `unsupported` (a watch tool on a server without
  a watch store, such as a shard or the stdio binary).

## [0.3.0] - unreleased

Query-time capabilities that need no re-indexing: a time series, ordering and
origin for stories, keyset paging, and relative signal thresholds.

### Added

- `GET /aggregate` and the `aggregate_news` tool: per calendar bucket (`hour`,
  `day`, `week` from Monday, `month`, or `all`), the article count and
  optionally distinct `stories`, distinct `outlets` and a signal's mean and
  percentiles (`metrics=stories,outlets,signal:finance`, `percentile=90`).
  `interval` is required; `from` comes from the plan window when omitted; at
  most 366 buckets. Distinct counts summed across indexes are marked
  `approximate`.
- `sort=count|outlets|recency` on `/stories` and `list_stories`; each story
  carries `published_at` of its representative article.
- `GET /stories/{id}` reports `first_reported` (the earliest member in the
  index), `title_variants` (distinct headlines among the listed articles) and
  `sources_by_first_seen`. These are proxies for origin; nothing in the index
  marks wire copy.
- `cursor` on `/search`, `/top-headlines` and their tools: keyset paging over a
  newest-first browse. Pass `start`, then each `next_cursor`. Pages are
  deterministic and disjoint even when many articles share a timestamp. Not
  with `offset`, `q` or a ranked `mode`.
- `signal_percentile_min` (50-99.9) on `/search`, `/stories`, `/top-headlines`:
  keep the top slice of a signal over the match set. The resolved value is
  returned as `signal_threshold`; across indexes it is a count-weighted mean and
  marked `approximate`.

### Changed

- `aggregate_news` and `resolve_entity` require exactly what their endpoints
  require, and the OpenAPI document now marks required query parameters
  (`/entities`: `q`, `type`; `/aggregate`: `interval`).

## [0.2.0] - unreleased

The "agent-native" release: the documented agent workflow is expressible over
MCP, every tool is typed end to end, and a mistake in a request is refused
instead of silently changing the question.

### Breaking

- **Unknown query parameters are refused.** `GET /search?organiztion=asml`
  is now `400 {"code": "unknown_parameter", "detail": "Unknown parameter
  \"organiztion\". Did you mean \"organization\"?"}` on every endpoint. It
  used to be ignored, which searched everything. Over MCP an undeclared
  argument is JSON-RPC `-32602` with the same hint; every tool now declares
  `additionalProperties: false`.
- **Unknown signal names are refused** (`code: unknown_signal`) instead of
  matching nothing; `signal_min`/`signal_max` without `signal`, an unknown
  `facets` field, and a malformed `signals` clause are `invalid_parameter`.
- **401, 402 and 429 bodies are JSON**, the documented `Error` shape, instead
  of plain text. A missing document (`404`) now carries a body too.
- **`Error` gains `code`** (stable; `invalid_parameter`, `unknown_parameter`,
  `unknown_signal`, `not_found`, `unauthorized`, `rate_limited`, `suspended`,
  `quota_exceeded`, `upstream_unavailable`, `internal`) and requires
  `error`, `code`, `detail`. MCP tool errors carry the same JSON in their
  text block; protocol errors carry `code` in `error.data`.
- **`search_news` no longer requires `q`**: omit it for a newest-first
  browse, as `GET /search` always allowed. `list_stories` and
  `top_headlines` gain `source`, `mode` and the enums they lacked;
  `top_headlines` no longer injects `mode=recent` (it is implied).
- **`/stories` honours `mode`.** It was always keyword-ranked; the default is
  still `keyword`.

### Added

- `GET /stories/{id}` and the `get_story` tool: one story expanded --
  counts, outlets, `first_seen`/`last_seen`, facets over its members, the
  representative article, and its newest articles as compact hits.
- `GET /entities` and the `resolve_entity` tool: the spellings the index
  holds for a name, grouped by a deterministic `entity_id`
  (`<type>:<normalised key>`, `er-v1`) with aliases, counts and a
  confidence. Approximate by design; not a registry.
- `story_id` on `search_news`, `list_stories` and `top_headlines`, and in
  the `facets` enum.
- `view=compact` on `/search`, `/top-headlines`, `/similar/{id}` and the
  matching tools: each hit keeps `id, title, url, source, published_at,
  language, story_id, score`. Default stays `full`.
- `partial` and `coverage` on every search-shaped response, on both tiers
  and on `/similar/{id}`; the server instructions and tool descriptions
  state the rule: never conclude absence when `partial` is true, `coverage`
  is not `complete`, or `from_clamped` is true.
- `outputSchema` on every MCP tool (the OpenAPI component, inlined), so a
  schema-aware host can validate `structuredContent`.
- `mode=recent` documented; `score` and the previously undocumented
  `Article` fields documented; `ArticleDetail`'s `quotes`, `amounts`,
  `date_mentions` and `location_details` typed; the `signal` parameter
  carries its enum in the OpenAPI document.
- A parity test: every tool's arguments equal its REST path's query
  parameters; every parameter and property has a description; the signal
  vocabulary has one source.

### Fixed

- The gateway counted a shard's 4xx/5xx with a JSON body as an answer with
  no rows and `partial: false`. A non-2xx is now a failed shard.
- A bodyless 404 from a shard that does not serve a path (an older build
  during a rolling deploy) is reported as unreachable (`503`, retry), never
  as a cacheable absence.
