Skip to content

fix(drift): verifica HTTP degli URL consumati — l'inventario non è autorevole - #437

Merged
Gabrymi93 merged 3 commits into
mainfrom
fix/url-drift-probe
Oct 28, 2026
Merged

Gabrymi93 merged 3 commits into
mainfrom
fix/url-drift-probe

Conversation

@Gabrymi93

@Gabrymi93 Gabrymi93 commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Sintesi

Il matcher url del referenced-item drift confrontava l'URL consumato con un solo distribution_url per item d'inventario. L'inventario non è autorevole per gli URL di http_file: può descrivere un altro endpoint dello stesso dato, o una sola distribuzione per item. Risultato: due falsi positivi già in produzione, su URL che rispondono 200.

Contesto collegato

Nessuna issue: emerso misurando durante l'onboarding di open-consip (fase 1 della decentralizzazione). Motivazione dell'assenza di issue: il difetto non era segnalato, è stato trovato confrontando il drift con la realtà HTTP.

Cosa cambia

  • Modifica script (radar, inventory, source-check, MCP)
  • Documentazione

Checklist

  • pytest tests/ passa (429 passed, 2 skipped)
  • ruff check . passa
  • mypy scripts/ so_mcp/ passa
  • Comando manuale verificato (so-catalog --dry-run su indice-pa e open-consip)

Verifica

Repo Prima Dopo
indice-pa 8 missing (tutti 200 OK) 0 missing, 9/9 matched
open-consip 15 missing (tutti 200 OK) 1 missing, 15 verificati via HTTP

Causa dei falsi positivi:

  • indice-pa: l'inventario agid descrive catalogocloud.agid.gov.it, il repo consuma indicepa.gov.it/ipa-dati/... → endpoint diversi, stesso dato;
  • open-consip: l'inventario tiene un resource per package, il repo consuma più anni dello stesso package.

Il ref rimasto su Consip è drift reale: consumi-in-convenzione-2023.csv risponde 404 e il portale espone solo 2024/2025/2026.

Semantica: inventario conferma l'URL → matched (nessuna rete); altrimenti decide la verifica HTTP — 2xx → matched, risposta di errore → missing, errore di rete → unknown (un timeout non deve produrre un falso drift). Con inventario vuoto (fonti radar-only, es. dcl-bologna) il check ora gira, dove prima era checked=0. Senza url_probe il comportamento resta quello storico.

Note per chi revisiona

  • La semantica di missing si restringe: spariscono i falsi positivi, restano i drift veri. Un unknown in più è preferibile a un drift falso.
  • 401/403/407/429 sono unknown, non drift: un blocco WAF o un rate limit non prova che la risorsa sia stata rimossa. Conta sulle fonti che ci proteggono con 403 (ANAC, MUR, Consip hanno già un user_agent dedicato).
  • I ref templati ({year}) non si sondano: l'URL concreto lo genera la pipeline a runtime, quindi sondarli darebbe missing per costruzione. Resta l'esito dell'inventario. Vale per 30 URL in 6 repo (es. dichiarazioni-italia, 16 ref).
  • La verifica di esistenza non legge il corpo (read_body=False): basta lo status. La lettura è comunque protetta — su uno stream resettato il probe ora è "non concludente" (http_code "-") invece di sollevare, che avrebbe fatto fallire il build.
  • I registry/catalog.json committati nei repo si ripuliscono al primo run dopo il merge (cron settimanale): fino ad allora contengono i missing vecchi.
  • Costo: N richieste HTTP per run sugli URL non confermati (indice-pa 9, Consip 15), sequenziali, con cache per run. Parallelizzabili se crescono.
  • L'UA del probe arriva da [tool.observatory], per le fonti dietro WAF.
Tipo: root fix
Problema reale: il drift `url` usava l'inventario come oracolo di esistenza per
  URL che l'inventario non descrive (endpoint diverso, o una sola distribuzione
  per item) → 23 falsi positivi su 2 repo.
