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 centinelanro_registro, la canonización depersona, 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…116las sustituye por un solo libro,backfill_ledger(operacion, entidad, clave, lote, antes, aplicado_en, revertido_en), con la pre-imagen como documentojsonby 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}/reportand its MCP twin gain two additive fields:estadoDerivado(omitted when the corpus has nothing to add) andestadoDesactualizado(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
Statuschanged — andStatusis the last field INAPI flips, in all of its sources. It now also enqueues when the CSV carries aRegistrationNumberthe corpus does not have, under its ownreason(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 migration — reason 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,158or1230) is older than a 4-calendar-day lag whileinstancia.estadois 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
seccionTipostops coming backnullfor the Estado-DiarioMfamily and for a large slice of everything else.M1–M14had never had a row inseccion_catalogo, and neither had 491 other section codes covering 1,263,393 annotation rows (11.9 % of the corpus), which reached consumers with bothseccionTipoandseccionNombreempty.
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
comunaandregion; annotations carryid. Additive on bothGET /v1/brands/:nro/reportandGET /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(scopeinsights:read, MCP twinget_feriados_cobertura) returns{ "from": "2013-01-01", "through": "2030-12-31" }, cacheable for an hour. It is not themin/maxof 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 MCPsearch_brandstool) gain an OPTIONALfacetsblock via a new?facets=clase,estadoparam — 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 (nofacetsfield); an unknown dim is a400 validation_error. Search items also gain an OPTIONALhighlightspan ({ 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/v1schema change to existing fields) and identical on REST and MCP. -
?envelope=trueon/v1/freshnessand/v1/sync-runs+ new MCP tools (REQ-F4). The two endpoints gain an OPT-IN?envelope=truemode 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_freshnessandget_sync_runs(scopeinsights:read, 1 billable unit) — return that envelope natively, byte-identical to the REST enveloped mode. No existing consumer is affected. -
Idempotency-KeyonPOST /v1/watch— safe create retries (REQ-F3). An OPTIONALIdempotency-Keyrequest header (mirrored on the MCPwatch_registertool asidempotencyKey) makes a retried watchlist create SAFE: the SAME key + SAME body replays the original201(the same watchlist id and signing secret — no second watchlist is minted); the SAME key + a DIFFERENT body returns409 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/v1contract-schema change. Applied identically on REST and MCP (both call the same core seam). Consumer action: send a uniqueIdempotency-Keyper 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 noAccess-Control-Allow-Originheader (byte-identical to today, zero behavior change). An operator can now opt specific browser origins in per deploy via theIRIS_CORS_ALLOWLISTenv var (comma-separated exact origins) — only those exact origins are reflected (never a wildcard,credentials:false). Added via the official@fastify/corsplugin. The MCP transport does not use CORS (server-to-server, not a browserfetch). Additive — no/v1contract-schema change. See docs/auth. -
Per-key burst rate-limit tier — a new
429 rate_limitedcondition (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/v1contract-schema change; it only adds a new429response withcode: "rate_limited",message: "Too many requests (per-key burst limit)"and aRetry-Afterheader. 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: honorRetry-Afterand retry after that many seconds (distinct from the monthlyquota_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 didString(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 singlenormalizeNroRegistropredicate 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 reason2023-01-02and2024-09-20were classifiedexcepcionalwhen a loader can reproduce them: both are nowregular. Theorigenof2024-09-20cited 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}/reportand their MCP twins now return only the CURRENT holders and representatives inpersonas; the ones a transfer ended move to a newpersonasHistoricasarray carryingretiradoEn(the date INAPI's transfer annotation says ownership moved) andretiradoPor(the annotation that proved it). Searching bytitular,representanteorrut, themarcasTotalon/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 MCPfind_similar_brandstwin migrated from trigramword_similarityto the SAME BM25 top-k (pdb.fuzzy(2)) that/v1/brandssearch 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–1score/signalscontract). This is a behavioural refinement, not a breaking contract change: no/v1version 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/brandssince the cutover (BM25 fuzzy is whole-token Levenshtein-≤2, not substring/prefix). This is SOFTENED end-to-end: the unchanged phonetic channel and per-candidatesignalsscoring backfill it, and any residual noise floors tolowConfidence. - 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,fechaDesdeandfechaHastaparameters were removed from the/v1contract andestadois now a validated 14-code enum — the strict contract is unconditional (no flag). Each break is identical on REST and MCP: - BR-1 —
denominacion→nombre(applied). The deprecated lower-priority text alias was retired fromGET /v1/brands+search_brands.q(general text) andnombre(field-scoped) are unaffected — field-scoped search is NOT removed. - BR-2 —
fechaDesde/fechaHasta→fechaPresentacion*on/v1/brands(applied). The legacy date bounds were retired; usefechaPresentacionDesde/fechaPresentacionHasta. - BR-3 —
fechaDesde/fechaHasta→fechaPresentacion*on the 5/v1/insights/*aggregations (applied). Same as BR-2, on the insight surface (the canonicalfechaPresentacion*remains). - BR-4 —
estadois a validated 14-code vocabulary (applied). An out-of-vocabularyestadoreturns400 validation_errorunconditionally (the codes:en_tramite, observacion_de_fondo, publicada, oposicion, concedida, registrada, esperando_renovacion, rechazada, denegada, desistida, abandonada, anulada, caducado, vencida).find-similar/oppositionslose 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()) →200with broader results, never a400. Consumer action: usenombreandfechaPresentacion*, and keepestadoin the 14-code vocabulary. See the v1.6 migration guide. - Related accumulated behavioural notices (this milestone):
find-similarlexical channel moved to BM25 (result order may shift, schema unchanged — see the### Changednote above); the new per-key burst tier adds a429 rate_limitedcondition (honorRetry-After— see### Added). Both are additive and covered by the migration guide.
Removed
- Deprecated-contract machinery + the
IRIS_STRICT_CONTRACTflag (cleanup, quick-260726-ldl). Now that consumers have migrated, the entire deprecation apparatus was removed: theIRIS_STRICT_CONTRACTflag (strict behavior is now unconditional), the RFC 8594 deprecation signals (Deprecation/Link/Sunsetheaders) and the OpenAPIdeprecated: truemarks, and the retireddenominacion/fechaDesde/fechaHastaparameters. Thecontract-flags.tsmodule, thedeprecated_param/conflicting_aliaserror 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-vocabularyestadois a400 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
sortvocabulary (campo:direcciongrammar; the three v1.4 tokens stay as aliases): relevance(aliasrelevancia, the default) — RRF relevance with text, else newest-first.fechaPresentacion:desc/:asc(aliasesrecientes/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
sortvalue returns400 validation_error(never silently ignored). - Offset pagination —
offset+limitalongside the existing keysetcursor. Available for every non-relevance sort;nextCursorisnullin offset mode and the consumer incrementsoffset.estimatedTotalis returned in every mode. Offset is capped at 10 000 (offsetabove 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+offsettogether;offsetwithsort=relevance;cursorwith 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 twininsights_by_representante) — the exact mirror ofby-holder, scoped to the representante role instead of the holder: distinct marks per representante (the rolesrepresentanteandambas; a puretitularis excluded), ordered by mark count descending, capped attopN(≤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. Scopeinsights: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 twinget_brands_batch) — resolve up to 100 application numbers to their enriched summaries in one call, instead of N single-brandGET /v1/brands/:nroSolicitudround-trips. The body is{ "nros": [ … ] }; the response is{ items: BrandSummary[], notFound: string[], dataAsOf? }— the found summaries in request order (the same enrichedBrandSummarythe 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. Scopebrands:read. A batch over 100 numbers returns422 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 aclasefilter, 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_searchengine) in place of the prior full-text + trigram channels. In practice this means cleaner typo tolerance — e.g.santadersurfaces Santander marks without dragging in unrelatedsanta…results — and better multi-word ranking: a query likebanco chilenow 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 asKOLORESthe 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
mcpcompose service was missingSUPABASE_DATABASE_URL, so all 9watch_*tools returnedservice_unavailablewhile REST/v1/watchworked (parity break). - Pre-auth reader-pool flood closed — the API auth gate moved from an
onRequesthook to apreHandler, so the rate-limiter (onRequest) now deterministically fronts theverifyApiKeySELECT (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 andblocked_at(rotation no longer drops the user block cascade / resets caps / re-arms a blocked key). - P2-1 vector pool wired —
createVectorReaderPoolis now instantiated andvectorSqlinjected intosearchBrandson both transports (had shipped inert / tests-only).
Fixed — P2/P3
- core/mcp/api:
findSimilarCorevectorSql seam threaded to both transports;watch_add_itemandcreateWatchlistWithItemscaps enforced under an advisory lock (TOCTOU closed);extractApiKeyenv fallback gated to stdio-only; MCP/metricsbearer-gated (METRICS_TOKEN twin of REST P2-5); oversized MCP body answers 413 instead of building a dead server; F-3 keysetLastSeenwired into both auth paths (was inert); watchlist wire exposesbaselineStatus+lastSweptAtliveness. - jobs:
job_resultidempotency (unique constraint + dedup),SERVICE_SECRET≥32B floor,watch_hitretention purge, baseline opt-in survives sweep failure, webhook push anchored vialast_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,
/auditcategory filter + keyset pagination, canonical scope-vocabulary validation, session TTL made truly rolling. - db:
uq_anotacion_eventrebuiltNULLS NOT DISTINCT(the anotacion sibling of the persona FIX-04 bug — 0 prod duplicates, preventive); redundantidx_instancia_fecha_presentaciondropped.
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=falseis honored (P2-9). TheallClassesfind-similar flag no longer usesz.coerce.boolean()(which turned any non-empty querystring, includingfalse, intotrueand silently widened a class-scoped request to all classes). A sharedbooleanFlagmaps onlytrue/'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 itsmax_watchlistsslot; 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. thenroSolicitudxorqrule) 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 nocode. A client can nowJSON.parse(text).error.codeuniformly across ALL MCP tool errors, matching the REST401/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/statusnow verifies the member belongs to the:idorg; a mismatched org/user URL returns404(and rolls the flip back) instead of silently flipping the member under the wrong org's URL and returning200. The audit row now also carries the member'sconsumerId, matching the sibling org-scoped routes. - Watch-sweep signal watermark is scoped to the run (P2-21).
syncIncrementalemitted awatch_sweep_signalwhenever ANY entity changed (totalChanged > 0), carrying arun_watermarkcomputed as a GLOBALmax(occurred_at)over allinstanciahistory — 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 thanmax()-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 noinstanciamovement 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
MOVEMENTbranch selectsmovement_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_SECRETentropy floor enforced at boot (P2-7). The Admin API now rejects aSESSION_SECRETshorter 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
/metricsscrape guard (P2-5).GET /metricsis exempt from the API-key gate and carries per-consumer ids + per-route traffic; previously the only protection was the Traefik router excludingPathPrefix(/metrics), so a single reverse-proxy misconfig leaked the tenant id set with no in-app defense. A new optionalMETRICS_TOKENenv adds an in-app backstop: when set,GET /metricsrequiresAuthorization: Bearer <METRICS_TOKEN>(401otherwise); 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 theirisPrometheus job (commented stanza indeploy/observability/prometheus/prometheus.yml). - Neutral MCP transport-level 500 (P2-13). A connect/transport-level failure on the MCP HTTP
endpoint (and the
/metricserror 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_aton every job (D-11) but nothing ever deleted past it, so the off-core Supabasejob/job_resulttables (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 viaJOB_PURGE_INTERVAL_MS);job_resultrows cascade away with their parent. Requires the new off-core migrationdb-jobs/20260719000001_job_expires_at_index.sql(a supportingidx_job_expires_atbtree) — apply by hand againstSUPABASE_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 superuseriris, with SELECT-only / identity-DML isolation resting ENTIRELY on a per-connectionSET ROLEGUC — a single missedSET ROLE(or a strayRESET 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) andiris_admin_svc IN ROLE iris_admin(identity DML, noDELETE, zero corpus) — so the wire identity itself is already confined; the server'sSET ROLEstays 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.envvarsIRIS_READER_DATABASE_URL/IRIS_ADMIN_DATABASE_URL, falling back to the superuserDATABASE_URLwhen unset (no regression for a box that has not yet applied the migration). OPERATOR: apply migration051by hand, thenALTER ROLE …_svc PASSWORD '…'and set the two env vars (the migration + compose are the code; the passwords/rotation are operator-owned — seedeploy/.env.box.example). Deploy never runs migrations.
Performance
- Search vector channel isolation (P2-1). A common-term + large-
clasequery could make the HNSW class-filtered walk over-scan and burn the 15s readerstatement_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).
fetchProcessedDatesran an unboundedSELECT 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_timeoutwiring and env parsing are exported once as@iris/corecreateReaderPool(+ 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/coreand consumed by both REST and MCP, removing duplicated literals. - Documented date-paging ceiling with text (F-4). With a text query,
sort=recientes/sort=antiguasre-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 thesortcontract. - Narrow watch-store re-export in
@iris/jobs(P2-17).packages/jobs/src/store/watch.tswasexport * from "@iris/core", leaking the ENTIRE core barrel (all queries, similar-search, admin, embeddings) through a path namedstore/watch. It now re-exports only the watch-store surface the worker,signalPolland 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-connectionstatement_timeout(reusesdbStatementTimeoutMs, 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.ymlbuilds+pushes anddocker compose pull && up -dbut 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 newestdb/migrationsversion against the box's last appliedschema_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 manualdeploy/BOOTSTRAP.md). - Neutrality gate matches at identifier boundaries (P2-20).
scripts/neutrality-check.shmatched the private-brand token as a bare substring (grep -niE 'rmc'), so a benign future identifier that merely containsr-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.VIOLATIONSstays 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_largepastmax_watch_items) andDELETE /v1/watch/:id/items/:itemId(remove,watch:write,204). Tenant-scoped by the key's consumer with a uniform404on any cross-tenant/unknown id. Mirrored as the MCP toolswatch_list_items/watch_add_item/watch_remove_itemover the same shared@iris/corestore + Zod (REST≡MCP). See the watch guide. - Key last-seen usage signal (F-3). A Redis-only
setLastSeen/getLastSeenkeyed 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.failureandauth.logoutinadmin_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 nooperator_ideven 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 /auditoperatorIdis now nullable (anauth.login.failurerow has none). Requires migration…050(relaxesadmin_audit.operator_idto 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 returns429with aRetry-Afterheader, 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 MCPsearch_brandstwin) accepts a combined, AND-composed field surface —nombre/denominacion,titular,representante,rut(normalized + mod-11 validated,400 invalid_ruton a bad check digit),nroSolicitud,nroRegistro, multi-valueclase/estado/tipoSigno, and a filing-date range — all additive and backward-compatible, with REST≡MCP parity by construction. Each result item now carries an additivematchedFields[]({ field, kind }) explaining why the mark surfaced, and a newsort=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/brandsrequest without free text (q/nombre) — e.g.?clase=9or?estado=…alone — now defaults tofecha_presentacion DESC, nro_solicitud ASC(newest first) instead of the previousnro_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 setsort=explicitly. In-flight keyset cursors from no-text listings reset once after this change (an oldcursor=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), andGET /v1/freshness(per-source staleness report). Every endpoint is API-key authenticated via theX-API-Keyheader 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.