# Company Atlas API — contract (v1) Base: `/api/v1` · JSON (orjson) · UTC ISO-8601 timestamps (`Z`) · immutable ids (`co_…`, `sen_…`, `evt_…`) · slugs for public URLs. Public GET endpoints need no auth. Owner endpoints (watchlists, alerts) take `X-CA-Owner-Token` (client-generated random string ≥ 24 chars, stored hashed). Admin endpoints take `X-CA-Admin-Token`. Errors: `{ "detail": string }` with 4xx/5xx. Pagination: `?page=1&per_page=25` (max 200) → `{ items, page, per_page, total, pages }`. Lists accept `sort` where documented; unknown params are ignored. Interpretive language is careful by design (spec §167–168): events say *"no longer listed"*, never *"fired"*; anything inferred carries a `confidence` (0–1) and a `confidence_label` (`VERIFIED | HIGH_CONFIDENCE | LIKELY | INFERRED | LOW_CONFIDENCE`). ## Shapes ```ts type CompanyCard = { id: string; slug: string; display_name: string; legal_name: string | null; canonical_domain: string; website: string; description: string | null; industries: string[]; industry_primary: string | null; country: string | null; hq_city: string | null; hq_region: string | null; public_company: boolean; ticker: string | null; exchange: string | null; founded_year: number | null; employees_band: string | null; logo_url: string | null; status: string; onboarding_status: string; importance: number; tier: 1|2|3|4; metrics: Partial>; // activity_score, hiring_momentum_30d, product_velocity, ai_adoption, corporate_change_index, open_jobs … counts: { sensors: number; observations: number; changes: number; events: number; jobs_open: number }; last_event_at: string | null; last_observed_at: string | null; sparkline?: number[]; // 30 daily activity values when `?sparkline=1` (or on detail) }; type Metric = 'activity_score'|'hiring_momentum_7d'|'hiring_momentum_30d'|'hiring_momentum_90d'|'open_jobs'|'ai_adoption'|'product_velocity'| 'geo_expansion'|'developer_momentum'|'communication_activity'|'pricing_activity'|'leadership_activity'|'corporate_change_index'|'anomaly_score'|'historical_coverage'; type Event = { id: string; company: { id: string; slug: string; display_name: string; canonical_domain: string; country: string|null; logo_url: string|null }; event_type: string; event_subtype: string; importance: number; confidence: number; confidence_label: string; title: string; summary: string | null; old_value: string | null; new_value: string | null; payload: Record; entities: Record; tags: string[]; detected_at: string; effective_at: string | null; published_at: string | null; source_url: string | null; surface: string | null; sensor_id: string | null; change_id: string | null; cluster_id: string | null; origin: 'deterministic'|'llm'|'hybrid'|'backfill'; model_name: string | null; prompt_version: string | null; status: 'active'|'retracted'|'duplicate'|'review'; sources?: { source_url: string; surface: string | null; detected_at: string; kind: string; sensor_id: string | null }[]; // detail only }; type Sensor = { id: string; company_id: string; surface: string; connector_id: string; url: string; canonical_url: string; domain: string; status: string; tier: 'A'|'B'|'C'|'D'|'E'; quality_score: number; discovery_confidence: number; discovery_method: string | null; current_interval_s: number; next_run_at: string; last_run_at: string | null; last_success_at: string | null; last_change_at: string | null; last_status: number | null; last_failure_class: string | null; consecutive_failures: number; observation_count: number; snapshot_count: number; change_count: number; meaningful_change_count: number; event_count: number; created_at: string; }; type Snapshot = { id: string; sensor_id: string; version_no: number; fetched_at: string; title: string | null; language: string | null; text_length: number | null; block_count: number | null; extracted_summary: Record; content_hash: string; previous_snapshot_id: string | null }; type Change = { id: string; sensor_id: string; surface: string; company_id: string; detected_at: string; significance: number; kind: string; blocks_added: number; blocks_removed: number; blocks_modified: number; text_delta_ratio: number; similarity: number | null; snapshot_before: string | null; snapshot_after: string; diff?: DiffPayload; structured_delta?: Record }; type DiffPayload = { added: BlockDelta[]; removed: BlockDelta[]; modified: BlockDelta[]; moved: string[]; counts: Record; text_delta_ratio: number; similarity: number; reasons: string[] }; type BlockDelta = { key: string; kind: string; path: string; before: string | null; after: string | null; weight: number; similarity: number | null }; type Job = { id: string; title: string; department: string | null; location_text: string | null; city: string | null; country: string | null; remote: boolean | null; employment_type: string | null; seniority: string | null; url: string | null; posted_at: string | null; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: 'open'|'no_longer_listed'; is_ai: boolean }; type Person = { id: string; name: string; title: string | null; role_category: string | null; is_executive: boolean; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string; source_url: string | null }; type Product = { id: string; name: string; category: string | null; description: string | null; url: string | null; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string }; type Plan = { id: string; plan_name: string; price: number | null; price_text: string | null; currency: string | null; billing_period: string | null; unit: string | null; features: string[]; contact_sales: boolean; version_no: number; valid_from: string; valid_to: string | null; status: string; source_url: string | null }; type Location = { id: string; kind: string; name: string | null; city: string | null; region: string | null; country: string | null; lat: number | null; lon: number | null; first_seen_at: string; last_seen_at: string; removed_at: string | null; status: string; source_url: string | null }; type NewsItem = { id: string; title: string; url: string; summary: string | null; category: string | null; published_at: string | null; first_seen_at: string; language: string | null }; type MetricPoint = { day: string; value: number; confidence: number }; type Signal = { id: string; company_id: string | null; scope: string; scope_key: string | null; kind: string; strength: number; confidence: number; title: string; explanation: string | null; evidence: Record; window_days: number; detected_at: string; status: string }; ``` ## Endpoints ### Platform | Method | Path | Notes | |---|---|---| | GET | `/health`, `/ready` | service health (also at `/api/v1/health`) | | GET | `/stats` | `{ companies, companies_active, sensors, sensors_active, observations, snapshots, changes, meaningful_changes, events, jobs_open, countries, industries, observations_today, changes_today, events_today, dataset_started_at, dataset_age_days, oldest_history_days, last_observation_at, archive: {objects, bytes} }` (cached 60 s) | | GET | `/stats/history?days=90` | `{ items: GlobalDaily[] }` — `{ day, companies_active, sensors_active, observations, changes, meaningful_changes, events, events_by_type, jobs_open, jobs_new, jobs_removed, activity_index }` | | GET | `/system` | public aggregate health only: `{ sensors_online, sensors_failing, observations_today, events_today, countries_covered, queue_lag_s, scheduler_last_tick_at, fetch_per_min, success_rate_24h }` | | GET | `/pulse` | homepage aggregate: `{ stats, live: Event[12], movers: CompanyCard[10], hiring: CompanyCard[8], launches: Event[8], pricing: Event[8], ai: CompanyCard[8], industries: IndustryRow[12], countries: CountryRow[12], trending: TrendRow[10], activity_index: {value, delta_7d, series: MetricPoint[30]}, map: MapBucket[] }` (cached 60 s) | | GET | `/live?limit=50&since=&event_type=&min_importance=` | latest active events (no cache); `since` returns only newer than that time | | GET | `/live/stream` | **SSE**: `event: event` with an `Event` JSON per message (poll-based, ~5 s), `event: heartbeat` every 20 s, `?since=` supported | ### Companies | Method | Path | Notes | |---|---|---| | GET | `/companies` | filters: `q, country, industry, tier, public (bool), status, has_events (bool), sort=activity\|events\|hiring\|name\|importance\|recent\|relevance` (relevance is automatic with `q`), `sparkline=1` → page of `CompanyCard` | | GET | `/companies/{slug_or_id}` | `CompanyCard & { aliases: string[], domains: {domain, kind}[], relationships: {kind, company: {slug, display_name} \| null, to_name, valid_from, valid_to, confidence}[], metrics_detail: {metric, value, confidence, computed_at, inputs}[], sensors_by_surface: Record, coverage: {historical_coverage, first_observed_at, days_observed, sensor_uptime}, signals: Signal[], sparklines: {activity_30d: number[], hiring_90d: number[]} }` | | GET | `/companies/{slug}/events` | filters `event_type, event_subtype, since, until, min_importance, surface`; `sort=recent\|importance` → page of `Event` | | GET | `/companies/{slug}/timeline?filter=all\|products\|jobs\|pricing\|leadership\|locations\|legal\|news\|developer&limit=200` | `{ items: (Event & {day: string})[], days: {day, count}[] }` grouped for the timeline UI | | GET | `/companies/{slug}/metrics?metric=activity_score&days=90` | `{ current: {metric, value, confidence, computed_at, formula_version, inputs}[], series: Record }` | | GET | `/companies/{slug}/jobs?status=open\|removed\|all&q=&country=&ai=1` | page of `Job` + `{ summary: {open, new_7d, removed_7d, ai_open, by_country: {country,n}[], by_department: {department,n}[], remote_ratio} }` as `meta` | | GET | `/companies/{slug}/people` | `{ listed: Person[], no_longer_listed: Person[] }` | | GET | `/companies/{slug}/products` | `{ listed: Product[], removed: Product[] }` | | GET | `/companies/{slug}/pricing` | `{ current: Plan[], history: Plan[] }` | | GET | `/companies/{slug}/locations` | `{ items: Location[], countries: string[] }` | | GET | `/companies/{slug}/news?limit=50` | `{ items: NewsItem[] }` | | GET | `/companies/{slug}/sensors` | `{ items: Sensor[] }` | | GET | `/companies/{slug}/history` | `{ sensors: (Sensor & { versions: Snapshot[] })[] }` — historical page viewer index (max 20 versions per sensor) | | GET | `/companies/{slug}/similar?limit=8` | `{ items: CompanyCard[] }` (same industry/country, closest importance) | | GET | `/companies/compare?companies=stripe,adyen,block` | `{ companies: CompanyCard[], metrics: Record>, series: Record, events_30d: Record>, jobs: Record, locations: Record }` (2–6 companies) | ### Sensors, snapshots, changes (provenance) | Method | Path | Notes | |---|---|---| | GET | `/sensors/{id}` | `Sensor & { company: CompanyRef, latest_snapshot: Snapshot \| null }` | | GET | `/sensors/{id}/snapshots?limit=50` | `{ items: Snapshot[] }` | | GET | `/sensors/{id}/changes?limit=50` | `{ items: Change[] }` | | GET | `/snapshots/{id}` | `Snapshot & { text: string (≤ 200 kB), blocks: Block[], extracted: object }` | | GET | `/snapshots/{id}/diff/{other_id}` | `{ before: Snapshot, after: Snapshot, diff: DiffPayload }` (computed on demand) | | GET | `/changes/{id}` | `Change` with `diff` and `structured_delta`, plus `events: Event[]` | | GET | `/events/{id}` | `Event` detail with `sources`, `change` (Change summary), `company` | ### Events | Method | Path | Notes | |---|---|---| | GET | `/events` | filters `event_type, event_subtype, country, industry, since, until, min_importance, min_confidence, q, surface, origin, company`; `sort=recent\|importance` → page of `Event` | | GET | `/events/types` | `{ types: {event_type, subtypes: {event_subtype, count_30d}[], count_30d}[] }` | | GET | `/events/summary?days=7&group=type\|industry\|country` | `{ items: {key, count, delta_pct}[] }` | ### Rankings, industries, countries, signals, trends | Method | Path | Notes | |---|---|---| | GET | `/rankings?kind=most_active\|hiring_growth\|hiring_decline\|product_velocity\|ai_active\|geo_expansion\|developer_momentum\|pricing_changes\|unusual_activity&window=24h\|7d\|30d\|90d\|1y&country=&industry=&limit=50` | `{ kind, window, items: (CompanyCard & { rank: number; value: number; delta: number \| null })[] }` | | GET | `/industries` | `{ items: IndustryRow[] }` — `{ slug, name, parent_slug, companies, events_7d, events_30d, hiring_momentum_30d, activity_score, ai_adoption, top_event_types: string[] }` | | GET | `/industries/{slug}` | `IndustryRow & { description, companies: CompanyCard[24] (most active), events: Event[20], hiring: {open, new_30d, removed_30d, momentum_30d}, series: MetricPoint[90] (activity), countries: {country, companies}[], trending: TrendRow[] }` | | GET | `/countries` | `{ items: CountryRow[] }` — `{ code, name, region, companies, events_7d, events_30d, hiring_momentum_30d, activity_score, industry_mix: {industry, companies}[], lat, lon }` | | GET | `/countries/{code}` | `CountryRow & { companies: CompanyCard[24], events: Event[20], movers: CompanyCard[10], new_entrants: CompanyCard[10], series: MetricPoint[90], industries: IndustryRow[] }` | | GET | `/signals?kind=&scope=company\|industry\|country\|global&limit=50` | `{ items: Signal[] }` | | GET | `/trends?window=7d\|30d\|90d&limit=30` | `{ items: TrendRow[] }` — `{ term, mentions, companies, momentum, series: number[] }` | | GET | `/map?metric=events_30d\|companies\|hiring` | `{ buckets: MapBucket[] }` — `{ lat, lon, country, city: string \| null, companies: number, events_30d: number, jobs_open: number, top: {slug, display_name}[] }` (clustered by city/country, ≤ 600 buckets) | | GET | `/index` | Global Corporate Activity Index: `{ value, baseline: 100, delta_7d, delta_30d, series: MetricPoint[365], by_type: Record, by_country: {key, value}[], by_industry: {key, value}[], formula_version }` | ### Search | Method | Path | Notes | |---|---|---| | GET | `/search?q=&types=companies,events,industries,countries,people,products&limit=10` | `{ query, companies: CompanyCard[], events: Event[], industries: IndustryRow[], countries: CountryRow[], people: (Person & {company: CompanyRef})[], products: (Product & {company: CompanyRef})[], took_ms }` | | GET | `/search/suggest?q=` | `{ items: { kind: 'company'\|'industry'\|'country'\|'event_type', label, sublabel, href }[] }` (≤ 10, < 50 ms) | | GET | `/ask?q=` | natural-language routing (deterministic parser → structured query; LLM optional): `{ interpretation: {filters…}, answer: string, companies: CompanyCard[], events: Event[], sources: string[] }` | ### Watchlists & alerts (owner token) | Method | Path | Notes | |---|---|---| | GET/POST | `/watchlist` | GET → `{ items: CompanyCard[], events: Event[30] (for the watched companies) }`; POST `{ company: slug }` adds; DELETE `/watchlist/{slug}` removes | | GET/POST | `/alerts` | POST `{ name, company?: slug, condition: {event_types?: string[], min_importance?: number, metrics?: {activity_score?: {gt: number}}}, channel: 'web'\|'webhook', target?: url }`; DELETE `/alerts/{id}` | | GET | `/alerts/deliveries?limit=50` | recent deliveries for this owner | ### Exports & docs | Method | Path | Notes | |---|---|---| | GET | `/export/events.{json,ndjson,csv}?since=&event_type=&country=&limit=10000` | streamed export | | GET | `/export/companies.{json,ndjson,csv}?country=&industry=` | streamed export | | GET | `/export/jobs.ndjson?company=&since=` | | | GET | `/sitemap?kind=companies\|industries\|countries&page=` | `{ items: {slug, updated_at}[], pages }` — only companies with `indexed = true` | | GET | `/methodology` | `{ metrics: {metric, formula_version, description, inputs: string[]}[], significance_bands, event_types: string[], confidence_labels }` | ### Admin (`X-CA-Admin-Token`) | Method | Path | Notes | |---|---|---| | GET | `/admin/overview` | `{ companies_by_status, sensors_by_status, sensors_by_tier, queue: {pending, running, dead, oldest_pending_s}, llm: {pending, done_today, failed_today, budget_left}, failures_24h_by_class, fetch_rate_1h, change_rate_1h, meaningful_rate_1h, storage: {objects, bytes}, workers: {name, last_seen_at, inflight}[], cost_today: {fetch, browser, llm} }` | | GET | `/admin/connectors` | `{ items: {id, name, version, category, enabled, sensors_active, sensors_failing, success_rate_24h, avg_latency_ms, change_rate_24h, errors_24h, last_run_at}[] }` | | GET | `/admin/sensors?status=&domain=&connector=&company=&filter=healthy\|failing\|stale\|blocked\|redirected\|low_quality\|high_activity&page=` | page of `Sensor & {company: CompanyRef}` | | POST | `/admin/sensors/{id}/{action}` | action ∈ `pause, resume, retry, rediscover, retire, run_now`; body `{ interval_s?, connector_id? }` for `set_interval`, `set_connector` | | GET | `/admin/companies?onboarding_status=&page=` · POST `/admin/companies` `{ website, display_name?, country?, industries? }` (create + queue discovery) · POST `/admin/companies/{slug}/rediscover` | | GET | `/admin/failures?class=&since=&page=` · GET `/admin/queue?kind=&status=` · POST `/admin/queue/requeue-dead` | | GET | `/admin/llm?status=&page=` · GET `/admin/reviews?kind=&status=open` · POST `/admin/reviews/{id}` `{ resolution: 'accepted'\|'rejected', note? }` | | POST | `/admin/events/{id}/retract` `{ reason }` · POST `/admin/events/{id}/restore` | | GET | `/admin/quality` | `{ coverage: {companies_active_pct, sensors_active_pct}, freshness: {sensors_checked_24h_pct, stale}, duplicate_rate, event_confidence_avg, unknown_surfaces, failed_sensors, calibration: {correct, duplicate, noise, misclassified} }` | | GET | `/admin/costs?days=30` | `{ items: {day, dimension, key, units, cost_estimate}[], per_1000_companies, per_million_observations, per_meaningful_event }` | | POST | `/admin/cache/clear` | | ## Rate limits & auth | Tier | How | Limit (per client, per minute) | |---|---|---| | anonymous | no header — keyed by client IP (first hop of `X-Forwarded-For`) | 120 | | authenticated | `X-CA-API-Key: ` (create with `catlas api-key create `) | 600 | | paid | `X-CA-API-Key` with `tier = paid` (`catlas api-key create --tier paid`) | 3 000 | | internal | `X-CA-API-Key` with `tier = internal` | unlimited | | admin | valid `X-CA-Admin-Token` | bypass (also required for `/admin/*`) | Token bucket per process (refills continuously at `limit / 60` tokens per second). Every limited response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Tier`; when the bucket is empty the API answers `429 { "detail": "rate limit exceeded" }` with a `Retry-After` (seconds). `/health`, `/ready`, `/api/v1/docs` and `/api/v1/openapi.json` are never limited. Keys are stored as sha256 hashes (`api_keys.key_hash`); the raw key is printed once by the CLI. `last_used_at` / `request_count` are updated lazily (≈ every 30 s). Owner endpoints (`/watchlist`, `/alerts*`) need `X-CA-Owner-Token` (client-generated, 24–200 chars); the owner row is created on first use and the token is only ever stored hashed. Admin endpoints answer `401 { "detail": "admin token required" }` without a valid `X-CA-Admin-Token`. ## Implementation notes (API v1.0 — additive details) - **Caching / ETags.** Cached public aggregates (`/pulse` 60 s, `/stats` 60 s, `/system` 30 s, `/rankings` 120 s, `/industries*`, `/countries*`, `/index`, `/map`, `/trends`, `/events/types`, `/events/summary`, `/stats/history` 300 s, `/sitemap` 600 s, `/methodology` 3600 s) return `Cache-Control: public, max-age=N, stale-while-revalidate=2N` plus a weak `ETag`; a matching `If-None-Match` yields `304`. Other public GETs carry `public, max-age=30…600` (per resource volatility); `/live*`, owner and admin routes are `no-store`. `POST /admin/cache/clear?prefix=` clears one key prefix (e.g. `pulse`, `industries:`) or everything. - **Pagination totals.** `total` is exact up to **10 000** (bounded count) so deep feeds stay cheap; `pages` derives from it. - **`/live`** also accepts `country` and `industry`; the payload is `{ items, count, cursor, server_time }` — pass `cursor` back as `since`. - **`/live/stream`** accepts `?since=&event_type=&min_importance=&max_s=` (`max_s` ≤ 3600 bounds the connection for proxies with idle timeouts; the stream ends with `event: end` carrying the last cursor). The first message is always an `event: heartbeat` with `{at, cursor}`; `event: event` messages carry `id: `. Response headers include `X-Accel-Buffering: no`. - **`/companies`** gains `sort=relevance` (automatic when `q` is present); `q` uses FTS (`companies.search`) for ≥ 3 characters with a trigram fallback and a prefix match for shorter strings. `sparkline=1` adds 30 daily `activity_score` points. - **`/companies/{slug}`** additionally returns `company_type, employees, wikidata_id, indexed, discovered_at, first_observed_at, last_change_at, recent_events: Event[10]` and `sparkline`. - **`/companies/{slug}/timeline`** accepts `filter=corporate` (FINANCING, M&A, PARTNERSHIP, STRATEGY) and `before=`; items carry `day`. - **`/companies/{slug}/jobs`** accepts `department`, `remote`, `sort=recent|title|posted`; `q` is a trigram `ILIKE` on `title`. - **`/companies/{slug}/history`** accepts `versions=1…20`; `/companies/{slug}/sensors?include_retired=1`; `/companies/{slug}/news?category=`; `/companies/{slug}/locations?status=all`; `/companies/{slug}/pricing?history_limit=`. - **`/companies/compare`** accepts `days=` (7–365) for the activity series; unknown slugs → `404 company not found: `; fewer than 2 → 422. - **`/events`** accepts `status=active|retracted|duplicate|review|all` (default `active`). Retracted events expose `retracted_reason`. `/events/{id}` embeds the full `CompanyCard` under `company` (a superset of `CompanyRef`), `sources` (falls back to the event's own `source_url` when `event_sources` is empty), `change` and `cluster`. - **`/events/types`** lists every taxonomy type/subtype (count `0` when unseen) so filter UIs can render on an empty database. - **`/events/summary`** items also carry `previous` (count of the preceding window). - **`/rankings`** items are `CompanyCard & { rank, value, delta }`; the payload also lists `kinds`. `hiring_growth`/`hiring_decline` read `hiring_momentum_7d|30d|90d` according to the window; `pricing_changes` counts PRICING events in the window; `delta` is the difference with the `metric_series` value at the start of the window (`null` when no history yet). - **`/countries/{code}`** accepts an ISO-2 code or a name slug (`/countries/canada`); `CountryRow` includes `slug` and `subregion`. `/industries/{slug}` adds `children`, `signals`, `companies_total`; `trending` is `[]` until per-industry trend data exists (never faked). - **`/signals`** accepts `scope_key`, `company`, `min_strength`; items include a `company` ref when scoped to a company. - **`/search`** people/products use trigram similarity (`migrations/pending/api.sql` adds the two supporting GIN indexes). `/search/suggest` items add `slug` / `code` / `event_type` next to `href`. - **`/ask`** returns `engine` (`deterministic` | `llm`) and `events_total`; the parser is `services/llm/ask.py` when present (deterministic parse + optional LLM refinement), else `api/ask_fallback.py`. Answers only phrase measured counts. - **`/snapshots/{id}`** accepts `include=text,blocks,extracted` and returns `text_truncated` (text capped at 200 kB), `sensor`, `object_keys`. `/snapshots/{a}/diff/{b}` orders the pair by `fetched_at`, returns `source: "change"` (stored diff, with `change_id`) or `"computed"` (`sdk.diff.compare`); `501` when the diff engine is unavailable, `404` when block objects are missing. - **Exports.** `/export/events.{fmt}` also accepts `until`, `company`, `min_importance`, `limit` (≤ 10 000); `/export/companies.{fmt}` accepts `status`, `tier`, `limit` (≤ 20 000); `/export/jobs.{json|ndjson|csv}` accepts `status=open|removed|all`, `country`, `ai`, `limit` (≤ 10 000). CSV flattens the company ref (`company_slug`, `company_name`, `company_domain`, `country`). - **`/sitemap`** pages hold 5 000 entries; `kind=companies` returns `total`; industries/countries only list entries with ≥ 1 active company. - **Admin additions.** `GET /admin/sensors` accepts `surface`, `sort=recent|next_run|failures|quality|changes|created`; sensor actions return `{ ok, action, sensor, queued? , interval_s? }` (`set_interval` is clamped to `CA_MIN_INTERVAL_S…CA_MAX_INTERVAL_S` and recomputes the tier). `POST /admin/companies` → `201 { ok, company, queued }` (409 when the registrable domain already exists; 422 for non-http(s) URLs, unknown country codes or industry slugs). `GET /admin/failures` returns `by_class` and `classes`; `GET /admin/queue` returns `counts` + `items`; `POST /admin/queue/requeue-dead { kind? }`; `GET /admin/llm` includes `stats`; `POST /admin/reviews/{id}` accepts `label` (correct | duplicate | noise | misclassified) feeding `/admin/quality.calibration`; retract/restore append an audit trail under `payload._audit` and never delete; `GET /admin/storage` reports the object store. `/admin/overview` also returns `companies_by_onboarding`, `scheduler_heartbeat`. ## Profile & facts (additive, 2026-09-13 — `services/enrichment.py`) Companies are enriched from **Wikidata** (entity), **Wikipedia** (lead summary, CC BY-SA 4.0), the **homepage** (meta description, JSON-LD `Organization`, icons) and, only when no Wikipedia text exists, a **grounded LLM description** built from the company's own pages. Every accepted value keeps its provenance; a weaker source never overwrites a better one (description: wikipedia > llm > homepage > wikidata; other facts: wikidata > homepage > registry > wikipedia). Nothing is inferred — absent facts are `null`. ```ts type Provenance = { field: string; source: 'wikidata'|'wikipedia'|'homepage'|'llm'|'registry'; url: string | null; retrieved_at: string }; type Money = { value: number; currency: string; year: number }; // ISO 4217; the fiscal year the figure refers to type CompanyProfile = { description: string | null; description_source: 'wikipedia'|'wikidata'|'homepage'|'llm'|null; description_url: string | null; description_license: string | null; // 'CC BY-SA 4.0' for Wikipedia text (attribution required: show description_url) description_attribution: string | null; // e.g. 'Text from Wikipedia (en), CC BY-SA 4.0' · "Generated from the company's public pages" logo_url: string | null; icon_url: string | null; founded_year: number | null; legal_form: string | null; legal_name: string | null; employees: number | null; employees_year: number | null; revenue: Money | null; net_income: Money | null; total_assets: Money | null; hq: { city: string | null; region: string | null; country: string | null; address: string | null; lat: number | null; lon: number | null }; ticker: string | null; exchange: string | null; isin: string | null; lei: string | null; sec_cik: string | null; public_company: boolean; wikipedia_url: string | null; wikidata_url: string | null; official_website: string | null; phone: string | null; products: string[]; industries: string[]; industry_labels: string[]; socials: { linkedin?: string; x?: string; youtube?: string; facebook?: string; instagram?: string; github?: string; tiktok?: string; crunchbase?: string }; enriched_at: string | null; sources: Provenance[]; version: 'profile-v1'; }; type Fact = { key: 'founded'|'headquarters'|'employees'|'revenue'|'net_income'|'total_assets'|'legal_form'|'listing'|'isin'|'lei'|'sec_cik'|'website'|'wikipedia'; label: string; value: string; raw: unknown; source: string | null; url: string | null; retrieved_at: string | null }; ``` - **`CompanyCard.profile?: CompanyProfile`** — present on every card (lists, rankings, detail…) once the company has been enriched (`companies.source_meta.profile`); absent before. `sources[]` holds one entry per populated field. - **`GET /companies/{slug}`** adds `profile: CompanyProfile | null` and `facts: Fact[]` (display-ready strings such as `"47,756 (2013)"`, `"USD 305.6 B (2023)"`, `"Mountain View, California, US"`; `raw` carries the underlying value). `relationships` rows now carry `provenance: {source, property, qid?, retrieved_at?}`, `first_seen_at`, `last_seen_at`, and `kind` ∈ `PARENT_OF | SUBSIDIARY_OF | OWNED_BY | OWNER_OF | ACQUIRED_BY | ACQUIRED | PARTNER_OF | COMPETITOR_OF | INVESTOR_IN | BRAND_OF` (Wikidata rows: confidence 0.85, `valid_from` / `valid_to` from the start/end qualifiers; `company` is set when the counterpart is in the atlas, else only `to_name`). Current relationships are listed before ended ones (limit 200). - **`GET /companies/{slug}/people`** rows add `source: 'wikidata' | 'page'` (derived from `source_url`; Wikidata rows have `title` = role such as `Chief Executive Officer`, `Chairperson`, `Founder`, or the position label for key people) and the payload adds `sources: string[]`. A person whose Wikidata role ended is `no_longer_listed` (never "left"); page-sourced rows are never overridden by Wikidata. - Columns back-filled by enrichment (`description`, `logo_url`, `hq_city`, `hq_region`, `country`, `founded_year`, `employees`, `ticker`, `exchange`, `legal_name`, `lei`, `sec_cik`, `industries`) record their source in `companies.source_meta.provenance[column]` (`{source, url, retrieved_at, previous?}`), so `CompanyCard.description` may now be a Wikipedia paragraph — show the attribution from `profile.description_attribution` / `profile.description_url` when `profile.description_source === 'wikipedia'`. - CLI: `catlas enrich-companies [--limit N] [--company slug…] [--source wikidata|wikipedia|homepage|llm|all] [--concurrency N] [--no-llm]`, `catlas profile [--json]`. Periodic task `company-enrichment` (every 10 min, batch `CA_ENRICH_BATCH` = 300; never-enriched first, active companies first, then profiles older than `CA_ENRICH_REFRESH_DAYS` = 30). ## Conventions for implementers - Every list endpoint is bounded (`per_page ≤ 200`, `limit ≤ 500`), uses indexed predicates, and returns `Cache-Control: public, max-age=60` for public aggregates (`/pulse`, `/stats`, `/rankings`, `/industries`, `/countries`) and `no-store` for `/live*`, owner and admin routes. - Company lookups accept slug **or** id. Unknown → 404 `{detail: "company not found"}`. - Text search: Postgres FTS (`companies.search`, `events.search`) + trigram fallback for short/partial queries; never LIKE on unindexed columns. - Numbers: metrics are 0–100 floats rounded to 1 decimal except `hiring_momentum_*` (percentage, may be negative) and `open_jobs` (int). - Never fabricate: when a metric has no inputs, omit it (or `null`) rather than returning 0 as if measured.