Saltar a contenido

Changelog

All notable changes to Tarno are documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. The public read contract is versioned independently under the /v1 URL prefix — see Versioning & deprecation for how a release maps to a contract version.

[Unreleased] — v1.6 — performance refinements

Added

  • One ledger instead of three: backfill_ledger. Las pasadas de operador que reescriben el corpus en masa (el centinela nro_registro, la canonización de persona, la fusión de personas duplicadas) guardaban su pre-imagen en tres tablas distintas, cada una con su forma, su restricción de unicidad y su fichero de reversión. La migración …116 las sustituye por un solo libro, backfill_ledger(operacion, entidad, clave, lote, antes, aplicado_en, revertido_en), con la pre-imagen como documento jsonb y sin ninguna clave foránea: una cascada se llevaría por delante exactamente la evidencia que la tabla existe para guardar.

Los tres libros anteriores se absorben sin perder una sola pre-imagen —una puerta de conteo dentro de la migración pone el despliegue en rojo si falta una— y no se borran: su retirada va a un plan posterior. La bajada de la migración se niega a borrar pre-imágenes que no existan en ningún otro sitio.

revertido_en es capacidad nueva: una fila revertida deja de ser indistinguible de una que nunca se tocó, y las reversiones sellan sólo lo que de verdad deshicieron, así que una reversión que sale corta se ve en vez de disfrazarse de completa.

Sin efecto sobre el contrato de lectura: es una tabla interna de operador.

  • The report no longer contradicts itself about a granted trademark. GET /v1/brands/{nro}/report and its MCP twin gain two additive fields: estadoDerivado (omitted when the corpus has nothing to add) and estadoDesactualizado (a boolean, always present). They answer the question a consumer actually has — is INAPI's word still true today? — without touching the word itself.

The strongest argument for them is the timeline. INAPI assigns RegistrationNumber, RegistrationDate and ExpirationDate before it flips EstadoDescripcion/Status, and it does so in every one of its sources. Measured 2026-08-27: the Buscador returns registrada on day 4 for 88 % of marks, day 6 for 99,9 %, and only reaches a stable 100 % from day 9. The grant annotation from the Estado Diario, meanwhile, is in the corpus the same day it is declared (4.564 of the last 30 days' grant annotations were ingested on day 0). So the honest answer can be given on day 0, for free — the knowledge was already loaded and was being thrown away.

Both fields are computed from four witnesses report.ts already loads in the same query (the M5/158/1230 annotations, the real nro_registro, clases[].estado, fechaRegistro): zero new reads, zero new requests. estado keeps coming back verbatim from INAPI — the corpus is neutral and no existing consumer breaks. REST and MCP are asserted identical by presence and by value.

  • The Sheets diff now fires on the registration number, days before the status flips. The daily open-data diff used to enqueue a mark for re-reading only when Status changed — and Status is the last field INAPI flips, in all of its sources. It now also enqueues when the CSV carries a RegistrationNumber the corpus does not have, under its own reason (registro_new) so the cohort stays countable.

Why this is the better signal, measured 2026-08-27 on the live CSV: 544 rows carried a real RegistrationNumber while still saying Status='En Trámite' (16,9 % of the 3.225 rows with a number) — and all 544 shared the same LastUpdatedDate. INAPI wrote the number, the date and the expiry, and left the old status behind in the same write. Their RegistrationDate is 7-9 days earlier, so the number surfaces ~2-4 days after registration, comfortably before the Buscador flips the status on day 4-6. The predicate runs the corpus's previous value through the same normalizer as the ingest, so it also fires on rows still holding the text sentinel described under Fixed. Enqueuing is free; the drain that already runs pays for the re-read. No migrationreason is an unconstrained text column.

  • A coherence rule re-reads marks whose grant annotation contradicts their status. The weekly re-visit pass (FRESH-10) gains a second selection: marks whose grant annotation (M5, 158 or 1230) is older than a 4-calendar-day lag while instancia.estado is still pre-grant get re-queued on the Buscador queue, with their own cooldown and cap in a sibling stamp table.

The lag is measured, not guessed. Anchoring on the annotation date with no lag wastes ~40-50 % of the requests: mark 1655190 had its grant annotation at 19:38 and an authoritative Buscador re-read at 22:03 the same day, and INAPI still answered en_tramite. Annotation + ~4 days lands on day ~9, where INAPI is at 100 %. Anchoring on the annotation rather than on fecha_registro also works for the 62 marks the Buscador has never read, and anchors on the fact that actually proves the grant.

Scope: the 270 genuinely stuck marks (spread from 2009, peaking at 97 in 2016) plus a shortened delay for the 725 that were already inside the healing window. The job costs zero requests of its own — it only writes the queue and its stamp table; the daily drain pays the re-read, inside its existing budget, IP rotation and circuit breaker. A partial index on anotacion is the real access path, confirmed by EXPLAIN (a bitmap index scan, no sequential scan). It runs inside the CLI the box's cron already invokes: no deploy change, no new cron, no new job.

  • The section catalog now names and groups 114 more codes — so seccionTipo stops coming back null for the Estado-Diario M family and for a large slice of everything else. M1M14 had never had a row in seccion_catalogo, and neither had 491 other section codes covering 1,263,393 annotation rows (11.9 % of the corpus), which reached consumers with both seccionTipo and seccionNombre empty.

The names are not invented: INAPI publishes them in EstadoDescripcion on every Buscador detail and the ingest pipeline was discarding them for any code outside M1..M14. They were recovered from the raw payloads already stored in raw_source — 13,856 marks, no new requests. Across the two migrations, annotation rows resolving a seccionTipo go from 88.1 % to 95.4 %. The M family's nombre is derived from tipo_anotacion rather than transcribed, so the two catalogs cannot drift and no third spelling enters circulation.

M6, M7, M8 and M11 deliberately keep a null tipo (106,951 rows). Whether M6 is a notified act or an Estado-Diario section header — and whether M7/M8 mean "filed" or "resolved" — decides which annotation a legal deadline runs from, and that is not a gap to fill by pattern-matching. They now have a row, so the catalog distinguishes "unknown code" from "known code, grouping pending".

Two downstream effects worth knowing before the next sweep: seccionTipo becomes non-null on 459,387 rows at once, and GET /v1/brands/:nro/opposition gains 25 codes in the oposicion group, so opposition timelines that were coming back short now carry events that were always in the corpus. ventanaOposicion and GET /v1/oppositions/open are unaffected.

  • Persons in the report now carry comuna and region; annotations carry id. Additive on both GET /v1/brands/:nro/report and GET /v1/brands/:nro, identical over REST and MCP.

comuna/region are CUT codes, not names, and never the street address — which is not exposed. They come without a leading zero ('13', '13114', '5109'); left-padding the comuna to five digits and taking the first three yields the province, so scoping by province needs no lookup table. 99 / 99999 / 999 is the unknown-or-foreign sentinel, not a real region. Measured coverage on the report surface: 70.0 % of holders, 96.4 % of representatives.

anotacion.id is stable — corpus writes are append-only, so a row keeps its id for as long as it exists. What it does not fix is documented alongside it: see below.

  • The holiday calendar now states which years it covers — and refuses to answer outside them. GET /v1/feriados/cobertura (scope insights:read, MCP twin get_feriados_cobertura) returns { "from": "2013-01-01", "through": "2030-12-31" }, cacheable for an hour. It is not the min/max of the rows: coverage is an explicit assertion, one row per year, and it only advances when a year is complete — a half-seeded year would otherwise yield a perfectly credible maximum and a consumer would freeze a calendar that is 5 % done. The published range is the contiguous run from the earliest year, so a gap truncates it instead of spanning a hole it does not admit to, and it ends on 31 December because the year is covered whole.

Asking GET /v1/feriados for a range beyond it now returns 422 fuera_de_cobertura carrying coverage in the error body, never a 200 with an empty or partial list. The distinction is the whole point: "no holidays that year" leaves business-day arithmetic correct, while "that year is not loaded" degrades it to weekends-only and returns a deadline LONGER than the real one with nothing failing. An open end (?desde=… alone) is not an error, and inside the range an empty list still means empty. MCP returns the same code and the same coverage.

Note for existing consumers: the coverage block in ?envelope=true now reports this declared range (2030-12-31) instead of the last row's date (2030-12-25).

  • The holiday calendar refreshes itself weekly. A cron keeps the rolling window [today, today+2y] current so a newly enacted ad-hoc holiday, or an election that moves from its planned date to the one actually held, lands without waiting for a release. Regular holidays are derived locally from the permanent rules rather than read from a feed; exceptional ones are written only when two independent sources agree; and rows the loader itself wrote that no source confirms any more are removed — without that last part a postponed election's stale date would linger forever, lengthening deadlines. Detection of exceptional holidays reaches as far as the shorter source publishes (~16 months today); regular ones cover the whole window.

  • Search faceting + highlight (REQ-F1). GET /v1/brands (and the MCP search_brands tool) gain an OPTIONAL facets block via a new ?facets=clase,estado param — per-class / per-estado counts scoped to the current search (the bounded ranked set on a text search; the filtered corpus on a listing). Omitting the param is byte-identical to today (no facets field); an unknown dim is a 400 validation_error. Search items also gain an OPTIONAL highlight span ({ field:"nombre", start, end }) marking the LITERAL accent/case-insensitive match of the query in the denominación — absent on phonetic/semantic-only hits and on the listing/find-similar paths. Both are additive (no /v1 schema change to existing fields) and identical on REST and MCP.

  • ?envelope=true on /v1/freshness and /v1/sync-runs + new MCP tools (REQ-F4). The two endpoints gain an OPT-IN ?envelope=true mode returning the additive { data, dataAsOf? } envelope; the DEFAULT response is the UNCHANGED bare array (its deprecation/flip is a later phase). Two NEW MCP tools — get_freshness and get_sync_runs (scope insights:read, 1 billable unit) — return that envelope natively, byte-identical to the REST enveloped mode. No existing consumer is affected.

  • Idempotency-Key on POST /v1/watch — safe create retries (REQ-F3). An OPTIONAL Idempotency-Key request header (mirrored on the MCP watch_register tool as idempotencyKey) makes a retried watchlist create SAFE: the SAME key + SAME body replays the original 201 (the same watchlist id and signing secret — no second watchlist is minted); the SAME key + a DIFFERENT body returns 409 idempotency_conflict; no key = the current behaviour, unchanged. The reservation of the key and the mint of the watchlist happen in ONE transaction, so a concurrent retry serializes on the key (no double-mint). Keyed per owning org; the stored response has a TTL of 24 h (the retry window). Additive — no /v1 contract-schema change. Applied identically on REST and MCP (both call the same core seam). Consumer action: send a unique Idempotency-Key per logical create and retry with the SAME key + body on a network failure. See docs/auth.

  • Opt-in CORS via IRIS_CORS_ALLOWLIST (REQ-F5). Tarno stays server-side-only by default — the API key is a server-side credential, so with the allowlist unset/empty the server emits no Access-Control-Allow-Origin header (byte-identical to today, zero behavior change). An operator can now opt specific browser origins in per deploy via the IRIS_CORS_ALLOWLIST env var (comma-separated exact origins) — only those exact origins are reflected (never a wildcard, credentials:false). Added via the official @fastify/cors plugin. The MCP transport does not use CORS (server-to-server, not a browser fetch). Additive — no /v1 contract-schema change. See docs/auth.

  • Per-key burst rate-limit tier — a new 429 rate_limited condition (REQ-E2). A SECOND rate-limit tier keyed on the key hash now caps a single tenant at 300 requests / 60 s by default (IRIS_KEY_BURST_MAX / IRIS_KEY_BURST_WINDOW_SEC), on top of the existing monthly quota and the per-IP flood defense. It is additive — there is no /v1 contract-schema change; it only adds a new 429 response with code: "rate_limited", message: "Too many requests (per-key burst limit)" and a Retry-After header. Applied identically on REST and on the MCP (both transports share the same counter), and independent of the client IP, so a rotating IP pool cannot dodge it. Consumer action: honor Retry-After and retry after that many seconds (distinct from the monthly quota_exceeded, which resets at the month boundary). See docs/auth.

Fixed

  • INAPI's "no registration number" sentinel was being stored as the literal text '0', in 61.618 rows. The Buscador mapper did String(NumeroRegistro).trim() || null — and "0" is a truthy string, so the guard never fired. The same hole existed in the Sheets mapper and, for a different reason (Number.isFinite(0) is true), in the legacy-mongo one. All three now share a single normalizeNroRegistro predicate that imports the sentinel literal from @iris/core, so the read side and the write side can no longer drift apart — which is the fault that opened this whole phase.

It broke two separate things. The read contract promises to omit nroRegistro when there is no number, and instead emitted "0", which a consumer renders as a registered mark numbered zero. And because the stored value was not NULL, the fill-nulls precedence (COALESCE(instancia.nro_registro, EXCLUDED…)) could never overwrite it — measured: the daily CSV already carries the real number for 326 of those rows (counting 2026 filings alone) and was discarding it, every single day.

Cleaning the 61.618 already-stored rows ships as an operator CLI, not a migration — deliberately. deploy.yml applies migrations on deploy, which would run the pass with no dry-run, no prior count and none of the decision window the change requires. It defaults to counting without writing, commits per batch (a single transaction over 61.618 rows would be a very long lock), resumes itself because its predicate is the sentinel, and writes a reversal row before each update in the same transaction, so an updated row cannot exist without its undo record. Runbook: deploy/NRO-REGISTRO-BACKFILL.md.

⚠ Accepted side effect (D-15) — a movement avalanche on the next authoritative re-read. Normalizing the sentinel changes the content hash, so the next authoritative read of each of the 61.618 marks emits a movement_log row of entity_type='instancia' — and that is the channel that feeds the watch sweeps. This is accepted, not overlooked, and it is bounded by the consumer's 30-day recency floor: only recent marks can surface as alerts. No mitigation is added to the watch engine (D-17): it works today and this phase does not touch it.

⛔ Nulling the content hash on the touched rows is REJECTED (D-16) — this is a closed door, not a pending optimization. It looks like the obvious way to suppress the avalanche above. It is not: setting content_hash = NULL would move all 61.618 marks into the seed branch of the worklist and trigger a paid re-drain against INAPI and Bright Data. If you are reading this because you are looking at a wave of movement rows and want to make it stop — that is the expected behaviour described in the paragraph above, and this is the fix that must not be applied.

Three new migrations ship with the above, all additive and all with a down: …0075_anotacion_seccion_fecha_idx (the partial index the coherence rule selects through), …0076_buscador_coherencia_state (the coherence rule's own stamp table — a sibling of buscador_revisit_state, not a column moved inside it) and …0077_nro_registro_backfill_audit (the reversal list; without it a '0'→NULL leaves no trace, since afterwards a row with no number is indistinguishable from one that never held the sentinel). The backfill pass itself is not among them, for the reason given above.

  • The calendar reached back only to 2023, so any deadline anchored on an older resolution was computed against zero holidays — silently longer than the real one. It now covers 2013-2022 as well (176 national holidays, cross-checked against two independent sources and a from-scratch derivation of the permanent rules). Measured over the corpus: 265.762 publications (79,6 % of that decade) had a wrong fechaLimite.

  • Three weekday holidays were missing from 2027-2030 (2027-09-17, 2029-09-17, 2030-09-20) because the extra Fiestas-Patrias holiday was read as an ad-hoc law when it is in fact a permanent rule (Ley 20.215 and Ley 20.983). For the same reason 2023-01-02 and 2024-09-20 were classified excepcional when a loader can reproduce them: both are now regular. The origen of 2024-09-20 cited Ley 21.681, which creates the wildfire emergency fund and has nothing to do with holidays — the real source is Ley 20.215.

Changed

  • BREAKING — a brand no longer reports former holders as current. GET /v1/brands/{nro}, /v1/brands/{nro}/report and their MCP twins now return only the CURRENT holders and representatives in personas; the ones a transfer ended move to a new personasHistoricas array carrying retiradoEn (the date INAPI's transfer annotation says ownership moved) and retiradoPor (the annotation that proved it). Searching by titular, representante or rut, the marcasTotal on /v1/personas, and the by-holder insights all follow the same rule.

Why it is worth breaking: instancia_persona had no notion of "still true", so a mark that changed hands kept both parties and nothing said which was current. Production mark 850587 (KANGOL) listed Kangol Limited and Kangol LLC side by side — the first stopped owning it on 2025-10-14. A consumer had no way to tell, and a search for the former owner returned a portfolio they no longer had.

The history is served rather than hidden, in an array of its own rather than behind a boolean on the existing one: the failure being fixed is exactly that a current and a former holder looked alike, and a flag a consumer is free to ignore would leave that intact. personasHistoricas is ALWAYS present, empty for the overwhelming majority of marks — a non-empty one is itself the signal that a mark changed hands.

Asking the mirror question. GET /v1/brands (and /v1/brands listing, and their MCP twins) take a new vigencia parameter that scopes titular/representante/rut: vigente (the default — who holds it now), historica (what this person USED to hold, otherwise unreachable now that the default filters retired relations out) and todas (both, the pre-validity behaviour, now something a caller opts into rather than gets by accident). It scopes only those three filters, so supplying it without one is rejected rather than silently ignored — a caller who asked for historica and quietly got the default would read a list of CURRENT marks as a person's history.

GET /v1/personas gains marcasHistoricas alongside marcasTotal, so the latter now counting only current relations is never a silent subtraction: a non-zero value is the cue that history exists and can be listed with ?titular=<nombre>&vigencia=historica. 2.406 people in production carry any at all.

Co-ownership is untouched. Of the 47.298 marks with two or more holders, 38.659 (82 %) have no transfer annotation at all: they are jointly held, and both parties stay current. Retirement requires a transfer annotation naming the acquirer, never a "latest wins" guess.

  • The nightly Buscador drain now pays ONE request per mark instead of two (internal; no contract change, no consumer action). The detail endpoint needs three INAPI values — ApplicationSeq/Type/Serie — and the drain used to spend a whole extra POST (FindMarcas) every night rediscovering them for each mark. At ~600-1.200 marks enriched per night that was ~600-1.200 Bright Data requests thrown away daily.

The three values already arrive free in INAPI's open-data CSV, so they are now persisted on instancia (migration …066, nullable) and read straight from the corpus at drain time. A mark whose keys are known is fetched with one request; a mark missing any of them still falls back to the two-request path — a gap costs money, never coverage. The per-pass log line buscador_pass_cost reports oneRequestItems so the saving can be reconciled against the bill.

Two things deliberately did not change. The keys are call plumbing, not corpus content, so they sit outside content_hash and emit no movement_log row — otherwise the one-off backfill that populates them would have announced an update on ~1,1M marks and flooded the watch sweeps with re-alerts for marks nothing happened to. And the per-invocation anti-ban bound is unchanged: a pass still stops after the same number of marks it always did, so the discount shows up as money not spent rather than as a longer nightly run.

  • find-similar lexical channel: trigram → BM25 (behavioural, no contract change). The lexical retrieval channel of GET /v1/brands/{nro}/similar, the by-text similar endpoint, and the MCP find_similar_brands twin migrated from trigram word_similarity to the SAME BM25 top-k (pdb.fuzzy(2)) that /v1/brands search has used since 2026-07-22. Consumer impact:
  • Result ORDER may shift slightly for a given query — the response schema is unchanged (same fields, same matchedBy: 'fuzzy' label, same 0–1 score/signals contract). This is a behavioural refinement, not a breaking contract change: no /v1 version bump, no deprecation window, no consumer action required.
  • Very short (<5-char) partial/prefix queries lose some recall via the lexical channel — the same KNOWN GAP already live in /v1/brands since the cutover (BM25 fuzzy is whole-token Levenshtein-≤2, not substring/prefix). This is SOFTENED end-to-end: the unchanged phonetic channel and per-candidate signals scoring backfill it, and any residual noise floors to lowConfidence.
  • The phonetic (over-weighted, W_PHON=2.0) and semantic channels and the RRF fusion are unchanged; REST and MCP stay in lock-step (both call the same core), so the shift is identical on both.

Deprecated

  • Contract consolidation (v1.6) — the four breaks are now APPLIED and the deprecated params RETIRED (REQ-G1..G6). The denominacion, fechaDesde and fechaHasta parameters were removed from the /v1 contract and estado is now a validated 14-code enum — the strict contract is unconditional (no flag). Each break is identical on REST and MCP:
  • BR-1 — denominacionnombre (applied). The deprecated lower-priority text alias was retired from GET /v1/brands + search_brands. q (general text) and nombre (field-scoped) are unaffected — field-scoped search is NOT removed.
  • BR-2 — fechaDesde/fechaHastafechaPresentacion* on /v1/brands (applied). The legacy date bounds were retired; use fechaPresentacionDesde/fechaPresentacionHasta.
  • BR-3 — fechaDesde/fechaHastafechaPresentacion* on the 5 /v1/insights/* aggregations (applied). Same as BR-2, on the insight surface (the canonical fechaPresentacion* remains).
  • BR-4 — estado is a validated 14-code vocabulary (applied). An out-of-vocabulary estado returns 400 validation_error unconditionally (the codes: en_tramite, observacion_de_fondo, publicada, oposicion, concedida, registrada, esperando_renovacion, rechazada, denegada, desistida, abandonada, anulada, caducado, vencida). find-similar/oppositions lose date filtering entirely (they had no canonical date param — an accepted residual, additive later).
  • A straggling retired param is DISCARDED silently (querystrings are not .strict()) → 200 with broader results, never a 400. Consumer action: use nombre and fechaPresentacion*, and keep estado in the 14-code vocabulary. See the v1.6 migration guide.
  • Related accumulated behavioural notices (this milestone): find-similar lexical channel moved to BM25 (result order may shift, schema unchanged — see the ### Changed note above); the new per-key burst tier adds a 429 rate_limited condition (honor Retry-After — see ### Added). Both are additive and covered by the migration guide.

Removed

  • Deprecated-contract machinery + the IRIS_STRICT_CONTRACT flag (cleanup, quick-260726-ldl). Now that consumers have migrated, the entire deprecation apparatus was removed: the IRIS_STRICT_CONTRACT flag (strict behavior is now unconditional), the RFC 8594 deprecation signals (Deprecation/Link/Sunset headers) and the OpenAPI deprecated: true marks, and the retired denominacion/fechaDesde/fechaHasta parameters. The contract-flags.ts module, the deprecated_param/conflicting_alias error branches, and the flag wiring in the deploy compose are gone. No consumer action — a straggling retired param is silently discarded (200), and an out-of-vocabulary estado is a 400 validation_error.

[v1.5.0] — 2026-07-23 — server-side sort + offset pagination (additive)

GET /v1/brands (and the MCP search_brands twin) gains a richer server-side sort vocabulary and offset pagination. Fully backward-compatible: a request with no sort (or the existing relevancia/recientes/antiguas) and a keyset cursor behaves byte-for-byte as before — no consumer action required. The existing brands:read scope covers everything; no new scope.

Added

  • Richer sort vocabulary (campo:direccion grammar; the three v1.4 tokens stay as aliases):
  • relevance (alias relevancia, the default) — RRF relevance with text, else newest-first.
  • fechaPresentacion:desc / :asc (aliases recientes / antiguas) — by filing date.
  • fechaActualizacion:desc / :asc — by the record's last update (updated_at).
  • denominacion:asc / :desc — alphabetical by mark text (accent/case-folded).
  • estado / estado:asc / estado:desc — grouped by the trámite lifecycle (en trámite → observación de fondo → publicada → oposición → concedida → registrada → esperando renovación → then the terminal/negative states; unknown/NULL last), not alphabetically.
  • An unrecognized sort value returns 400 validation_error (never silently ignored).
  • Offset paginationoffset + limit alongside the existing keyset cursor. Available for every non-relevance sort; nextCursor is null in offset mode and the consumer increments offset. estimatedTotal is returned in every mode. Offset is capped at 10 000 (offset above it → 400 validation_error) to protect the database — beyond that, narrow the query with filters.
  • Relevance stays keyset-only (the RRF ranking has no stable offset), and the keyset date sorts keep working exactly as before.
  • Conflicts return 400 validation_error (nothing is silently ignored): cursor + offset together; offset with sort=relevance; cursor with an offset-only sort (fechaActualizacion / denominacion / estado).
  • New btree indexes back the offset sorts (updated_at, the estado lifecycle ordinal, and the accent-folded denominación) so a sorted listing over the full corpus is an ordered Index Scan, never a full sort. REST≡MCP by construction; SELECT-only; 0-RMC.

[v1.4.6] — 2026-07-23 — insight: activity by representante (additive)

A new additive insight endpoint. Nothing about the existing contract changes; this only adds a surface, so no consumer action is required.

Added

  • GET /v1/insights/by-representante (and the MCP twin insights_by_representante) — the exact mirror of by-holder, scoped to the representante role instead of the holder: distinct marks per representante (the roles representante and ambas; a pure titular is excluded), ordered by mark count descending, capped at topN (≤ MAX_LIMIT). A lead-gen surface over the law firms / agents that file on behalf of holders. Each row is { representanteId, nombre, apellido, pais, n } — neutral person columns only. It shares the same filters (estado / tipoSigno / fecha / clase) and the same { data, dataAsOf?, coverageThrough? } freshness envelope as the other insights. Scope insights:read; REST≡MCP by construction.
  • Multi-attribution caveat (the same intentional behaviour as by-class / by-holder): a mark can have several representantes, and it is counted once for each — so Σ(per-representante) exceeds the number of distinct marks. This is by design, not a double count.

[v1.4.5] — 2026-07-23 — batch brand lookup (additive)

A new additive read endpoint. Nothing about the existing contract changes; this only adds a surface, so no consumer action is required.

Added

  • POST /v1/brands/batch (and the MCP twin get_brands_batch) — resolve up to 100 application numbers to their enriched summaries in one call, instead of N single-brand GET /v1/brands/:nroSolicitud round-trips. The body is { "nros": [ … ] }; the response is { items: BrandSummary[], notFound: string[], dataAsOf? } — the found summaries in request order (the same enriched BrandSummary the listing returns: clases, titular, enrichmentPending), plus the requested numbers that do not exist in the corpus. Repeated numbers collapse to one. It costs a single quota unit per call (not one per number) — the point is to avoid the N+1. Scope brands:read. A batch over 100 numbers returns 422 invalid_input; REST≡MCP by construction. See the field-scoped search guide → Batch lookup.

[v1.4.4] — 2026-07-22 — search engine: BM25 + latency fixes (non-breaking)

Internal upgrades to the text-ranking engine behind GET /v1/brands and the search_brands MCP tool. The public read contract is unchanged — request parameters, the BrandSummary / matchedFields[] response shape, the matchedBy channel labels (exact / fuzzy / phonetic / semantic), error codes and keyset pagination are all byte-identical. No consumer action is required; existing calls behave the same, only faster and with better matching.

Performance

  • Common-term latency cut. A common query term (e.g. cafe, chile), especially combined with a clase filter, is now bounded to roughly ~1.5–2s warm — previously such queries could tip over into a 504 under concurrency. The four retrieval channels now fan out concurrently (wall-clock = the slowest, not the sum), the HNSW vector channel no longer over-scans, and the fuzzy channels filter by class first before ranking.

Changed

  • Lexical text matching moved to BM25 (via the ParadeDB pg_search engine) in place of the prior full-text + trigram channels. In practice this means cleaner typo tolerance — e.g. santader surfaces Santander marks without dragging in unrelated santa… results — and better multi-word ranking: a query like banco chile now rewards marks that contain both words. Result ordering can differ slightly from before for the same query; measured relevance on real brand queries is comparable or better.
  • Caveat — very short partial queries. BM25 fuzzy matches whole tokens within an edit distance, not prefixes, so a very short fragment (≤4 letters, e.g. kolo) no longer prefix-matches longer marks such as KOLORES the way trigram did. For best recall, search full or near-full brand names; improving short-partial recall is a tracked follow-up.

[v1.4.3] — 2026-07-20 — round-2 deep-review remediation

Remediation of a second multi-agent deep review (.planning/reviews/2026-07-20-deep-review-round2.md, 45 verified findings) that targeted the round-1 blind spots (jobs worker/cursors, Estado-Diario PDF parse, embeddings/e5 sidecar, Traefik/deploy infra, supply-chain) plus the v1.4.2 surface. 5 P1 + 19 P2 + 11 P3 fixed; 4 product-scope features + infra hardening deferred to Backlog.

Fixed — P1 (urgent, deployed as a hotfix)

  • MCP watch surface restored in prod — the mcp compose service was missing SUPABASE_DATABASE_URL, so all 9 watch_* tools returned service_unavailable while REST /v1/watch worked (parity break).
  • Pre-auth reader-pool flood closed — the API auth gate moved from an onRequest hook to a preHandler, so the rate-limiter (onRequest) now deterministically fronts the verifyApiKey SELECT (FIX-02 was inert because auth ran before the deferred rate-limit plugin's hook).
  • MCP burst limiter re-keyed by real client IP (was the attacker-rotatable X-API-Key).
  • rotateApiKeyTx now carries user_id, per-tenant watch caps and blocked_at (rotation no longer drops the user block cascade / resets caps / re-arms a blocked key).
  • P2-1 vector pool wiredcreateVectorReaderPool is now instantiated and vectorSql injected into searchBrands on both transports (had shipped inert / tests-only).

Fixed — P2/P3

  • core/mcp/api: findSimilarCore vectorSql seam threaded to both transports; watch_add_item and createWatchlistWithItems caps enforced under an advisory lock (TOCTOU closed); extractApiKey env fallback gated to stdio-only; MCP /metrics bearer-gated (METRICS_TOKEN twin of REST P2-5); oversized MCP body answers 413 instead of building a dead server; F-3 key setLastSeen wired into both auth paths (was inert); watchlist wire exposes baselineStatus + lastSweptAt liveness.
  • jobs: job_result idempotency (unique constraint + dedup), SERVICE_SECRET ≥32B floor, watch_hit retention purge, baseline opt-in survives sweep failure, webhook push anchored via last_pushed_hit_id, baseline hash chunked, watchlist cursor skew margin.
  • sync: FRESH-10 aged-stub revisit terminates (bounded revisit_count), Buscador bootstrap routed through the circuit-breaker, MOVEMENT cursor anchored to the DB clock, embedding invalidated on denominación change, zero-yield Sección-M page alerted/quarantined.
  • admin: cross-org API-key attribution blocked, /audit category filter + keyset pagination, canonical scope-vocabulary validation, session TTL made truly rolling.
  • db: uq_anotacion_event rebuilt NULLS NOT DISTINCT (the anotacion sibling of the persona FIX-04 bug — 0 prod duplicates, preventive); redundant idx_instancia_fecha_presentacion dropped.

Migrations applied by hand (deploy never runs them): corpus 052/053/054, Supabase db-jobs 001–004.

[v1.4.2] — 2026-07-19 — backend hardening

Professional-grade hardening of the Tarno backend: correctness, performance and a stable, single-sourced REST≡MCP contract for downstream integrators.

Fixed

  • allClasses=false is honored (P2-9). The allClasses find-similar flag no longer uses z.coerce.boolean() (which turned any non-empty querystring, including false, into true and silently widened a class-scoped request to all classes). A shared booleanFlag maps only true/'true'/'1' to true on both REST and MCP.
  • Watchlist create cap counts only live lists (P2-3). A soft-retired (DELETE) watchlist now frees its max_watchlists slot; a tenant that created and retired lists over time is no longer permanently locked out of creating new ones.
  • Bounded SSRF-guard DNS lookup (P2-6). The callback-URL safety check races the DNS lookup against a 2s deadline so a black-holing resolver can no longer stall the create/patch request.
  • Consistent MCP tool error envelope (P2-14). Auth, scope and input-validation errors on an MCP tool (unauthorized / forbidden / invalid_input — e.g. the nroSolicitud xor q rule) now return the SAME structured { error: { code, message } } tool result that every other MCP error already uses, instead of a bare plain-text message with no code. A client can now JSON.parse(text).error.code uniformly across ALL MCP tool errors, matching the REST 401/403/400 {error:{code}} shape (REST≡MCP). Observable wire-shape change for these three error cases only; success results are unchanged.
  • Admin member status flip is scoped by its org (P2-11). PATCH /orgs/:id/users/:uid/status now verifies the member belongs to the :id org; a mismatched org/user URL returns 404 (and rolls the flip back) instead of silently flipping the member under the wrong org's URL and returning 200. The audit row now also carries the member's consumerId, matching the sibling org-scoped routes.
  • Watch-sweep signal watermark is scoped to the run (P2-21). syncIncremental emitted a watch_sweep_signal whenever ANY entity changed (totalChanged > 0), carrying a run_watermark computed as a GLOBAL max(occurred_at) over all instancia history — so a run that only touched personas/anotaciones still emitted a signal bearing a stale instancia watermark from a prior run (an idle sweep, and a cursor-rewind risk if the downstream worker ever assigned rather than max()-ed the watermark). The watermark is now scoped to movements this invocation produced (occurred_at >= run-start, captured from the DB clock), and emission is skipped entirely when no instancia movement landed this run.
  • Buscador movement cursor no longer skips mid-pass events (P2-22). The Buscador committed its enrichment cursor as the wall-clock time at pass END, but its work-list MOVEMENT branch selects movement_log.occurred_at >= last_enriched_at. An estado_diario movement recorded AFTER the work-list snapshot but BEFORE that pass-end instant was invisible to the current run yet excluded from the next (occurred_at < since) — missed forever. The cursor now advances to the instant captured BEFORE the work-list snapshot, so any movement landing during the pass is re-scanned next run (idempotent re-enrichment) instead of lost.

Security

  • SESSION_SECRET entropy floor enforced at boot (P2-7). The Admin API now rejects a SESSION_SECRET shorter than the documented 32 bytes with a clear boot error, instead of silently deriving a low-entropy sealing key (static-salt derivation makes the secret the sole entropy source) for every operator session cookie and CSRF token. Byte-length, not char-length, is checked. Operators must set a >= 32-byte secret (e.g. openssl rand -base64 48).
  • In-app /metrics scrape guard (P2-5). GET /metrics is exempt from the API-key gate and carries per-consumer ids + per-route traffic; previously the only protection was the Traefik router excluding PathPrefix(/metrics), so a single reverse-proxy misconfig leaked the tenant id set with no in-app defense. A new optional METRICS_TOKEN env adds an in-app backstop: when set, GET /metrics requires Authorization: Bearer <METRICS_TOKEN> (401 otherwise); when absent (dev/test/CI default) the endpoint stays open so the internal Prometheus scrape and tests work unchanged. If you set it, add the same bearer to the iris Prometheus job (commented stanza in deploy/observability/prometheus/prometheus.yml).
  • Neutral MCP transport-level 500 (P2-13). A connect/transport-level failure on the MCP HTTP endpoint (and the /metrics error path) no longer echoes the raw error string to the client; it returns the same neutral { error: { code: "internal_error", message: "Internal Server Error" } } envelope the REST API emits for a 5xx and logs the real cause server-side only, closing an internal-error disclosure that bypassed the REST 5xx genericization.
  • Clearance job retention is enforced (P2-4). The async clearance store wrote a ~30-day expires_at on every job (D-11) but nothing ever deleted past it, so the off-core Supabase job / job_result tables (holding full sibling-grouped hit jsonb) grew unbounded and expired clearance results stayed fetchable indefinitely behind only the shared service secret. The worker now runs a periodic purge (DELETE FROM job WHERE expires_at < now(), every 6h, env-overridable via JOB_PURGE_INTERVAL_MS); job_result rows cascade away with their parent. Requires the new off-core migration db-jobs/20260719000001_job_expires_at_index.sql (a supporting idx_job_expires_at btree) — apply by hand against SUPABASE_DATABASE_URL (deploy never runs migrations).
  • Least-privilege service login roles (P2-8). The read (iris/mcp/jobs-worker) and admin planes connected to Postgres as the cluster superuser iris, with SELECT-only / identity-DML isolation resting ENTIRELY on a per-connection SET ROLE GUC — a single missed SET ROLE (or a stray RESET ROLE) would regain superuser against the corpus. A new migration (db/migrations/20260719000051_service_login_roles.sql) mints dedicated LOGIN roles that are members of the existing group roles — iris_reader_svc IN ROLE iris_reader (SELECT-only) and iris_admin_svc IN ROLE iris_admin (identity DML, no DELETE, zero corpus) — so the wire identity itself is already confined; the server's SET ROLE stays as belt-and-suspenders. The reader floors (statement_timeout=15s, word_similarity_threshold=0.2) are pinned on the svc login too (role GUCs are not inherited on login). Compose now prefers new box .env vars IRIS_READER_DATABASE_URL / IRIS_ADMIN_DATABASE_URL, falling back to the superuser DATABASE_URL when unset (no regression for a box that has not yet applied the migration). OPERATOR: apply migration 051 by hand, then ALTER ROLE …_svc PASSWORD '…' and set the two env vars (the migration + compose are the code; the passwords/rotation are operator-owned — see deploy/.env.box.example). Deploy never runs migrations.

Performance

  • Search vector channel isolation (P2-1). A common-term + large-clase query could make the HNSW class-filtered walk over-scan and burn the 15s reader statement_timeout (504), whose cancel risked corrupting the pooled connection. The vector channel now runs on a dedicated short-budget (~4s) reader pool (createVectorReaderPool), so an over-scan is bounded and degrades to FTS+trigram+phonetic instead of erroring.
  • Estado-Diario catch-up scan is watermark-bounded (P2-2). fetchProcessedDates ran an unbounded SELECT DISTINCT to_char(fecha,…) FROM anotacion (seq-scan + hash-distinct over the largest append-only table) on EVERY extract iteration, so a K-date catch-up drain paid K+1 full scans. It is now bounded by the cursor watermark (WHERE fecha > floor): dates at-or-below the floor are already treated as done by the drain, so dropping them from the query is provably equivalent while turning the scan into a bounded range read. A fresh corpus (no floor) keeps the unbounded fallback, which scans an empty table.
  • Caps lookup uses the api_key PK index (P2-10). The per-tenant caps read binds+casts the parameter (id = $1::bigint) instead of casting the PK column (id::text = $1), keeping the predicate index-sargable.
  • One auth SELECT per MCP tools/call (P2-12). The MCP HTTP boundary resolved the API key with verifyApiKey (an uncached 2-LEFT-JOIN SELECT) and the per-tool guard then resolved it AGAIN for the same request. The boundary-resolved record is now threaded into the per-request server and reused by the tool, halving the auth round-trips for an authenticated tool call and closing the window for the gate and the tool to disagree on a mid-request key-status flip. Still uncached (a fresh verify per request) so a status change cascades on the next request.

Contract / internal

  • Single-sourced reader pool factory (P2-15). The reader-pool GUCs, statement_timeout wiring and env parsing are exported once as @iris/core createReaderPool (+ the vector variant), replacing the per-transport duplication.
  • Single-sourced hit-pull query (P2-16). WatchHitsPullQuery (since + limit, default 50 / max 200) is exported from @iris/core and consumed by both REST and MCP, removing duplicated literals.
  • Documented date-paging ceiling with text (F-4). With a text query, sort=recientes / sort=antiguas re-order the relevance-capped fused candidate set (≈600 rows), not the full matching corpus; the no-text listing pages the full corpus by date. This ceiling is now documented in the search guide and the sort contract.
  • Narrow watch-store re-export in @iris/jobs (P2-17). packages/jobs/src/store/watch.ts was export * from "@iris/core", leaking the ENTIRE core barrel (all queries, similar-search, admin, embeddings) through a path named store/watch. It now re-exports only the watch-store surface the worker, signalPoll and the watch test suites consume, so nothing but the watch store is reachable via that path.
  • Bounded clearance store queries (P2-18). createJobsStorePool (the Supabase operational-store pool, max:3) now pins a per-connection statement_timeout (reuses dbStatementTimeoutMs, 15s), so a query already executing when Supabase stalls dies at the deadline instead of pinning one of only three connections — a stalled store write then surfaces as a throw the worker records durably (markFailed) rather than hanging.
  • Deploy schema-version gate (P2-19). deploy.yml builds+pushes and docker compose pull && up -d but never applies migrations (invariant: migrations are hand-applied with dbmate), so merging schema-dependent code with its migration could push the code to prod before the migration was applied — a skew window where a new code path hits a missing relation/column. The deploy job now runs a gate that compares the ref's newest db/migrations version against the box's last applied schema_migrations.version (over SSH) and fails the deploy (fail-closed) when the ref is ahead, with a message telling the operator to apply the pending migration by hand and re-run. Assumes the box stack is up (steady-state deploys; first boot is the manual deploy/BOOTSTRAP.md).
  • Neutrality gate matches at identifier boundaries (P2-20). scripts/neutrality-check.sh matched the private-brand token as a bare substring (grep -niE 'rmc'), so a benign future identifier that merely contains r-m-c (performCleanup, transformClass, warmCache) could redden the gate (fails closed, but a false positive). It now requires a non-alphanumeric edge on both sides, flagging only the token as a standalone identifier component, and strips INLINE trailing comments (not just full comment lines) before scanning. VIOLATIONS stays 0.

Added

  • Watchlist item-level endpoints (F-1). Edit a watched portfolio incrementally instead of recreating the whole watchlist (which minted a new id + signing secret and lost hit history): GET /v1/watch/:id/items (list, watch:read), POST /v1/watch/:id/items (add one mark, watch:write, idempotent re-add, 422 watchlist_too_large past max_watch_items) and DELETE /v1/watch/:id/items/:itemId (remove, watch:write, 204). Tenant-scoped by the key's consumer with a uniform 404 on any cross-tenant/unknown id. Mirrored as the MCP tools watch_list_items / watch_add_item / watch_remove_item over the same shared @iris/core store + Zod (REST≡MCP). See the watch guide.
  • Key last-seen usage signal (F-3). A Redis-only setLastSeen/getLastSeen keyed by the quota key hash (never a Postgres write on the read path) so the admin portal can later surface dormant or anomalous keys.
  • Operator authentication audit trail (F-2). The Admin API now records auth.login.success, auth.login.failure and auth.logout in admin_audit (previously only identity mutations were audited), so there is a durable record of who signed in and — with the shared-IP login rate-limit — a brute-force signal. Each row carries the client IP; a failure also carries the attempted email as its target, and never the password. A failure records no operator_id even for a known email, so the audit columns never leak operator existence. Auditing is best-effort (a write error is logged, never blocking the auth outcome). Contract change: GET /audit operatorId is now nullable (an auth.login.failure row has none). Requires migration …050 (relaxes admin_audit.operator_id to nullable) — apply by hand on the box (deploy never runs migrations).

[v1.1.0] — Unreleased

Turning the v1.0 read contract into a robust, sellable headless product (REST + MCP) for enterprise integrators, with manual operator onboarding. In active development; this section is finalized when v1.1 is released.

Added

  • Corpus freshness — Tarno runs its own sync. A managed cron drives the three INAPI ingestion pipelines (Sheets, Estado-Diario, Buscador) so the corpus stays current instead of being a point-in-time bootstrap. Per-source staleness is reported by GET /v1/freshness.
  • Neutral MCP server, deployed. The Model Context Protocol transport (Streamable HTTP + stdio, API-key authenticated, REST-parity tools) is promoted to a first-class deployed endpoint so AI agents can connect directly.
  • API-key management + quotas. Keys belong to a consumer (org), carry scopes (brands:read, insights:read) and an optional expiry, and are subject to a monthly request quota. Exhausting the quota returns 429 with a Retry-After header, applied identically on REST and MCP. Operators administer keys (create / rotate / revoke) through an admin surface.
  • Observability. Per-route and per-consumer metrics, structured request logs, sync-run history, and freshness/health checks for operator and tenant-facing insight.
  • Documentation portal. Getting-started, authentication, "connect your AI agent" (MCP) guide, code samples, this changelog, a status page, a versioning/deprecation policy, and an SLA document.
  • Field-scoped search on GET /v1/brands. The search endpoint (and its MCP search_brands twin) accepts a combined, AND-composed field surface — nombre/denominacion, titular, representante, rut (normalized + mod-11 validated, 400 invalid_rut on a bad check digit), nroSolicitud, nroRegistro, multi-value clase/estado/tipoSigno, and a filing-date range — all additive and backward-compatible, with REST≡MCP parity by construction. Each result item now carries an additive matchedFields[] ({ field, kind }) explaining why the mark surfaced, and a new sort= parameter (relevancia | recientes | antiguas) lets the client pick the order. See the field-scoped search guide.

Changed

  • Default order of the no-text listing. A GET /v1/brands request without free text (q/nombre) — e.g. ?clase=9 or ?estado=… alone — now defaults to fecha_presentacion DESC, nro_solicitud ASC (newest first) instead of the previous nro_solicitud ASC. This is a deliberate, observable behavior change: "newest first" is the better default for the intended clients. A caller that wants a stable date order can set sort= explicitly. In-flight keyset cursors from no-text listings reset once after this change (an old cursor= from a no-text listing falls back to page 1; simply restart pagination). Text search (q/nombre) is unaffected — it still ranks by relevance (RRF).

[v1.0.0] — 2026-06-29

The initial Tarno Backbone: a neutral INAPI trademark corpus in Postgres, exposed only through a stable, versioned read contract.

Added

  • Neutral corpus in Postgres. INAPI trademark data normalized into a neutral corpus (no consumer-specific fields), populated idempotently from the INAPI sources, with Spanish full-text search, trigram fuzzy matching, and accent-insensitive lookup.
  • Versioned read contract — REST /v1. A Fastify read API namespaced under /v1: GET /v1/brands (keyset-paginated search + filtering), GET /v1/brands/:nroSolicitud (full brand detail), and GET /v1/freshness (per-source staleness report). Every endpoint is API-key authenticated via the X-API-Key header and returns a uniform error envelope.
  • Versioned read contract — MCP. A neutral Model Context Protocol server backed by the same query layer and the same Zod schemas as REST, so both transports return identical results.
  • Live OpenAPI reference. An interactive Scalar reference at /docs, generated from the route schemas, as the authoritative per-field schema for the contract.
  • Read-only access boundary. No consumer touches the database directly; all access is through the read contract, served by a SELECT-only reader role.