SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
30.6 KB

# 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<Record<Metric, number>>;              // 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<string, unknown>; entities: Record<string, unknown>; 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<string, number>; 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<string, unknown> };

type DiffPayload = { added: BlockDelta[]; removed: BlockDelta[]; modified: BlockDelta[]; moved: string[]; counts: Record<string, number>;
  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<string, unknown>; 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=<iso>&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<string, number>, 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<Metric, MetricPoint[]> }
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<Metric, Record<slug, number>>, series: Record<slug, MetricPoint[]>, events_30d: Record<slug, Record<event_type, number>>, jobs: Record<slug, {open, ai_open, new_30d}>, locations: Record<slug, number> } (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}[] }
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<string, number>, by_country: {key, value}[], by_industry: {key, value}[], formula_version }
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: <key> (create with catlas api-key create <name>) 600
paid X-CA-API-Key with tier = paid (catlas api-key create <name> --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: <event 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=<iso>; 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: <slug>; 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 <slug> [--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.