Skip to content

Public API v1

The Futuros public API is versioned static data. Futuros is a frontend-only platform: there is no query server behind /api/v1/. Everything is baked from public/data/ by scripts/bake-api.ts and served as plain files over the CDN. A post-bake gate (scripts/check-api.ts, run in the same prebuild step) asserts the bake matches the corpus — citation counts line up, every cell is covered, the bulk dumps parse, generated_at is at least as new as the newest data file — and fails the build on any drift, so the served tree cannot go stale. Every endpoint below is a plain HTTP GET of a file — no auth, no API key, no rate limit, fully cacheable.

  • Base URL: https://futuros.xyz/api/v1/
  • Discovery (DCAT catalog): https://futuros.xyz/data.json (at the site root, not under /api/v1/)
  • Human docs page: https://futuros.xyz/datos-abiertos?lang=en

Because the responses are static files, an endpoint is just its path. Fetch one with curl, a browser, fetch(), pandas.read_csv, R, or any SDMX/DCAT client.

How the tree is baked

bake-api.ts then check-api.ts run as the final two steps of prebuild (after typecheck, the test suite and the other data gates), so every deploy re-bakes /api/v1 from the current public/data/ and proves the result before vite build ships it:

Bake invariants (scripts/bake-api.ts):

  • Atomic promote. The whole tree is written into public/api/v1.tmp (plus public/data.json.tmp) and swapped over the live paths with renameSync as the very last step. A crash mid-bake leaves the previous tree intact — the old delete-then-repopulate flow could ship a half-written /api/v1.
  • Cell source. One cell = one public/data/parameter-cache/<param>__<ISO3>.json envelope; a cell whose payload has no indicators is skipped (the gate counts those as legitimately absent, not missing).
  • Row flattening. One CSV/SDMX row per (indicator, year). Duplicate years inside a series are dropped (first occurrence wins), and the latest vintage row is appended only when the series doesn't already contain that year — a value is never emitted twice.
  • Formula-injection neutralization (OWASP). Any string cell starting with = + - @ (or a leading tab/CR) is prefixed with ' before it reaches CSV or XLSX, so Excel/Sheets/LibreOffice render it as literal text rather than executing it as a formula. Numbers pass through untouched.
  • Append-only columns. New CSV columns are appended, never inserted (label_en,label_pt sit after citation_id), so positional parsers keep working and header-name parsers pick the additions up.
  • M49 is baked in. The ISO3→M49 map is a fixed 26-entry table in the baker (25 countries + LATAM = 419), not a runtime lookup.

What check-api.ts asserts (any failure prints a diff and exits 1):

#AssertionDrift it blocks
1index.json.counts.citations = length of public/data/citations.jsonmanifest out of step with the citation registry
2every non-empty cache cell has its baked observations/<param>__<ISO3>.jsonsilently dropped cells
3bulk CSV/SDMX/XLSX exist, are non-empty and parse (CSV: header + ≥1 row; XLSX: opens)corrupt bulk dumps
4index.json's file mtime ≥ newest public/data mtime − 5 min tolerancestale bake. Compared mtime-vs-mtime because git checkouts rewrite data-file mtimes — the old generated_at comparison false-failed on fresh checkouts
5observation rows < 1,000,000overflowing the XLSX single-sheet ceiling (1,048,576 rows)
6every search-index persona/case ref resolves to a file on diskdead retrieval hits (137 dead persona refs shipped in July 2026)
7ES/EN/PT labels complete and exact-matching the metrics registry, with a ≥90% instance-coverage floorthe public API regressing to Spanish-only, or a silently desynced label join

Stability contract

The manifest states it verbatim:

Shapes are additive-only within v1; breaking changes go to /api/v2/.

Practically:

  • Fields may be added to any object within v1. Do not assume a fixed key set — read by key, ignore unknowns.
  • Existing field names, types and meanings will not change within v1.
  • A breaking change (renaming/removing a field, changing units or semantics) ships as a new /api/v2/ tree; /api/v1/ keeps working.
  • index.json carries api_version, generated_at, and a stability string — read generated_at to detect a rebuild.

CORS & caching

Set in vercel.json for the /api/v1/(.*) path:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, OPTIONS
Cache-Control: public, max-age=3600, s-maxage=86400

So the read API is cross-origin readable from any browser app (GET/OPTIONS only — it is read-only), edge-cached for a day. There is no write surface here. The dynamic endpoints (/api/chat, /api/mcp, …) set their own headers in code; of those, only /api/mcp is cross-origin (*, POST, OPTIONS) — the rest are same-origin.

License & attribution

From index.json:

  • license_url: https://creativecommons.org/licenses/by/4.0/ (CC BY 4.0)
  • license (verbatim): "Datos redistribuidos de fuentes abiertas; cada observación conserva su fuente primaria (source_name, source_url, citation_id). Al reutilizar: cite la fuente primaria de cada observación y «Futuros» como agregador."

In English: the data is redistributed from open sources; every observation keeps its primary source (name, deep-link URL, and citation_id). When you reuse it, cite each observation's primary source and credit Futuros as the aggregator. Nothing is imputed or synthesized — the honesty contract of the baker is that every value on the wire carries the source it came from.

Interoperability standards

index.json → standards declares the codes the API speaks so UNSD-SDG, World Bank Data360, OECD.AI and CEPALSTAT can ingest it directly:

StandardWhere
REF_AREA = UN M49 numeric + ISO 3166-1 alpha-3every geography & observation
SDG global indicator framework (A/RES/71/313)sdg block per indicator + sdg-crosswalk.json
SDMX-CSV (ISO 17369)observations.sdmx.csv
DCAT (project-open-data v1.1)/data.json

The manifest — index.json

Start here. It lists every dataset, live counts, the license, the stability statement, and a machine-readable endpoints map (path templates for the rest).

bash
curl -s https://futuros.xyz/api/v1/index.json
jsonc
{
  "name": "Futuros — API de datos abiertos",
  "api_version": "1",
  "generated_at": "2026-08-29T04:17:28.143Z",
  "docs": "/datos-abiertos",
  "license": "Datos redistribuidos de fuentes abiertas; …",
  "license_url": "https://creativecommons.org/licenses/by/4.0/",
  "delta_basis": "latest value vs mean of the prior 3 years (smooths COVID-era volatility)",
  "stability": "Shapes are additive-only within v1; breaking changes go to /api/v2/.",
  "languages": ["es", "en", "pt"],
  "label_fields": { "parameters": ["name_es", "name_en", "name_pt"],
                    "geographies": ["name_es", "name_en", "name_pt"],
                    "observations": ["label_es", "label_en", "label_pt"] },
  "standards": {
    "ref_area": "UN M49 numeric (REF_AREA) + ISO 3166-1 alpha-3",
    "sdg": "UN SDG global indicator framework (A/RES/71/313); see /api/v1/sdg-crosswalk.json",
    "sdmx": "SDMX-CSV (ISO 17369) at /api/v1/observations.sdmx.csv",
    "catalog": "DCAT (project-open-data v1.1) at /data.json"
  },
  "counts": {
    "parameters": 10, "geographies": 26, "cells": 260,
    "observations": 397898, "sdg_tagged_indicator_instances": 2848,
    "citations": 15098, "signal_files": 260, "governance_datasets": 3, "contributions": 0
  },
  "bulk": {
    "observations_csv": { "path": "/api/v1/observations.csv", "bytes": 170533700, "rows": 397898 },
    "observations_sdmx_csv": { "…": "…" }, "workbook_xlsx": { "…": "…" }
  },
  "endpoints": { "openapi": "/api/v1/openapi.json", "schemas": "/api/v1/schemas/",
                 "parameters": "/api/v1/parameters.json", "…": "…" }
}

counts.geographies is 26 because the 25 countries include the LATAM regional aggregate (M49 419). A cell is one parameter × one geography; an observation is one indicator-year row.

The bulk block carries bytes + row counts for each bulk dump — currently about 171 MB (observations.csv), 111 MB (observations.sdmx.csv) and 91 MB (futuros-data.xlsx), ~398k rows each — so check it before starting a bulk download. languages + label_fields (the machine-readable replacement for the earlier prose language_coverage note) declare that every parameter and geography carries name_es/name_en/name_pt and every observation label_es/label_en/label_pt. label_es is the canonical payload label and is never renamed; the EN/PT siblings are joined from public/data/metrics/registry.json on indicator_id and are null (JSON) / empty (CSV) for ids the registry does not yet cover. Counts and sizes here are illustrative, the live manifest is authoritative.