Contratto riusato/sostituito: riusa `probe_pattern` (pattern_probes);
  `check_referenced_items` accetta un `url_probe` tri-stato iniettato.
Codice rimosso/duplicazione: nessuna; l'euristica sull'inventario resta come
  fallback (`_url_state_from_inventory`).
Test aggiunti: 9 — 6 sul drift (probe conferma / rimozione / inconcludente /
  non chiamato quando l'inventario conferma / inventario vuoto / ref templato),
  1 sull'adattatore (mapping + cache + UA + 403 non concludente), 2 sulla
  robustezza del probe (lettura del corpo fallita → non concludente;
  `read_body=False` non legge).
Rischio residuo: N richieste HTTP in più per run; un 403 da WAF sarebbe un falso
  drift (mitigato dall'UA di config).
Follow-up obbligatorio: verificare il 404 di Consip sul repo open-consip.

…torevole

Il matcher `url` confrontava l'URL consumato con un solo `distribution_url`
per item d'inventario. Due falsi positivi già in produzione:

- AGID/indice-pa: l'inventario descrive `catalogocloud.agid.gov.it`, il repo
  consuma `indicepa.gov.it/ipa-dati/...` → 8 "missing" su URL che rispondono 200
- Consip: l'inventario tiene un resource per package, il repo consuma più anni
  → 15 "missing" su URL che rispondono 200

Ora `check_referenced_items` accetta un `url_probe` opzionale: se l'inventario
conferma l'URL → matched (nessuna rete); altrimenti decide la verifica
dell'URL consumato — 2xx → matched, risposta di errore → missing (drift vero),
errore di rete → unknown (nessun falso drift). Con inventario vuoto (fonti
radar-only) il check funziona solo grazie al probe. Senza probe il
comportamento resta quello storico.

- drift.py: `url_probe` (tri-stato) in `_url_state`/`_state_for`/
  `check_referenced_items`; l'euristica inventario diventa
  `_url_state_from_inventory`
- build.py: `_UrlProbe` adatta `probe_pattern` al tri-stato, con cache per run
  e UA da `[tool.observatory]` (fonti con WAF)
- pattern_probes.py: `probe_pattern` accetta `user_agent`
- runbook: come funziona il drift degli URL consumati

Misurato: indice-pa 8 missing → 0 (9 verificati via HTTP); Consip 15 → 1.
Il ref rimasto è drift reale: `consumi-in-convenzione-2023.csv` risponde 404 e
il portale espone solo 2024/2025/2026.

Verifiche: 435 passed / 2 skipped, mypy e ruff puliti.
Due difetti trovati verificando il fix su casi reali (open-consip, indice-pa):

1. **URL templati sondati letteralmente.** Un ref con placeholder
   (`...index.php?tree={year}CIVATOT0201`) veniva passato al probe così com'è:
   l'URL concreto lo genera la pipeline a runtime, quindi la verifica dava
   `missing` per costruzione. Colpiva 30 URL in 6 repo (es. dichiarazioni-italia,
   16 ref). Ora i ref templati non si sondano: resta l'esito dell'inventario.

2. **Il probe poteva sollevare.** `probe_pattern` leggeva il corpo con
   `resp.content`: su uno stream resettato dal peer (`ChunkedEncodingError`)
   l'eccezione usciva dal probe e avrebbe fatto fallire il build del catalog.
   Ora la lettura è protetta e un fallimento è "non concludente" (`http_code`
   `"-"`), coerente con gli errori di rete.

In più, la verifica di esistenza degli URL consumati non legge più il corpo
(`read_body=False`): basta lo status, evitando di scaricare N file grandi per
un controllo di esistenza. `content_length` resta `None` in quel caso.

Verifiche: 432 passed / 2 skipped, mypy e ruff puliti; reale su open-consip
(21 checked, 20 matched, 1 missing = il 404 del 2023) e indice-pa (9/9).
Un blocco WAF o un rate limit non prova che la risorsa sia stata rimossa:
mappare "qualunque codice HTTP di errore" a `missing` produceva falsi drift
proprio sulle fonti che ci proteggono con 403 (ANAC, MUR, Consip hanno già
un user_agent dedicato in config per questo motivo).

Ora i codici 401/403/407/429 danno `unknown` (non concludente), come gli
errori di rete. Restano `missing` i 404/410 e gli altri errori definitivi.

- build.py: `_INCONCLUSIVE_HTTP_CODES` + mapping in `_UrlProbe._probe`
- runbook: semantica aggiornata (codici non concludenti, ref templati)
- test: caso 403 nell'adattatore (atteso `None`)

Verifiche: 432 passed / 2 skipped, mypy e ruff puliti.
@Gabrymi93
Gabrymi93 merged commit ab047dc into main Oct 28, 2026
1 check passed
@Gabrymi93
Gabrymi93 deleted the fix/url-drift-probe branch October 28, 2026 12:45
Gabrymi93 added a commit that referenced this pull request Oct 28, 2026
…atch per kind (#439)

* fix(drift): `missing` solo con prova di assenza — regola unica, non patch per kind

Il drift dichiarava `missing` quando un ref non si trovava nell'inventario,
assumendo che l'inventario descrivesse tutto ciò che la fonte pubblica. Non è
vero per 3 dei 5 kind, e ogni kind che poggia sull'assunzione perdeva:

- `ckan_resource`: l'inventario tiene UNA distribuzione per item, ma un
  package CKAN ne ha in media decine → open-mur segnava 9 resource "sparite"
  che invece esistono (verificato con `resource_show`);
- `ckan_dataset`: il matcher toglieva il placeholder e faceva `startswith`,
  che funziona solo con la parte fissa in testa: `{year}-nome` non matchava
  mai → 1 falso positivo su open-mur;
- `url`: già risolto con la verifica HTTP (#437), stesso principio.

Ora la regola è una sola, dichiarata in `drift.ABSENCE_EVIDENCE`: `missing`
solo con prova positiva di assenza — inventario **completo** per quel kind,
oppure verifica diretta dell'item. Un kind non dichiarato non può dichiarare
`missing`: al massimo `unknown`.

- drift.py: tabella `ABSENCE_EVIDENCE` (load-bearing in `_state_for`),
  `_match_ckan_dataset` con placeholder come wildcard, nuovo
  `_ckan_resource_state` (probe o `unknown`)
- build.py: `_CkanResourceProbe` — `resource_show` sull'endpoint CKAN della
  fonte, cache per run, UA di config; 403/429/rete → non concludente
- runbook: la regola della prova di assenza, con la tabella per kind

Misurato: open-mur **10 missing → 0** (13/13 matched, 9 via `resource_show`);
nessuna regressione su appalti-pubblici (13/13), indice-pa (9/9), e il drift
**reale** di Consip resta segnalato (1 missing, il 404 del 2023).

Verifiche: 452 passed / 2 skipped, mypy e ruff puliti.

* test(drift): la tabella è il registro dei kind — gate a uguaglianza

Follow-up dei finding della review su #439.

- `_MATCHERS` non elenca più `ckan_resource` (kind a tre stati): ogni kind ha
  un solo percorso
- `_state_for` consulta `ABSENCE_EVIDENCE` come **primo** passo: un kind non
  dichiarato non entra nel check, e la prova dichiarata decide se l'assenza
  può diventare `missing`
- gate a **uguaglianza** (dichiarato ⇔ instradabile) invece che a
  sottoinsieme: copre anche "dichiarato ma non instradabile"
- nuovo test: un kind fuori tabella non entra nel check

Comportamento invariato, verificato su open-mur (13/13, 9 via resource_show),
appalti-pubblici (13/13) e open-consip (1 missing reale).

Verifiche: 453 passed / 2 skipped, mypy e ruff puliti.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant