# Changelog of Brand Name IP Screener (`protocol/brand-name-ip-screener`) Actor

- **URL**: https://apify.com/protocol/brand-name-ip-screener/changelog.md
- **Full Actor documentation**: https://apify.com/protocol/brand-name-ip-screener.md

## Changelog

All notable changes to this project are documented in this file.

### \[1.14.0] - 2026-08-20

#### Changed

- **Generate mode is now free.** `candidate_generated_and_rescreened` is priced
  at **$0.00** (was $0.50). Generate-mode users supply their own LLM key via
  `llmApiKey` (or run against their own provider subscription), so the Actor
  carries no inference cost to recover. Name generation, the Tier-1 filter, and
  the full IP + RDAP screening of every generated candidate are free in any
  number of jurisdictions.

  The event still **fires** at $0.00 rather than being removed: it is the usage
  signal for how often generate mode runs, it keeps the charge breakdown honest
  about what work was done, and restoring a price later is a Console change
  rather than a code change. `PPE_TARIFF` in `src/billing.ts` pins the new price
  and `test/billing/billing.test.ts` guards it against Console drift.

  **Screen mode pricing is unchanged** — `screening_report_produced` stays
  $1.00 per name × jurisdiction that returns `screened`.

  This is a price *decrease* on a not-yet-published Actor, so no 14-day user
  notice applies (hard rule #6 governs increases to live pricing).

- **Docs + contract updated together** (schema-first, hard rule #1): all 15
  billing descriptions in `.actor/INPUT_SCHEMA.json`, the README pricing table,
  worked examples, comparison table, and agent-integration notes now state
  generate mode as free. Structural schema fields are byte-identical — only
  descriptions changed.

### \[1.13.4] - 2026-08-19

#### Fixed

- **WIPO (WO) seed: closed three cross-jurisdiction gaps.** Revolut, Sephora,
  and Spotify were present in ≥3 other office seeds but absent from the WO seed.
  Each was verified against WIPO Madrid Monitor record pages (INID fields,
  status `ACT`) plus a TMview WO-office sweep before add — no mark was added on
  assumption. Classes are the register's, not the cross-office expectation:
  Sephora also covers 44; Spotify covers 35/38 but NOT 42. WO seed 318 → 321
  (total 1,185 → 1,188).

#### Skipped (verified absent / not a word mark — not added, by design)

- **Netflix (WO):** Netflix, Inc. holds live IR 1235989 (cls 9/38/41), but it is
  figurative — INID 571 "Netflix in stylized font", image reproduction, no INID
  541 standard-characters flag. No word-mark IR was found. Word marks only.

#### Added

- **Cross-jurisdiction coverage regression test** (`test/registry/crossJurisdictionCoverage.test.ts`).
  Guards the invariant that genuinely-universal famous marks (Starbucks,
  Coca-Cola, Adidas, Mercedes-Benz) are present in all five office seeds, so an
  asymmetric seed set cannot silently reintroduce a false `clear`/low-caution
  verdict in any jurisdiction. Also asserts every seed entry carries a known
  jurisdiction tag.

### \[1.13.3] - 2026-08-19

#### Fixed

- **Cross-jurisdiction seed-coverage gap that produced false `clear` / low-caution
  verdicts.** A smoke test of `"Starbux"` returned high-risk in US and WO
  (Starbucks present in both seeds) but only a low `caution` in EU and UK —
  because Starbucks, and dozens of other globally-famous marks, were present in
  ≥3 office seeds yet absent from EU/UK. The similarity engine was correct; the
  seed data was thin (UK at ~19% cross-coverage, US at ~28%). Each missing mark
  was verified against the target office's public register before add (no mark
  was added on assumption — see the skip list below). Total seed entries
  **1,114 → 1,185 (+71)**: EU 196 → 201, UK 269 → 300, US 169 → 203, FR 162 →
  163 (WO unchanged at 318).

- **Per-office class accuracy.** Where the verified registration for an office
  differs from the cross-jurisdiction class set, the office's actual classes are
  recorded: Spotify is `[9, 41]` in the US (class 42 is not registered at USPTO
  for the SPOTIFY word mark) and Revolut is `[36]` in the US (class 9 is a
  pending application, not registered). Pepsi is `[25, 32]` in the EU (the EUTM
  genuinely covers apparel + beverage, so a class-25 collision is real).

#### Added

- **EU (+5):** Starbucks, Coca-Cola, Pepsi, Sprite, Netflix — each a live EUTM
  verified via the EUIPO register (e.g. Starbucks EUTM 000596163 cls 30 +
  EUTM 003086014 cls 43). This closes the motivating EU false-clear gap.
- **UK (+31):** Adidas, BMW, Chanel, Danone, Dior, Ferrari, H\&M, Lacoste, Lindt,
  Louis Vuitton, Mercedes-Benz, Merck, Michelin, Nespresso, Nivea, Nutella,
  Orange, Peugeot, Pfizer, Philips, Puma, Renault, Sanofi, Sephora, Starbucks,
  Volkswagen, Zara, Air France, Deutsche Telekom, Haribo, Hennessy — verified
  via UKIPO opposition/DRS decisions, Madrid Monitor, and register data.
- **US (+34):** the 31 above (minus Louis Vuitton/Perrier, see below) plus
  Stella Artois, Heineken, Red Bull, Vodafone, Revolut, Spotify, Dyson, Dior,
  Evian, H\&M — verified via USPTO TSDR / TTAB / UDRP records (live Principal-
  Register word marks).
- **FR (+1):** Merck — INPI national word mark FR 1537463, holder Merck KGaA
  (Darmstadt), confirming the German entity (not Merck & Co./MSD) holds the
  French pharma mark.

#### Skipped (verified absent / not live — not added, by design)

- **Budweiser (EU + FR):** AB InBev's EUTM word mark was refused on Budvar's
  opposition (CJEU C-214/09 P); French rights are Budějovický Budvar's
  appellation of origin. AB InBev does not hold a live EU/FR BUDWEISER word
  mark.
- **Dyson (EU):** the EUTM expired 2023-11-21; no live DYSON EUTM.
- **Pfizer (EU):** only an expired (2022) figurative EUTM, held by Pfizer
  Products Inc. (not Pfizer Inc.).
- **Pampers (EU):** no live standalone PAMPERS word EUTM.
- **Danone (US):** no live USPTO DANONE word mark (cancelled); the US brand is
  the variant DANNON.
- **Peugeot (US):** US class-12 auto marks cancelled (2016/2020); the 2021
  Madrid IR does not designate the US.
- **Garnier (US):** standalone GARNIER word mark abandoned at USPTO (2021); only
  compound marks (GARNIER SKINACTIVE) are live.
- **Louis Vuitton (UK) + Perrier (UK):** could not confirm a UK class-25 (LV) or
  any UK (Perrier) registration from accessible sources — skipped rather than
  recorded on assumption. (Both are verified in the US seed.)

#### Notes

- Verification was performed by parallel research agents querying each office's
  public register (EUIPO eSearch / TMview, UKIPO, USPTO TSDR, INPI) plus
  authoritative court/opposition records. Six US marks (Dior, Dyson, Evian,
  Ferrari, H\&M) required a second pass after the first agent exhausted its
  web-search budget; all six were resolved (5 added, Ferrari added in its famous
  class 12 — the standalone US word mark covers 9/14 and class-12 rights are held
  via compound marks).
- Seed entries remain hand-curated factual records (mark text, proprietor, Nice
  classes, status) — no bulk register data is redistributed. See
  [DATA-SOURCES.md](./DATA-SOURCES.md) for provenance, coverage limits, and the
  third-party-trademark notice. `SEED_AS_OF` is unchanged (2026-07); a refresh of
  that timestamp is a separate pass.

### \[1.13.2] - 2026-08-18

#### Fixed

- **RDAP domain co-check was broken for every TLD except `.com`.** Live runs
  returned `0 free / 1 taken / 4 error` for every name: `.io`, `.co`, `.ai` and
  `.dev` all failed with `fetch failed`. The cause was not a timeout — the
  configured hosts `rdap.nic.io`, `rdap.nic.co`, `rdap.nic.ai` and
  `rdap.nic.dev` **do not exist in DNS at all**, so each lookup failed in about
  0.3 s. `.ai` and `.dev` now use the endpoints published in IANA's RDAP
  bootstrap registry (`data.iana.org/rdap/dns.json`), and `.io` uses Identity
  Digital, verified in both directions — 200 for known-registered names
  (`github.io`, `google.io`), 404 for known-free ones.

- **`.co` removed from the probed TLDs.** No RDAP endpoint could be found that
  actually distinguishes a registered `.co` from a free one: every candidate
  either did not resolve or answered 404 for `google.co` and `amazon.co` alike.
  Wiring one of those would have reported **every** `.co` as free — a false
  availability signal, the domain-side equivalent of a false `clear`. Reporting
  nothing beats reporting a confident wrong answer, so `.co` is omitted until a
  verified endpoint exists. Probed TLDs are now `.com`, `.io`, `.ai`, `.dev`.

#### Added

- **Regression guard: every supported TLD must have an endpoint configured.**
  The outage was invisible to the suite because a TLD with no working endpoint
  degrades to `error` rather than failing loudly. The new test asserts
  `SUPPORTED_TLDS` and `RDAP_ENDPOINTS` cannot drift apart, and was confirmed
  to fail when the old configuration is restored. Network-free, per mock-first.

#### Changed

- **README and `INPUT_SCHEMA.json` updated to the four probed TLDs** (hard rule
  \#1, schema-first) — including the worked example's `tlds` array and its
  `free/taken/error` summary, which would otherwise have documented output the
  Actor can no longer produce.

No record or row shape changed; `OUTPUT_SCHEMA_VERSION` stays `1.6.0`. The
`domainAvailability` block is unchanged in shape — only the set of TLDs inside
it. Gates: type-check clean, lint 0 errors, 265/265 tests green.

### \[1.13.1] - 2026-08-18

#### Fixed

- **Runtime crash on container start (Crawlee version mismatch).** Builds
  1.13.x under the `apify/actor-node:24` base image died at `Actor.init` with
  `Detected incompatible Crawlee version used by the SDK. User installed
  3.18.0 but the SDK uses 3.17.0` and exited before screening anything. The
  base image preinstalls the `crawlee` umbrella at 3.18.0 at the top-level
  `node_modules`, while the lockfile had naturally resolved `@crawlee/core` (the
  package `apify@3.7.2` actually bundles) to 3.17.0. `Actor.init` runs
  `checkCrawleeVersion`, which compares those two and throws on a mismatch. The
  build and the 264-test suite never caught it: the build only compiles, and
  the tests mock the SDK so `checkCrawleeVersion` never runs — it only fires in
  the real container.

  The four `@crawlee/*` sub-packages `apify@3.7.2` depends on (`core`,
  `memory-storage`, `types`, `utils`) are now pinned to **3.18.0** via
  `pnpm-workspace.yaml` overrides, aligning the SDK with the base image's
  umbrella. `apify@3.7.2` declares `@crawlee/*` as `^3.14.1`, so 3.18.0 is
  within the supported range, and the base image itself pairs `apify` with
  crawlee 3.18.0 — the alignment is the officially-blessed combination. Pinned
  to 3.18.0 exactly: 3.18.1 (the current latest) would re-trip the same check
  against the base image's 3.18.0 umbrella. Re-evaluate when `apify` ships an
  SDK built against crawlee 3.18.1+ (expected at the apify v4 bump) or when the
  base image bumps its crawlee.

  No record or row shape changed; `OUTPUT_SCHEMA_VERSION` stays `1.6.0`. Gates
  after the pin: type-check clean, lint 0 errors, 264/264 tests green.

### \[1.13.0] - 2026-08-17

#### Public release prep — Actor output surface, seed data provenance

No runtime behaviour changed in this release. `OUTPUT_SCHEMA_VERSION` stays
`1.6.0`: the record and row shapes are byte-for-byte identical to v1.12.0.
Everything here is the surface around them — how the Actor describes its own
output, where the seed marks come from, and what the repository claims about
itself now that it is public.

#### Added

- **Actor output schema (`.actor/output_schema.json`).** The Apify Console and
  any MCP/AI-agent consumer can now discover what a run produces without
  reading the source: the structured `OUTPUT` key-value record (read this
  first — it carries the per-name verdict already reduced across
  jurisdictions) and the flat, CSV-friendly dataset rows. Wired from
  `actor.json` as `"output"`.
- **Key-value store schema (`.actor/key_value_store_schema.json`).** Declares
  the single record this Actor writes — `OUTPUT`, `application/json` — so the
  Console surfaces it instead of burying it among platform state keys. Wired
  from `actor.json` as `storages.keyValueStore`.
- **Two dataset views (`.actor/dataset_schema.json`).** `Coverage` puts
  `marksScreened`, `screeningStatus` and `notScreenedReason` side by side, so
  the denominator behind a verdict is visible before anyone reads a `clear` as
  a low-risk signal — a `clear` over 3 marks is not a `clear` over 318.
  `Full detail` exposes every column including `similarMarksJson` and the
  disclaimer. The existing `Overview` view is unchanged.
- **`DATA-SOURCES.md`.** Documents where the 1,114 shipped seed marks come
  from and, as importantly, where they do *not*: they are hand-curated factual
  records (mark text, proprietor, Nice classes, status) read from publicly
  searchable registers, not a copy, export or redistribution of any office's
  bulk data product. Covers the per-office bulk-product terms (INPI, USPTO,
  EUIPO, WIPO, UKIPO — all marked *not ingested*), the third-party trademark
  notice (nominative use, no affiliation or endorsement), the coverage limits
  that make `clear` a bounded claim, the ISC/data licensing split, and a
  corrections and removal channel.
- **Provenance headers on all five seed data files.** Each
  `src/registry/*SeedMarks.data.ts` now points at `DATA-SOURCES.md` and
  restates the no-bulk-redistribution and marks-belong-to-their-owners
  position at the point of use.

#### Changed

- **Internal planning references removed from tracked source.** Fourteen
  source files and the changelog cited private vault documents by filename and
  section number for design rationale. Each citation is now reworded to be
  self-contained, so a reader of the public repository never hits a pointer
  they cannot follow.
- **README reflects a public repository.** Contributing and Support now name
  the live `github.com/m-raphael/brand-name-ip-screener` remote and the
  Console Issues tab, replacing the "not public yet" placeholders, and point
  seed-data questions at `DATA-SOURCES.md`.
- **`package.json` carries `repository`, `bugs` and `homepage`** so npm-style
  tooling and the Store listing resolve to the right places.

### \[1.12.0] - 2026-08-07

#### Pre-ship bug hunt — billing safety, false-clear closure, ranking integrity

Six defects found by reproducing them against the real pipeline, not by
inspection. Two were capable of charging a customer for a run that produced
nothing; one was a billed false `clear`, the outcome this Actor exists to
prevent.

#### Fixed

- **Rows are written before the charge for them (B-2).** Charging happened per
  name inside the screening loop while `Actor.pushData` ran once at the very
  end, so any failure in between — a `dataset_schema` violation on a later
  name, a dataset/KVS outage, an OOM or a run timeout — left the customer
  charged for a run that persisted nothing. PPE charges are not auto-refunded.
  Each name's rows are now validated and pushed immediately before that name is
  billed, so a charge strictly trails durable work (hard rule #6).
- **Blank and oversized names rejected at the boundary (B-1).** `names: [""]`
  passed input validation (no `minLength` on `names.items`), normalized to an
  empty core, and then failed the Stage-7 dataset gate with
  `ERR_OUTPUT_SCHEMA_VIOLATION` — killing the whole run *after* the other names
  had been billed. A trailing empty line in an API-submitted list was enough.
  `names.items` now carries `minLength: 1` / `maxLength: 100`, and blank-after-
  trim entries fail with `ERR_INVALID_INPUT` before anything is charged.
- **Duplicate names screened and billed once (B-5).** `jurisdictions` was
  de-duplicated; `names` was not, so `["Brandcraft", "BRANDCRAFT"]` ran the
  identical screen twice at $1.00 each. Names are now de-duplicated on their
  normalized core — the actual unit of work — keeping the first spelling.
  Names with an empty core fall back to their raw form so two different
  non-Latin names are not collapsed into one.
- **Homoglyph detection widened beyond Cyrillic/Greek (B-3).** `Ｇoogle`
  (fullwidth `G`) was not flagged, normalized to `oogle`, and screened `clear`
  against the wrong core — a billed false `clear`. Armenian, Cherokee,
  fullwidth ASCII and mathematical alphanumeric forms now join the confusable
  set. CJK and emoji stay excluded: they are stripped too, but nobody reads 株
  or 🚀 as a Latin letter, so the surviving Latin core is still the name under
  screen.
- **Errored RDAP probes no longer sink the composite rank (B-4).** The domain
  factor was `free / tlds.length`, counting an errored probe as evidence
  against the candidate. When RDAP degraded, every candidate scored 0 and the
  ranked output silently collapsed to input order. The denominator is now
  `free + taken`, neutral (1) when nothing resolved.
- **Honest reason for an unscreenable name (B-6).** A punctuation- or
  emoji-only name reported `name_not_latin_screenable` — "contains non-Latin
  script" — which is false. New `name_has_no_distinctive_core` covers names
  carrying no script at all. Both remain `not_screened` and unbilled.

#### Changed

- `OUTPUT_SCHEMA_VERSION` 1.5.0 → **1.6.0** (`notScreenedReason` gains
  `name_has_no_distinctive_core`). `dataset_schema.json`, the TS types and the
  README moved together (hard rule #1).
- `compositeScore` extracted from `main.ts` to `src/output/composite.ts` so the
  closed-loop rank is unit-testable; it previously had no direct coverage.
- `PPE_TARIFF` added to `src/billing.ts` — the event names and prices the
  Console must match, pinned by tests. A Console event name that drifts from
  the code by a trailing space bills nothing, with no error and no failing
  test; the release gate now has an exact artifact to diff against.
- CI: dropped the conflicting `version: 9` pin from `pnpm/action-setup`
  (`packageManager: pnpm@11.4.0` is the source of truth — specifying both makes
  the action fail), moved Node 18 → 24 to match `apify/actor-node:24`, and
  added a production `pnpm audit` step.

#### Tests

- 231 → **264** green, 21 → 22 suites. Every fix above landed test-first.

### \[1.11.0] - 2026-08-05

#### Pre-launch review hardening

#### Fixed

- **Typed `NotScreenedReason` enum + `notScreenedReason` on dataset rows (B3, schema 1.5.0).**
  The not-screened reason is now a typed union (`no_source` |
  `name_not_latin_screenable` | `budget_exhausted` | `source_error` |
  `confusable_script`) on `JurisdictionVerdict.reason`, with the human prose split
  into a new `reasonMessage` field. Each flat dataset row carries
  `notScreenedReason` (enum when `not_screened`, `null` when screened).
  `OUTPUT_SCHEMA_VERSION` bumped to `1.5.0`. The prose no longer lives in
  `reason`, so machine consumers get a stable contract instead of free text.
- **`RDAP_ENABLED` env gate honors the common falsy set (B9).** The gate
  previously treated only the literal string `"false"` as off, so `"0"`, `"no"`,
  and `"off"` still ENABLED RDAP. It now disables RDAP for any of
  `"false" / "0" / "no" / "off"` (case-insensitive); anything else (including
  `"true"` / unset) enables it. `enableDomainCheck: false` still skips RDAP
  unconditionally. No output-shape change — no schema bump.
- **Redact positioning brief from generate-mode INFO logs (B5).** The
  `Generating N name(s) …` log line previously echoed the customer's full
  positioning brief at INFO level, letting the operator read every customer's
  business strategy from run logs. It now logs only the brief's character
  length and tone — the structured OUTPUT is unchanged.
- **Treat empty LLM candidate output as a fallback/error (B7).** A model
  returning schema-valid but empty `{"candidates":[]}` previously passed AJV
  (the schema only requires that `candidates` exists, not that it is non-empty)
  and produced a silent zero-candidate run with no `ERR_*`. The state machine
  now treats an empty candidates array as a `malformed` primary result so the
  existing retry-on-primary → fallback flow engages; if the fallback also
  returns zero candidates, the run throws `LlmPipelineError`
  (`ERR_LLM_PIPELINE_FAILED`) instead of billing for / acting on an empty
  generation. No output-shape change — no schema bump.
- **Wire `ERR_OUTPUT_SCHEMA_VIOLATION` + export `ERR_UNEXPECTED` as typed (B4).**
  The README advertised six `ERR_*` codes but `ERROR_CODES` exported five and
  `ERR_OUTPUT_SCHEMA_VIOLATION` was never thrown (dead), while `ERR_UNEXPECTED`
  was emitted as an ad-hoc string with no typed class. `ERROR_CODES` now exports
  `UNEXPECTED: 'ERR_UNEXPECTED'` (5 → 6 entries); new `UnexpectedError` and
  `OutputSchemaViolationError` subclasses of `ScreenerError` carry the codes.
  `src/main.ts` wraps non-coded throws in `UnexpectedError` (replacing the
  ad-hoc `log.error('ERR_UNEXPECTED: …')` string) and AJV-validates every
  dataset row against the published `.actor/dataset_schema.json` right before
  `Actor.pushData`, throwing `OutputSchemaViolationError` on the first invalid
  row — making the previously-dead code live and guarding the schema-first
  contract. No output-shape change — no schema bump.
- **Detect mixed-script confusable names → `not_screened` (B6).** A mixed
  Latin + Cyrillic/Greek brand name (e.g. "Gоogle" with a Cyrillic `o`) is a
  homoglyph-evasion vector: `normalizeName` stripped the non-Latin codepoint,
  producing "gogle", which screened as a DIFFERENT name and missed the real
  "Google" — a false `clear` violating the "never a false clear" invariant.
  A new `detectConfusableScript` check now runs before normalization; mixed
  names surface as `not_screened` with reason `confusable_script` (already in
  the `NotScreenedReason` union from B3) across every jurisdiction, are
  skipped from RDAP, and never bill. Pure-Cyrillic/Greek names still fall
  through to the existing `name_not_latin_screenable` path. No output-shape
  change — no schema bump.
- **Surface `marksScreened` + curated-seed caveat in the `clear` recommendation (B2).**
  The `clear` recommendation previously read identically whether 3 or 318 marks
  were screened, defeating the v1.10.0 `marksScreened` honesty signal. It now
  names the denominator (`"No close marks found among the N in-scope seed marks
  for \"<name>\" in <jurisdiction>"`) and states this is a curated famous-mark
  screen, not a full-registry clearance — engaging a qualified trademark attorney
  before filing. The Apify Store card description and README intro carry the
  same curated-seed qualifier so the Store card cannot be read as full-registry
  screening. UPL boundary preserved — informational, not legal advice. No
  output-shape change — no schema bump.
- **Inferred-classes caveat + `seedAsOf` provenance (B10).** A `clear`/`caution`/
  `high-risk` verdict scoped to INFERRED Nice classes is a false-clear-by-mis-scope
  — the inferred classes may be wrong. The recommendation now appends
  `"Nice classes were inferred from your industry — verify them before relying
  on this screen."` whenever `niceClassesSource === "inferred"` (and only then;
  user-supplied classes carry no caveat, and `not_screened` is exempt). The
  structured record also surfaces a new `seedAsOf` field (YYYY-MM the curated
  famous-mark seeds were last verified) so a caller can signal staleness — the
  seeds are point-in-time, not a live registry. `dataset_schema.json` is
  unchanged (the field lives on the structured `OUTPUT` record, not the flat
  row). No breaking change — no schema bump.

#### Notes

- **README pricing section now documents generate-mode multi-jurisdiction pricing (B8).**
  The README Pricing section now states that `candidate_generated_and_rescreened` bills a
  flat $0.50 per filter-survivor regardless of how many jurisdictions that survivor is
  screened against, making multi-jurisdiction generate screening heavily discounted
  versus screen mode ($1.00 × jurisdictions). The note flags this is by design for v1 and
  that pricing may change in a future version (14 days' notice, as above). README-only
  change — no code, no tests, no schema bump.

### \[1.10.0] - 2026-07-15

#### Added — coverage honesty (P2)

- **`marksScreened` on every verdict and dataset row (P2.5).** Each
  jurisdiction verdict and output row now reports how many curated seed marks
  were actually compared (0 when `not_screened`). `OUTPUT_SCHEMA_VERSION` bumped
  to `1.4.0`. This makes "clear" unambiguous: it means *no collision found among
  the `marksScreened` seed marks in scope*, not "registry-empty".
- **`matchedKeywords` on each brand record (P2.5).** When Nice classes are
  inferred from the positioning brief, the keyword matches that drove the
  inference are surfaced on the record and in the OUTPUT KVS payload, so an
  MCP consumer can see *why* a class was scoped.
- **Nice-class inference word boundaries (P2.2).** Short keywords (≤4 chars)
  now match on word boundaries, fixing false positives like `ai` inside
  `hair`/`dairy`/`repair` and `app` inside `apparel`. `inferNiceClassesWithKeywords`
  returns both the classes and the matched keywords.

#### Changed — deterministic filter + non-Latin honesty (P2)

- **`mustInclude` / `avoid` enforced deterministically (P2.3).** Generate-mode
  Tier-1 filtering now applies explicit fragment constraints (`mustInclude` =
  required fragment, `avoid` = banned fragment) with new invalid reasons
  `missing_required_fragment` / `forbidden_fragment`. Avoid is checked before
  must-include. No LLM in the loop for these constraints.
- **Non-Latin names no longer screen "clear" (P2.1).** A name that normalizes
  to empty (pure CJK/Cyrillic/emoji, or mixed script with no Latin) is pushed
  to `not_screened` with reason `name_not_latin_screenable` across all
  jurisdictions and charges nothing — it never silently scores 0/clear.
- **RDAP timeout honored end-to-end (P2.4).** `RDAP_TIMEOUT_MS` now plumbs from
  input/env through `createRdapSource(timeoutMs)` to the fetch abort signal
  (was dead before). Aborts surface as `not_screened` domain co-checks, never
  hangs.
- **Graceful failure (P2.7).** Unexpected throws in the run are wrapped in
  `Actor.fail('ERR_UNEXPECTED: …')` with a log entry — no raw stack traces in
  run output.

#### Content — seed expansion (P2.6)

- **EU (EUIPO) seed expanded from 38 → 196** well-known real registered marks
  across tech, automotive, luxury/fashion, food/beverage, finance, pharma,
  retail, travel, and media. US=169, WO=318, UK=269 confirmed. README
  "Supported jurisdictions" table now lists per-jurisdiction counts.

#### Docs / OSS hygiene (A2, P3)

- README MCP section gained "Branching on the OUTPUT record" guidance
  (brandReadiness, screeningStatus, marksScreened, niceClassesSource +
  matchedKeywords, attorneyReview, billing), a screen-mode **tool-call
  example**, and a full **`ERR_*` code table** (6 codes incl. the runtime
  `ERR_UNEXPECTED` catch-all and `ERR_REGISTRY_SOURCE`, with when-it-fires +
  agent-action columns) replacing the prior 3-row table.
- `.actor/INPUT_SCHEMA.json` field descriptions audited (A2.3): all 19 fields
  now state their effect on the output and on billing (13 rewritten). No schema
  value, field name, or `OUTPUT_SCHEMA_VERSION` change — copy-only.
- New README **"How this compares"** section (P4.2): vs a $150–$300
  paralegal/attorney clearance, vs manual EUIPO/USPTO/INPI searching, vs doing
  nothing — probabilistic vocab, UPL boundary preserved.
- **P2.5 copy nuance:** the `clear` band (enum value unchanged) is now
  presented in human-facing copy as "no collision found among the marks
  screened" across the README lede, `actor.json` description, and the
  `dataset_schema.json` `risk` field description — never as a promise of
  clearance.
- Added `SECURITY.md`, `CONTRIBUTING.md`, `.github/ISSUE_TEMPLATE/`, and a
  `ci.yml` (type-check / lint / test / build on PR + push).
- Operator-only `docs/OPERATIONS.md` (gitignored) records env vars, the PPE
  event-name match check, and seed counts.
- `.gitignore` hardened with `tmp/`, `temp/`, `logs/`, `*.log`.

#### Fixed — pre-ship bug hunt (high-bar review, 206 tests green)

- **Never a false `clear` on registry source failure.** A throwing
  `MarkSource` (network/parse/auth) was silently caught in
  `createSeedAdapter.candidates()` and returned `[]`, which `screenName`
  turned into a billed `screened` clear (ipRiskScore 0, marksScreened 0). Now
  re-thrown as a typed `RegistrySourceError` (`ERR_REGISTRY_SOURCE`), caught
  per-jurisdiction in `main.ts`, surfaced as `not_screened` with reason
  `source_error`, logged, and never billed. Restores hard rule #3 + #7.
- **Non-decomposable Latin letters folded, not dropped.** `foldDiacritics`
  used only NFD, so ø/æ/ß/ð/þ/œ/ł (no canonical decomposition) survived and
  were dropped by the `[^a-z0-9]` filter — `RØW`→`rw` (spurious exact match
  for `RW`) and `Straße` vs `Strasse` (same German mark, no collision). An
  explicit `LATIN_FOLD` map now folds them to ASCII.
- **Caution band always carries its evidence.** The risk band (≥25) and the
  matches list (reportThreshold, default 60) were independently gated, so the
  whole caution band \[25,59] emitted `band=caution` + `ipRiskScore>0` but
  `matches=[]` — a non-clear verdict with no listed colliding mark. The
  band-determining top mark is now always surfaced as evidence.
- **`maxLength` enforced as a Tier-1 filter reject.** INPUT\_SCHEMA promised
  over-length candidates "are rejected at the filter (free)", but
  `runBrandTier1` never enforced it — they passed, got screened, and billed
  $0.50. Now a `too_long` reject (free) on the space-stripped length.
- **`avoid` / `mustInclude` hyphen bypass closed.** Constraint matching kept
  hyphens, so `Brand-X` evaded `avoid:["brandx"]` and failed
  `mustInclude:["brandx"]`. Both sides now compare on the fully
  punctuation-stripped form.
- **5-char keyword word boundaries.** `media`/`hotel` still used substring
  matching, so `media` false-matched inside `immediate`/`median`/`mediation`
  and mis-scoped the Nice classes. Threshold raised to ≤5.
- **Invalid `niceClasses` rejected, not silently dropped.** Out-of-range
  (e.g. `46`) or non-numeric entries were filtered out, screening against the
  wrong classes with no warning. Now throws `ERR_INVALID_INPUT`.
- **`names` + `positioning` mutual exclusion enforced.** Supplying both
  silently discarded `positioning` and ran screen mode. Now throws
  `ERR_INVALID_INPUT` (JSON Schema cannot encode "exactly one of").
- **Non-Latin `normalizedName` falls back to the raw name.** A fully non-Latin
  name normalized to `''`, violating `dataset_schema.json` `minLength:1`. Now
  falls back to the raw name in the output only (screening/RDAP still skip).
- **`not_screened` generate candidates never outrank screened ones.** A
  not\_screened candidate inherited `compositeScore = quality×100` and could
  top the ranked output. Now `compositeScore` stays 0 and the sort partitions
  screened above not\_screened.
- **Honest `not_screened` copy.** `recommendationFor` no longer asserts "no
  registry data source enabled" for every not\_screened case (wrong for
  `name_not_latin_screenable` / `budget_exhausted` / `source_error`); and
  `attorneyReview` no longer says "No high-risk collision detected" when
  nothing was screened.
- **`OUTPUT.schemaVersion` uses the constant.** Was `records[0]?.schemaVersion`
  \= `undefined` when every generate candidate was filtered (empty records),
  breaking the published string contract. Now uses `OUTPUT_SCHEMA_VERSION`.
- `actor.json` `version` corrected to `1.10.0` (was missing the patch
  component). 184 → 206 tests (+22); type-check, lint, build clean.

### \[1.9.0] - 2026-07-08

#### Security

- **Key-custody hardening (B1).** The operator's `OPENROUTER_API_KEY` is now used
  **only** on the default OpenRouter endpoint with the default `openai`
  provider. Setting a custom `llmBaseUrl`, or `llmProvider: "anthropic"`,
  **requires** your own `llmApiKey` — the operator key is never transmitted to a
  user-supplied endpoint or a non-default provider. A run that omits it fails
  fast with `ERR_MISSING_API_KEY` and performs **no fetch**, so no key can be
  exfiltrated to a user-controlled URL. `OPENROUTER_BASE_URL` env is now ignored
  for `anthropic` runs (it would otherwise post an Anthropic-shaped body to an
  OpenAI-shape endpoint — B10). `MissingApiKeyError` message names the custom
  endpoint/provider case explicitly.

#### Added

- **Pay-per-event billing (B2).** Billable work now calls `Actor.charge()`,
  matching the README pricing promise:
  - **Screen mode** — `screening_report_produced` ($1.00) per name ×
    jurisdiction that returns `screened`.
  - **Generate mode** — `candidate_generated_and_rescreened` ($0.50) once per
    filter-survivor that completes its full screen. The $0.50 covers the whole
    candidate; its per-jurisdiction screening does **not** additionally bill
    `screening_report_produced` (matches the README worked example: 8 candidates
    → $4.00).
  - **Free paths never charge:** `not_screened` rows, Tier-1 filter rejects,
    and failed runs. Charging happens **after** the unit of work completes, so a
    mid-run crash never over-bills. A charge-API failure is logged and never
    crashes a paid run. When the PPE budget is exhausted mid-run, remaining
    names stop screening and surface as `not_screened` with reason
    `budget_exhausted` (absence ≠ verdict — never a false `clear`).
  - New `src/billing.ts` exports the pure `screeningChargeFor(mode, count)`
    helper (unit-tested); the `Actor.charge` wrapper + budget flag live in
    `main.ts`. Event names must match the Console → Monetization event names
    exactly or billing is silently zero.

#### Fixed

- **Full positioning brief reaches the LLM (B3).** `positioning` was silently
  truncated to 50 chars before reaching the model, so generate mode ignored most
  of the user's brief. It now gets its own 500-char cap (fragments/techniques
  keep 50). Sanitization is unchanged.

#### Changed

- `.actor/INPUT_SCHEMA.json` `llmApiKey` / `llmBaseUrl` descriptions now state
  the key-custody rule (custom endpoint or non-default provider requires your
  own key). `llmApiKey` is no longer marked "Optional" in its lead copy.
- `JurisdictionScreening` gains an optional `reason` used for the
  `budget_exhausted` case; the structured `JurisdictionVerdict.reason` field is
  unchanged (already optional). No `OUTPUT_SCHEMA_VERSION` bump — output shape
  is additive and backward-compatible.
- README "Bring-your-own LLM" section + error-codes table now state the
  key-custody rule.

#### Tests

- 157/157 unit tests pass (141 → 157, +16: 4 router key-custody tests; 6
  `billing.test.ts` covering screen/generate/not-screened/budget decisions; 2
  prompt tests for the 500-char positioning cap; 4 `main.test.ts` billing
  scenarios — generate charges, screen per-row + multi-jurisdiction count,
  budget-exhaustion stops new names, charge-API failure never crashes).

### \[1.8.0] - 2026-07-06

#### Added

- **Bring-your-own-LLM (generate mode).** Each user can now supply their own LLM
  provider + key via four optional input fields — `llmProvider`, `llmApiKey`,
  `llmBaseUrl`, `llmModel` — instead of relying on the operator-configured
  `OPENROUTER_API_KEY` env var. When set, the per-run overrides take
  precedence; when unset, the operator env var is the fallback. This lets users
  pay their own LLM provider directly and pick the provider that fits them,
  while the operator can still ship a zero-friction default by setting the env
  var (Apify secret env vars are runtime + encrypted — never baked into the
  build image).
- **Anthropic (native Claude) support.** `llmProvider: "anthropic"` uses the
  native Claude Messages API (`/v1/messages`, `x-api-key` + `anthropic-version`
  headers, top-level `system` + `messages` body, `content[].text` response),
  so a user with a direct Anthropic key isn't forced through OpenRouter.
  `llmProvider: "openai"` (default) covers any OpenAI-compatible endpoint —
  OpenRouter, OpenAI, Groq, Ollama, LM Studio — via the existing chat-completions
  path.
- `generateBrandNames(input, fetchFn, overrides)` gains the optional `overrides`
  parameter (structurally compatible with the new `LlmOverrides` on
  `ScreenerInput`). `main.ts` forwards `input.llm` to it. A single user-supplied
  (or env-configured) model is used for both primary and fallback slots, so the
  existing retry / fallback state machine still protects against transient
  failures against the one model the user chose.

#### Changed

- `MissingApiKeyError` message now names both paths (`llmApiKey` input and
  `OPENROUTER_API_KEY` env) and notes screen mode needs no key. Code unchanged
  (`ERR_MISSING_API_KEY`).
- `.actor/INPUT_SCHEMA.json` gains the four `llm*` fields with provider/key
  examples (OpenRouter / Groq / OpenAI / Anthropic). `llmApiKey` uses
  `editor: "secret"` so it is never logged.
- `.env.example` documents provider defaults and Groq/Anthropic examples.

#### Tests

- 141/141 unit tests pass (130 → 141, +11 net: 6 router tests covering BYO
  OpenAI-compatible, BYO-overrides-env precedence, Anthropic native call shape,
  Anthropic malformed-retry, Anthropic missing-text-block fallback; 5
  `validate.test.ts` covering `llm` forwarding + blank/trim handling).

### \[1.7.0] - 2026-06-23

#### Added

- **Output-only attorney-escalation flag** (scoped down). Every structured
  record now carries an `attorneyReview`
  block — `{ recommended: boolean, message: string }` — set
  `recommended: true` only when `brandReadiness` is `high-risk`. Purely
  informational: no referral, no firm name, no fee logic. The real
  attorney-handoff network (referral-fee vs rev-share, single firm vs
  marketplace, per-jurisdiction UPL/bar-rules vetting) remains an open
  business/legal decision and stays out of scope.
- `OUTPUT_SCHEMA_VERSION` bumped to `1.3.0` to reflect the new
  `attorneyReview` field on `BrandScreenRecord`.

#### Tests

- 130/130 unit tests pass (129 → 130, +1 net: `attorneyReview` coverage in
  `builder.test.ts`).

### \[1.6.0] - 2026-06-23

#### Added

- **UKIPO (UK) jurisdiction**, wired the same way every other jurisdiction in
  this Actor shipped: a curated seed dataset of well-known, real registered
  UK trademarks (`src/registry/ukSeedMarks.data.ts`) — banking/finance,
  retail/fashion, telecom/media, travel/hospitality, tech, energy/industrial,
  pharma. Famous-mark collision screening only — not full UKIPO/TMview
  coverage. This completes the 5-jurisdiction rollout order
  (FR → EU → US → WO → UK); the TMview
  aggregator (70+ offices) remains the longer-term commercial endgame.
- `RegistryOptions` (in `registry/index.ts`) gains `ukipoSource`; `main.ts`
  wires it to `ukipoSeedMarkSource` automatically.
- Simplified `adapterFor` to a `Record<Jurisdiction, () => RegistryAdapter>`
  lookup now that all five jurisdictions are wired — the `unwiredAdapter`
  fallback (Phase 1–3 scaffolding) is no longer reachable and was removed.

#### Tests

- 129/129 unit tests pass (126 → 129, +3 net: UK seed-source tests,
  `adapterFor` routing test for UK).

### \[1.5.0] - 2026-06-23

#### Added

- **WIPO (WO) jurisdiction**, wired the same way FR/INPI, US/USPTO, and
  EU/EUIPO shipped: a curated seed dataset of well-known, real
  internationally-registered (Madrid System) trademarks
  (`src/registry/woSeedMarks.data.ts`) — mostly Asia-headquartered
  multinationals (electronics, automotive, retail, internet services,
  finance, industrial). Famous-mark collision screening only — not the full
  WIPO Global Brand Database (real bulk/API ingestion remains a separate,
  larger undertaking).
- `RegistryOptions` (in `registry/index.ts`) gains `wipoSource`; `main.ts`
  wires it to `wipoSeedMarkSource` automatically when `WO` is requested.

#### Tests

- 126/126 unit tests pass (123 → 126, +3 net: WO seed-source tests,
  `adapterFor` routing test for WO).

### \[1.4.0] - 2026-06-23

#### Added

- **EUIPO (EU) jurisdiction**, wired the same way FR/INPI and US/USPTO
  shipped: a curated seed dataset of well-known, real registered EU
  trademarks (`src/registry/euSeedMarks.data.ts`) — EU-headquartered
  companies plus global brands' EU Trade Mark (EUTM) registrations, spanning
  tech/fintech, automotive, fashion/retail, food/beverage, travel, telecom,
  and finance. Famous-mark collision screening only — not full EUIPO
  coverage (real OAuth2 + eSearch Plus / bulk ingestion remains a separate,
  larger undertaking).
- `RegistryOptions` (in `registry/index.ts`) gains `euipoSource`; `main.ts`
  wires it to `euipoSeedMarkSource` automatically when `EU` is requested.

#### Tests

- 123/123 unit tests pass (120 → 123, +3 net: EU seed-source tests,
  `adapterFor` routing test for EU).

### \[1.3.0] - 2026-06-23

#### Added

- **USPTO (US) jurisdiction**, wired the same way FR/INPI shipped in v1.0.0:
  a curated seed dataset of well-known, real registered US trademarks
  (`src/registry/usSeedMarks.data.ts`) spanning tech, fintech, retail,
  food/beverage, telecom, pharma, automotive, media, hospitality, and
  education. Famous-mark collision screening only — not full USPTO bulk
  coverage (TSDR is status-by-number only, so real bulk ingestion of the
  USPTO XML/TSV dumps would be a separate, larger undertaking).
- Extracted the curated-seed adapter pattern into a shared
  `src/registry/seedAdapter.ts` factory (`createSeedAdapter`), used by both
  `inpi.ts` and the new `uspto.ts` — no behavior change for FR.
- `RegistryOptions` (in `registry/index.ts`) gains `usptoSource`; `main.ts`
  wires it to `usptoSeedMarkSource` automatically when `US` is requested.

#### Tests

- 120/120 unit tests pass (111 → 120, +9 net: US seed-source tests,
  `adapterFor` routing tests for FR/US/EU/WO/UK).

### \[1.2.0] - 2026-06-23

#### Added

- **Closed-loop name generation.** When
  the input sets `positioning` instead of `names`, the actor:
  1. Generates candidate names via an OpenRouter-routed LLM
     (`deepseek/deepseek-chat` primary, `openai/gpt-4o-mini` fallback,
     1-retry-on-malformed-JSON, AJV-validated output, env-tunable via
     `OPENROUTER_*`/`LLM_*`).
  2. Runs them through a deterministic Tier-1 filter — normalize, profanity
     (static list + user `bannedWords`), duplicate detection — entirely
     locally, zero network calls.
  3. Screens the survivors through the *same* IP + RDAP pipeline as `names`
     mode (no separate code path).
  4. Ranks the screened candidates by a composite score — naming quality
     (avg of LLM brandFit/readability/memorability) × (1 − ipRisk) × domain
     availability ratio — surfaced as `generation.compositeScore` (0–100) on
     each record, sorted descending.
  - Filtered-out candidates (profanity/banned-word/duplicate) never reach the
    screen or RDAP stages; they're surfaced separately as `filtered` in
    `OUTPUT` so users can see what the LLM over-generated.
  - New input fields: `positioning`, `tone`, `techniques`, `mustInclude`,
    `avoid`, `count`, `maxLength`, `bannedWords`. Exactly one of `names` /
    `positioning` is required.
  - `OUTPUT` gains a top-level `mode: 'screen' | 'generate'`.
  - `OUTPUT_SCHEMA_VERSION` bumped to `1.2.0`. Structured record gains an
    optional `generation` block (`technique`, `brandFit`, `readability`,
    `memorability`, `compositeScore`); dataset rows are unchanged (generation
    metadata stays in the structured OUTPUT only).
  - `.env.example` documents `OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL`,
    `LLM_PRIMARY_MODEL`, `LLM_FALLBACK_MODEL`, `LLM_TIMEOUT_MS`.

#### Tests

- 111/111 unit tests pass (56 → 111, +55 net for the filter pipeline,
  generation router/prompt, the schema/builder updates, and an end-to-end
  generate-mode test exercising `main.ts`'s full closed loop).

### \[1.1.0] - 2026-06-15

#### Added

- **Double Metaphone** phonetic encoders (English + French) replace the v1.0
  Soundex baseline. The 2-tuple `[primary, secondary]` is compared with a
  max-of-pairs rule, so a partial match in either code still scores above
  zero. French pre-processing was trimmed to digraphs Double Metaphone does
  not collapse natively (`au`, `eau`, `qu`, soft `c`, `ai`/`ei`); `ph` and
  `ch` are now handled by DM directly.
- **Nice-class auto-classification** from a free-text `industry` field via a
  curated keyword → class table (~35 keywords covering tech, finance, food,
  fashion, health, media, education, real estate, mobility, energy). The
  resolved classes are tagged `niceClassesSource: 'inferred'` in the
  structured output and dataset row so callers know when to trust the scope.
  User-supplied `niceClasses` always wins.
- **RDAP-based domain availability co-check** across 5 TLDs
  (`.com`, `.io`, `.co`, `.ai`, `.dev`). Defaults to enabled; opt out via
  `enableDomainCheck: false` or `RDAP_ENABLED=false`. Per-request timeout
  (`RDAP_TIMEOUT_MS`, default 5000) and concurrency (`RDAP_CONCURRENCY`,
  default 4) are env-tunable. The new `domainAvailability` block on each
  structured record carries per-TLD status plus a `{ free, taken, error }`
  summary. Network failures never throw — they degrade to `error` entries.
- `.env.example` documents the three RDAP environment variables.
- `OUTPUT_SCHEMA_VERSION` bumped to `1.1.0`. Dataset row gains
  `niceClassesSource`; structured record gains `niceClassesSource` and
  `domainAvailability`.

#### Tests

- 56/56 unit tests pass (33 → 56, +23 net for Double Metaphone, industry
  inference, RDAP wiring).

### \[1.0.0] - 2026-06-15

#### Added

- Deterministic visual + phonetic similarity engine (normalize → Damerau-Levenshtein → Soundex
  EN/FR phonetics → Nice-class-scoped fusion) producing a 0–100 `ipRiskScore` and `clear` /
  `caution` / `high-risk` bands.
- FR (INPI) registry adapter wired to a curated seed dataset of well-known, real registered
  trademarks for famous-mark collision screening — not full registry coverage. Full bulk
  ingestion (~3.1 GB, data.gouv.fr) remains a Phase 2 roadmap item.
- Structured `OUTPUT` record per name plus a flat, CSV-friendly dataset (one row per
  name × jurisdiction), each carrying the mandatory legal disclaimer.

#### Notes

- `EU`, `US`, `WO`, and `UK` jurisdictions remain `not_screened` pending their own data-source
  ingestion — never a false `clear`.