Machine-readable contract — openapi.json & schemas/

The v1 contract itself ships as data, listed first in index.json → endpoints:

  • /api/v1/openapi.jsonOpenAPI 3.1.0 spec covering 15 core read paths (sdg-crosswalk.json, citations.csv and the DCAT catalog /data.json are not yet in the spec).
  • /api/v1/schemas/{manifest,parameter,geography,observation-cell,citation}.schema.jsonJSON Schema for each response shape.

Point a codegen or validation tool at these instead of hand-transcribing shapes from this page.


Parameters — parameters.json

The 10 Futuros pillars, in display order, each with its primary/secondary SDG goals.

bash
curl -s https://futuros.xyz/api/v1/parameters.json
jsonc
[
  { "slug": "salud", "name_es": "Salud", "name_en": "Salud",
    "display_order": 1, "sdg_primary": [3], "sdg_secondary": [2, 6] },
  { "slug": "educacion", "name_es": "Educación", "…": "…" }
]

Use slug (e.g. salud, educacion, cohesion-social-inclusion) as the <param> segment in observation paths.

Geographies — geographies.json

The 25 countries plus the LATAM region, each with ISO3 and M49.

bash
curl -s https://futuros.xyz/api/v1/geographies.json
jsonc
[
  { "iso3": "LATAM", "m49": "419", "slug": "latam",
    "name_es": "América Latina y el Caribe", "name_en": "Latin America and the Caribbean",
    "level": "region", "sub_region": null, "population_2024": null },
  { "iso3": "ARG", "m49": "032", "slug": "arg", "level": "country", "…": "…" }
]

Country slugs are the lowercased ISO3 (arg, mex, bra; the region is latam). Use iso3 (e.g. MEX, ARG, BRA) as the <ISO> segment in observation paths. M49 codes are zero-padded strings ("032", "484").


Observations — one cell

The core dataset. One file per parameter × country, addressed as observations/<param>__<ISO3>.json (note the double underscore separator).

JSON

bash
curl -s https://futuros.xyz/api/v1/observations/salud__MEX.json
jsonc
{
  "api_version": "1",
  "generated_at": "2026-08-29T04:17:28.143Z",
  "parameter": { "slug": "salud", "label_es": "Salud" },
  "geography": { "iso3": "MEX", "m49": "484", "name_es": "México",
                 "name_en": "México", "level": "country" },
  "confidence": "high",
  "last_refreshed": "2026-06-15T16:20:52.364Z",
  "indicators": [
    {
      "indicator_id": "sp_dyn_le00_in",
      "label_es": "Esperanza de vida al nacer",
      "label_en": "Life expectancy at birth",
      "label_pt": "Expectativa de vida ao nascer",
      "unit": "años",
      "latest": { "year": 2024, "value": 75.264 },
      "delta_pct": 3.199,
      "delta_basis": "latest value vs mean of the prior 3 years (smooths COVID-era volatility)",
      "good_direction": "up",
      "coverage": "primary",
      "benchmark": { "oecd": 80.39, "world": 73.48, "sea_peers": 73.87, "oecd_vintage": 2024 },
      "sdg": { "code": "3", "level": "goal",
               "indicator_name": "Ensure healthy lives and well-being …", "goal": 3 },
      "source": {
        "name": "World Bank Open Data",
        "url": "https://data.worldbank.org/indicator/SP.DYN.LE00.IN?locations=MX",
        "citation_id": "wb-sp-dyn-le00-in-mex-2024"
      },
      "series": [ { "year": 2000, "value": 72.562 }, { "year": 2001, "value": 72.912 }, "…" ]
    }
  ]
}

Every indicator carries latest (year + value), the full series back to ~2000, its source (name + primary-source deep link + citation_id), an official sdg code when one exists (level is indicator, target or goal), and delta_pct (see delta_basis: latest vs. the mean of the prior 3 years). sdg is null when no official SDG code applies — never fabricated.

CSV

The same cell flattened to one row per indicator-year:

bash
curl -s https://futuros.xyz/api/v1/observations/salud__MEX.csv

Header:

parameter,iso3,m49,indicator_id,label_es,unit,year,value,delta_pct_latest,
good_direction,coverage,vintage_year,sdg_code,sdg_level,source_name,source_url,citation_id,
label_en,label_pt

label_en,label_pt sit at the end of the row — column order is part of the CSV contract, so additions are always appended, never inserted.

Observations — bulk

Every cell concatenated into one CSV — the whole corpus in a single download (~164k rows):

bash
curl -s https://futuros.xyz/api/v1/observations.csv -o futuros-observations.csv

Same header as the per-cell CSV. Load directly:

python
import pandas as pd
df = pd.read_csv("https://futuros.xyz/api/v1/observations.csv")

Observations — SDMX-CSV

The bulk data as SDMX-CSV (ISO 17369), REF_AREA keyed on UN M49 to match UNSD-SDG / Data360 conventions:

bash
curl -s https://futuros.xyz/api/v1/observations.sdmx.csv
DATAFLOW,FREQ,REF_AREA,INDICATOR,TIME_PERIOD,OBS_VALUE,UNIT_MEASURE,
SDG_INDICATOR,REF_AREA_ISO3,FUTUROS_PARAMETER,SOURCE,SOURCE_URL,CITATION_ID
FUTUROS:DF_OBSERVATIONS(1.0),A,032,si_pov_gini,2000,51,índice,10,ARG,
cohesion-social-inclusion,World Bank Open Data,https://data.worldbank.org/…,wb-si-pov-gini-arg-2024

DATAFLOW is FUTUROS:DF_OBSERVATIONS(1.0), FREQ is A (annual).

Excel workbook — futuros-data.xlsx

One workbook with five sheets — README (license + delta_pct basis), parameters, geographies, observations (all rows), and citations:

bash
curl -s https://futuros.xyz/api/v1/futuros-data.xlsx -o futuros-data.xlsx

Citations — citations.json / citations.csv

The full source registry every citation_id resolves against.

bash
curl -s https://futuros.xyz/api/v1/citations.json
curl -s https://futuros.xyz/api/v1/citations.csv

CSV columns: id,source,title,url,year,retrieved_at. Join observations.*'s citation_id to citations.*'s id to attach full source metadata to any value.

Freshness ledger — freshness.json

Per-source refresh cadence — when each indicator was last pulled and when it is next due. A passthrough of the build's refresh-meta.json. Top-level keys: generated_at, total_indicators, indicators, plus a sources block.

bash
curl -s https://futuros.xyz/api/v1/freshness.json
jsonc
{
  "generated_at": "2026-06-15T16:20:52.364Z",
  "total_indicators": 48,
  "indicators": {
    "SI.POV.GINI": {
      "source": "World Bank Open Data",
      "source_url": "https://data.worldbank.org/indicator/SI.POV.GINI",
      "cadence_months": 12,
      "last_refreshed": "2026-06-15T16:20:52.364Z",
      "next_refresh_due": "2027-08-15T20:11:16.364Z",
      "countries_with_data": 0, "total_countries": 25
    }
  }
}

SDG crosswalk — sdg-crosswalk.json

The verified map from pillars and indicators to official SDG codes (goals, pillars, indicators). Join on indicator_id.

bash
curl -s https://futuros.xyz/api/v1/sdg-crosswalk.json

Signals — signals/<param>__<ISO3>.json

LLM-extracted, cited "what's moving" signals per cell (present because the multi-source layer is baked). Same <param>__<ISO3> addressing as observations, and since the 2026-08-05 bake coverage is total: all 260 cells have a file (counts.signal_files in the manifest). The 10 regional-aggregate cells (<param>__LATAM), which the signal harvest never writes, are baked as an empty array []; they previously had no file and answered HTTP 404. Treat a [] as "this cell has no signals", not as an error; a 404 under signals/ now does indicate a mistyped path.

bash
curl -s https://futuros.xyz/api/v1/signals/salud__ARG.json

Contributions — contributions.json

License-gated data contributed through the Data Trust (Pillar 1), published only for postures that permit republishing values. Currently count: 0.

bash
curl -s https://futuros.xyz/api/v1/contributions.json

