docs/adr/0014-euipo-api-migration.md
Rama master · réplica de lectura del repositorio original.
# ADR-0014: EUIPO provider migration to Trademark Search 1.1.0 (RSQL + X-IBM-Client-Id)
## Metadata
| Field | Value |
|-------|-------|
| **Status** | Accepted |
| **Date** | 2026-06-07 |
| **Authors** | DOMINUS team |
| **Deciders** | DOMINUS team |
| **Supersedes** | N/A |
| **Relates to** | ADR-0012 (trademark matching policy), ADR-0013 (domain parsing consolidation, follow-up backlog), architecture-guardian §1 Principle 6 (trademark gate is non-negotiable) |
| **Project** | DOMINUS |
## Context
The trademark gate (ADR-0012) runs two free public providers in
sequence: USPTO for US jurisdiction and EUIPO for EU jurisdiction. A
gate that silently fails on one of them is worse than a gate that
fails loudly — the operator sees a "Clear" verdict and assumes the
buy recommendation is sound. The previous ADR fixed the detector
algorithm and added a strict-USPTO-TLD rule; the EUIPO source itself
was left untouched.
The EUIPO source has been silently broken since the underlying API
was retired. The `copla/trademark/data-capture/V1/trademarks`
endpoint returns 200 OK with an empty `trademarks` array for every
query. From the operator's vantage point, the EUIPO half of the gate
always returns zero hits, so:
- A `.io` or `.co` candidate with an EUIPO-registered mark but no
USPTO match comes back **Clear** (only EUIPO was looked up; it
returned nothing) instead of **Blocked**.
- The strict-USPTO-TLD rule does not catch this: a `.io` name does
not trigger the `.com`/`.us` rule, so an EUIPO-only Clear is still
accepted as a Clean verdict.
The new EUIPO API — "Trademark Search 1.1.0" — has been the only
live trademark search endpoint for some time. The wire format is
materially different from COPLA:
- The query parameter is `query`, not `trademarkName`, and the value
is an RSQL expression (e.g. `trademarkName==*nike*`).
- The OAuth2 Bearer token must be accompanied by an
`X-IBM-Client-Id` gateway header on every search request. The
same `client_id` issued for OAuth2 is reused as the IBM
identifier.
- The response envelope is Spring-Data style:
`{ content: [...], totalElements, number, size }`.
- Status tokens changed: the legacy values ("Registered",
"Refused", "Expired", "Withdrawn") are now uppercase ("REGISTERED",
"REFUSED", "EXPIRED", "WITHDRAWN") and a few new values exist
("CANCELLED", "SURRENDERED", "APPLICATION_PUBLISHED", etc.).
The `TrademarkProvider` interface in
`src/providers/trademark/trademark-provider.ts` was designed to be
provider-agnostic (Principle 1). The migration touches only the
EUIPO adapter; the gate, the detector, and the upstream scoring
engine are not affected. This is exactly the resilience Principle 1
was meant to provide — and the reason a single commit can land the
fix without rippling through the codebase.
ADR-0013 already listed this migration as the second item in its
"Follow-up backlog" — the same pattern as the previous follow-up
(domain parsing, which became ADR-0013 itself). The work is not
gated on ADR-0013, but the timing is intentional: the SLD/TLD fix
corrects the *input* to the gate; this ADR corrects the *EUIPO
side* of the gate.
## Decision Drivers
1. **Principle 6 is non-negotiable** — every buy recommendation
must rest on a non-bypassable trademark check. A gate that
silently degrades to "EUIPO only returns 0 hits" is a
sub-version of the same failure mode.
2. **EU jurisdiction coverage is the entire point of the EUIPO
half of the gate.** A non-functional EUIPO provider is dead
code; a single-provider (USPTO-only) gate is a regression from
MVP.
3. **No paid API in MVP** (Principle 8) — the replacement must
use the same free EUIPO subscription model (OAuth2
client_credentials, free with identity verification).
4. **Wire format must be provider-agnostic at the gate level.**
The change happens entirely inside `EuipoProvider`. The
`TrademarkMatch` contract returned to the gate is unchanged.
5. **Defensive against RSQL injection** — the query string is
built from a user-influenced term (the SLD). The provider must
sanitise RSQL metacharacters before interpolating the term
into the query.
## Considered Options
### Option A: Migrate to Trademark Search 1.1.0 (CHOSEN)
Update the EUIPO adapter to the new API contract: RSQL query via
`?query=trademarkName==*<term>*`, OAuth2 Bearer token plus
`X-IBM-Client-Id` header, paged response envelope, broadened
inactive-status filter.
**Advantages:**
- The EUIPO half of the gate becomes live again. Real EU
marks are now surfaced, restoring cross-jurisdiction coverage.
- The change is scoped to one file (`euipo-provider.ts`); the
provider-agnostic contract keeps the gate and the detector
untouched.
- The `X-IBM-Client-Id` is the same identifier as the OAuth2
`client_id`, so no new env var is needed; the operator only
has to re-paste the same credential into the same env var.
- RSQL metacharacter sanitisation closes a small but real
injection vector (`*`, `'`, `"`, `\\`, whitespace).
- The status filter broadens to cover `CANCELLED`,
`SURRENDERED`, `INVALID`, `LAPSED`, `REVOKED` — the previous
filter only excluded `REFUSED`, `WITHDRAWN`, `EXPIRED`.
- The 401/403 error message becomes actionable: it tells the
operator to verify `EUIPO_CLIENT_ID` and the
Trademark Search 1.1.0 subscription. The old message was
terse and unhelpful.
**Disadvantages:**
- The EUIPO subscription must be active against the new API.
EUIPO issues new credentials for the new endpoint; the
operator has to re-register (free, 1-3 business days for
identity verification). Out of scope for this code change;
documented in the migration plan.
- The OAuth2 token URL is not part of the official spec
documents surfaced by EUIPO. The default
(`https://euipo.europa.eu/oauth2/token`) is a placeholder
until a verified current URL is known; the operator can
override it via `EUIPO_AUTH_URL`. This is a follow-up item
with a documented env-var escape hatch.
- Pagination is not implemented. The first 50 hits are
returned; if a search term produces more than 50 EU marks,
the tail is silently dropped. At single-user scale with
conservative `expected_value` thresholds, the operator
rarely sees a TM search term with >50 active marks; a
follow-up can iterate the `page` parameter when
`totalElements > size`.
**Cost Implications:** Trivial. ~85 LOC of provider code +
~150 LOC of test fixtures. No new dependencies, no schema
migration, no DB change.
**Risk Assessment:** Low. The provider contract
(`TrademarkMatch[]`) is unchanged, so the gate's behaviour is
preserved when EUIPO returns the same hits it did before. The
only observable change is that EUIPO no longer returns zero
hits for every query, which is a strict improvement on the
"silent zero" failure mode.
---
### Option B: Drop EUIPO, USPTO-only gate
Remove `EuipoProvider` from the gate entirely. The gate
becomes a single-provider (USPTO) check; the EU half of the
coverage is conceded.
**Advantages:**
- Dead-simple: ~50 LOC of deletions, no replacement code.
- One fewer credential to manage.
**Disadvantages:**
- The gate is regressing to a single-jurisdiction check. A
candidate that has only an EU registration (no US mark)
comes back Clear, which is exactly the silent-failure
pattern this ADR exists to prevent.
- The trademark gate is *more* conservative with both
providers (Principle 5). Removing one provider makes the
gate less conservative on the EU axis.
- The work is in the wrong direction: removing functionality
to fix a wiring bug.
**Cost Implications:** Trivial in code; the cost is the lost
EU coverage, which is real and silent.
**Risk Assessment:** Medium. The lost EU coverage is a
strict regression on the conservativity axis that Principle
5 and the EUIPO half of the gate were introduced to provide.
---
### Option C: Keep the COPLA endpoint, document the limitation
Leave the EUIPO adapter on the dead COPLA endpoint and add
a comment explaining that EUIPO silently returns zero hits
on every query.
**Advantages:**
- Zero code change.
**Disadvantages:**
- A "documented limitation" with no operator-actionable
remediation is a textbook recipe for the next contributor
to either reintroduce the bug or assume it is intentional.
- The gate is silently wrong in production. Every
`dominus run` since the COPLA retirement has been
clearing EU-only conflicts. The bias is invisible until
the operator acquires a name with an EU mark and gets a
cease-and-desist.
**Cost Implications:** Zero code work; the cost of the bias
is real and silent, exactly as in ADR-0012's Option D.
**Risk Assessment:** High. The bias is on the wrong axis of
Principle 5 (under-conservative on EU coverage), and it
cancels exactly the strict-USPTO-TLD rule that ADR-0012 just
landed.
## Decision
**Chosen option: Option A — migrate to Trademark Search
1.1.0.**
The provider-agnostic abstraction (Principle 1) is exactly
what makes this migration a one-file change. The
`TrademarkProvider` interface in
`src/providers/trademark/trademark-provider.ts` is unchanged;
`EuipoProvider` is the only file that learns the new wire
format. The gate, the detector, and the upstream scoring
engine are not touched.
For each decision driver:
- **Driver 1 (Principle 6)** — the EUIPO half of the gate
stops being a no-op. A candidate with an EU-registered
mark (no US mark) is now Blocked instead of Clear.
- **Driver 2 (EU jurisdiction coverage)** — restored, with
the same broad-coverage intent as before, against the new
API.
- **Driver 3 (no paid API)** — the new API uses the same
free EUIPO subscription model. No new vendor, no new
payment.
- **Driver 4 (provider-agnostic)** — the change is entirely
inside `EuipoProvider`. The gate's `TrademarkMatch[]`
contract is unchanged.
- **Driver 5 (defensive against RSQL injection)** — search
terms are sanitised against RSQL metacharacters
(`*`, `'`, `"`, `\\`, whitespace) before being interpolated
into the `?query=` parameter.
For each rejected alternative:
- **Option B (drop EUIPO)** concedes EU coverage, which is
the *primary* reason the EUIPO provider exists. Removing
it is a regression, not a fix.
- **Option C (keep COPLA, document)** preserves the silent
failure mode that the strict-USPTO-TLD rule was designed
to *avoid*. The bias is the wrong kind of conservatism.
## Consequences
### Positive
- **EU coverage restored.** Real EU marks surface again.
The previous silent-zero failure is replaced with a
working provider.
- **Provider abstraction validated.** A wire-format
migration on one of two providers touches one file. This
is the resilience Principle 1 was designed to provide.
- **Actionable error message.** A 401/403 from the new
endpoint tells the operator exactly what to verify
(`EUIPO_CLIENT_ID`, Trademark Search 1.1.0 subscription).
The old "unauthorised" message was unhelpful.
- **RSQL injection closed.** Search terms are sanitised
before RSQL interpolation. The character set allowed in
a term is the alphanumeric core; RSQL metacharacters are
stripped.
- **Broader status filter.** `CANCELLED`, `SURRENDERED`,
`INVALID`, `LAPSED`, `REVOKED` are now treated as inactive
in addition to the previous `REFUSED`, `WITHDRAWN`,
`EXPIRED` filter.
### Negative
- **EUIPO subscription must be re-issued.** EUIPO issues
new credentials for the Trademark Search 1.1.0 API; the
operator has to re-register (free, 1-3 business days for
identity verification). This is operational, not
code-side; it is documented in the migration plan.
- **OAuth2 token URL is a placeholder default.** The
default `EUIPO_AUTH_URL` is `https://euipo.europa.eu/oauth2/token`,
a path that may have changed. The operator can override
the env var when the official endpoint is confirmed. A
follow-up ADR can pin the default once the URL is
verified.
- **Pagination is not implemented.** The first 50 hits are
returned; the tail is silently dropped for terms with
more than 50 active EU marks. At single-user scale this
is invisible; a follow-up can iterate the `page`
parameter when `totalElements > size`.
- **The legacy `trademarks`/`total` response shape is
accepted for backward compat.** This makes the parser
more permissive than strictly necessary. The trade-off is
robustness to EUIPO API evolution and to pre-production
environments; the alternative is to break the test
fixtures on every EUIPO envelope rename.
### Compliance and Security Implications
- No new PII, no credentials, no API keys in code or
tests. The `X-IBM-Client-Id` reuses the OAuth2
`client_id`, which is the operator's existing secret.
- RSQL injection is mitigated by character stripping. A
term like `'; DROP TABLE` becomes `droptable` after
sanitisation; the RSQL parser sees a benign wildcard
query.
- The new API requires the operator to opt in to the
Trademark Search 1.1.0 subscription; until they do, the
EUIPO half of the gate is still effectively a no-op
(the new endpoint returns 401/403, which the gate
surfaces as `Unverified` with a helpful error message).
### Migration and Monitoring Plan
- **Migration:** no code migration. The provider is
updated in place; the gate's behaviour is restored on
the next `dominus run` once the operator's
`EUIPO_CLIENT_ID` is valid against the new API.
- **Operator actions:**
1. Register for the Trademark Search 1.1.0 subscription
at `https://euipo.europa.eu/ohimportal/en/open-data`
(free, 1-3 business days).
2. Paste the issued `client_id` and `client_secret`
into `EUIPO_CLIENT_ID` and `EUIPO_CLIENT_SECRET`
in `.env`.
3. Confirm `EUIPO_AUTH_URL` is correct; override if
EUIPO has rotated the OAuth2 token endpoint.
4. Run a smoke test: `dominus run --brandable nike`
should produce a `verdict=blocked` (Nike is a
registered EU mark).
- **Monitoring:** the gate's existing observability
surfaces 401/403 as `ProviderError` with
`code=EUIPO_UNAUTHORIZED`. A simple
`dominus run | grep "EUIPO_UNAUTHORIZED"` count over a
week gives a baseline of how often the new endpoint is
failing (expect near-zero once the subscription is
active).
- **Rollback:** revert the four commits. The
`TrademarkProvider` contract is unchanged; the previous
COPLA-based code is bit-recoverable. The bias returns
to the silent-zero failure mode, which is the exact
state before this ADR.
### Validation
- 20 unit tests in
`src/providers/trademark/__tests__/trademark-providers.test.ts`
cover the new wire format: RSQL query string, RSQL
metacharacter sanitisation, `X-IBM-Client-Id` header,
paged response envelope, legacy response backward
compat, broader status filter, and the actionable 401
error message.
- All 344 pre-existing tests in the repository pass
after the change (USPTO provider and gate tests are
untouched; the change is provider-local).
- Production validation: a `dominus run --brandable nike`
should produce a `verdict=blocked` for `nike.com` (USPTO
match) and a `verdict=blocked` for `nike.eu` and
`nike.co.uk` once the EUIPO subscription is active. The
pre-migration behaviour for the EU candidates was a
silent Clear.
### Follow-up Backlog
- ~~The `EUIPO_AUTH_URL` default is a placeholder. Pin it once the official
current URL is verified.~~ **Resolved.** The default is the verified
TMview production endpoint `https://auth.tmdn.org/oidc/access_token`
(`src/config.ts`), and `.env.example` documented the stale legacy URL
until the v1.1.0 release pass corrected it.
- ~~Pagination: iterate the `page` parameter when `totalElements > size` to
surface tail hits on high-volume search terms.~~ **Resolved (v1.1.0).**
`EuipoProvider.search()` now walks every page up to `EUIPO_MAX_PAGES`
(default 10 pages × `EUIPO_PAGE_SIZE` 50 records). Crucially it is
*fail-closed*: when the register declares more matching records than were
fetched, it throws `EUIPO_RESULT_TRUNCATED` instead of returning a
partial list, and `TrademarkGate` maps that to **Unverified** rather than
Clear. Enumerating the tail was the original ask; refusing to claim
"no match" without it is what makes the fix safe (Principle 6).
- A future hardening can swap the curated status filter
for an allow-list of EUIPO status codes once the EUIPO
documentation stabilises around the new values.
---
*This ADR was created following the MADR 4.0.0 standard. All DOMINUS
ADRs should be consistent with the product vision in
the ADR series starting at `docs/adr/0001-project-architecture.md`. Template:
`.claude/skills/adr/template.md`.*