Governance Engine — governance/*.json

The Governance Engine (Pillar 3) datasets are part of the versioned contract since the 2026-08-05 bake (they were previously reachable only via MCP or as an unversioned file). Each is a passthrough of the file the UI consumes, served with the same additive-only stability contract, CORS and caching as the rest of v1:

PathWhat
/api/v1/governance/resilience.jsonDemocratic Resilience Index: subindices + composite, cited per country
/api/v1/governance/regulation-index.jsonRegulatory Observatory: instruments per country and pillar
/api/v1/governance/scores.jsonGoalpost composite index per geography (2010-2024 series)
bash
curl -s https://futuros.xyz/api/v1/governance/resilience.json

The MCP path remains available: get_dataset with dataset_id: "democracy-resilience" on the MCP server returns the same resilience index with deterministic compute. The unversioned file GET /data/democracy/resilience.json also still exists, but with no stability contract; prefer the /api/v1/governance/ paths above.


Discovery — DCAT catalog at /data.json

A DCAT / project-open-data v1.1 catalog at the site root (not under /api/v1/) so data portals and crawlers can auto-discover the datasets:

bash
curl -s https://futuros.xyz/data.json

It advertises the bulk CSV, SDMX-CSV, XLSX, citations, SDG crosswalk and freshness datasets, each with downloadURL, mediaType and CC BY 4.0 license.

RSS feeds · /feeds/

The cited signals are also published as 267 static RSS 2.0 feeds under /feeds/ (at the site root, not under /api/v1/), built for RSS readers and institutional monitoring. Same regime as the read API: static GET, no auth, cacheable.

PathWhat
/feeds/futuros.xmlRegional master: every signal region-wide in one feed
/feeds/<ISO3>.xmlPer country: union of its 10 pillar feeds, max 50 items
/feeds/<param>__<ISO3>.xmlPer cell (pillar × country): cited signals with source deep-links
/feeds/index.jsonManifest: total_feeds, cells_written, cells_skipped_empty, countries_written, forecasts_included, forecasts_skipped_unverifiable, and feeds[] with title, path, kind and item count
bash
curl -s https://futuros.xyz/feeds/salud__BRA.xml

The feeds are no longer frozen: scripts/bake-feeds.ts runs inside the prebuild chain (alongside bake-data-health, bake-source-ledger and bake-api, right before check-api), so they are re-baked on every deploy from the current signal corpus. The manifest is explicit about what it did not publish: cells_skipped_empty counts cells with no signal — no empty feed is emitted to simulate coverage — and forecasts_skipped_unverifiable counts forecasts left out for not being verifiable.

Data-gap backlog — GET /api/backlog

Unlike everything above, this is not a baked file under /api/v1/: it is a live function (api/backlog.ts), because what it serves is produced at request time rather than at bake time. It is documented here because it is public, unauthenticated and read-only, like the rest of this page.

Every time the assistant queries the corpus and a corpus tool comes back empty, the server files a structured ticket instead of letting the signal die. This endpoint is how that backlog becomes visible — to the SPA (/vacios), to the ingestion lane deciding what to source next, and to anyone who wants to check that "we don't have that figure" is followed by something.

Parameters

ParameterDefaultWhat it does
limit100 (max 500)How many tickets to return
countryFilter by ISO3 (upper-cased)
pillarFilter by axis slug

Rate limit: 60 requests per minute per IP; over that it returns 429 with Retry-After. Cache: s-maxage=60, stale-while-revalidate=300.

Response

json
{
  "ok": true,
  "configured": true,
  "count": 2,
  "gaps": [
    {
      "id": "gap-ecu-medio-ambiente-clima-1a2b3c4d",
      "created_at": "2026-08-16T09:12:44.031Z",
      "lang": "es",
      "country": "ECU",
      "pillar": "medio-ambiente-clima",
      "route": "/atlas",
      "empty": ["get_dataset:regulation", "get_dataset:signals"],
      "candidate_sources": [
        { "title": "SIMAS", "url": "https://ambiente.gob.ec/simas", "source": "ambiente.gob.ec", "date": "2026" }
      ],
      "missing_indicators": ["Area under mining concession"],
      "hits": 7
    }
  ]
}
  • configured: false means the durable store (Upstash) is not wired on that deployment: gaps is then that instance's in-memory ring, neither shared nor persistent. It is declared rather than pretending a shared backlog exists.
  • hits is how many times that same gap has been raised. The id dedupes on the shape of the gap (country + axis + empty tools), not on the wording, so the same hole asked two ways increments a single ticket. That demand ordering is the ingestion queue.
  • candidate_sources only contains URLs an external tool actually returned in that same turn. Never a source the model named from memory.

No user text, by design

A ticket describes the hole, not the person who found it. It carries the country, the axis, the route, the tools that came back empty and the missing indicators; every one of those fields is drawn from platform-controlled vocabulary (ISO3 codes, axis slugs, tool keys, baked indicator labels).

The ticket neither persists nor serves the user's question. It is read only to establish that a real turn happened, and then dropped. Because this endpoint is public and unauthenticated, anything the ticket kept would be something the asker published without meaning to — and the consent posture of the Data Trust has to hold for the people using the chat, not only for the institutions contributing to it.

The text is kept, but elsewhere and under different rules: the private question log lives under a different key (chat:questions:v1, never backlog:gaps:v1) and is readable only with Authorization: Bearer $QUESTION_LOG_TOKEN — with no token, GET /api/questions refuses everything. No public path reaches it: the backlog rebuilds every ticket field by field, so this endpoint's guarantee is exactly what it was.

The guarantee is enforced on read as well as on write: tickets written before this rule are still in the store, so readGaps rebuilds each row field by field against an allow-list and drops everything else. A newly added field cannot reach the public surface by accident either: it has to be added there on purpose.

There is no write surface. Tickets are written by the chat server, in the same process that observed the empty retrieval; no browser-reachable write path exists.

Endpoint reference

PathWhat
/api/v1/index.jsonManifest: datasets, counts, bulk sizes, license, standards
/api/v1/openapi.jsonOpenAPI 3.1 spec of the read API (15 core paths)
/api/v1/schemas/*.schema.jsonJSON Schema: manifest, parameter, geography, observation-cell, citation
/api/v1/parameters.json10 pillars + primary/secondary SDGs
/api/v1/geographies.json25 countries + LATAM, ISO3 + M49
/api/v1/observations/<param>__<ISO3>.jsonOne cell: indicators, series, citations, SDG
/api/v1/observations/<param>__<ISO3>.csvSame cell, one row per indicator-year
/api/v1/observations.csvBulk: every cell concatenated
/api/v1/observations.sdmx.csvBulk, SDMX-CSV (ISO 17369), REF_AREA = M49
/api/v1/futuros-data.xlsxWorkbook (README/parameters/geographies/observations/citations)
/api/v1/citations.json · .csvFull citation registry
/api/v1/freshness.jsonPer-source refresh ledger
/api/v1/sdg-crosswalk.jsonPillar → SDG + indicator → official SDG code
/api/v1/signals/<param>__<ISO3>.jsonCited signals per cell (260/260 cells; the 10 regional __LATAM cells are [])
/api/v1/governance/{resilience,regulation-index,scores}.jsonGovernance Engine: democratic resilience, regulatory observatory, composite index
/api/v1/contributions.jsonLicense-gated contributed data
/data.jsonDCAT catalog (site root)
/feeds/futuros.xml · /feeds/<ISO3>.xml · /feeds/<param>__<ISO3>.xmlStatic RSS 2.0 signal feeds, re-baked on every deploy by bake-feeds inside prebuild
/feeds/index.jsonManifest of the 267 RSS feeds, with the counts of what was omitted
GET /api/backlogData-gap backlog (live function, not baked) — see above
GET /api/env-leversPublic posture of the 15 fail-closed production levers: per lever, its id, the variables that compose it, opens (what it enables) and probe (how to verify it from outside), plus a set boolean. Never returns secret values; Cache-Control: no-store. It is the machine-readable surface behind /confianza — see self-hosting

For agentic, tool-based access to the same corpus (with deterministic compute and citations), see the MCP server.

Crawling policy

The data is open by design, so crawling is not blocked — but it is declared and observed:

  • robots.txt (https://futuros.xyz/robots.txt) allows everything except /admin/ and an internal trap path. AI-training crawlers (GPTBot, ClaudeBot, CCBot, Bytespider, etc.) are explicitly allowed today; per-bot policy is a one-line change in that file (the token list is maintained in server/scrape/known-agents.ts).
  • Scraping the HTML is pointless: every app route returns the same SPA shell with no content. The machine surfaces are this API, the MCP server and the DCAT catalog (/data.json) — use those.
  • Access is observed, never blocked: an observation-only middleware (middleware.ts + server/scrape/, runbook at server/scrape/README.md) flags bulk-scrape and AI-copy patterns as internal events. No request is slowed or refused by this layer.

Every figure with its source — traceability is the contract.