./run il-consiglio-dellai-non-conta-il-guardrail-che-lo-boccia-si
Il consiglio dell'AI non conta: conta il guardrail che lo boccia
Come ho portato un piccolo cluster mail da DMARC p=none a enforcement reale senza bruciare la posta legittima: readiness score, rollout a onde e un advisor LLM che ho progettato per non contare.
- status
- draft
- project
- dmarcEnforcement
- updated
- 2026-07-07
- tags
p=none è il record DMARC più diffuso al mondo, ed è anche il più inutile: dichiara “controllatemi” e poi non fa applicare niente. Chiunque può ancora spedire posta a nome tuo. Il problema è che il passo successivo — quarantine, poi reject — è quello che ti fa finire la posta legittima in spam se sbagli, e non è un rollback da un minuto: il danno lo vedi giorni dopo, nei report aggregati. Ho costruito una piccola piattaforma per fare quel passo su un cluster mail personale (due nodi, una manciata di domini) in modo osservabile e reversibile. E la cosa più interessante non è stata l’advisor LLM che raccomanda le policy. È stato progettarlo perché il suo parere non contasse.
L’idea
Un dominio DMARC dovrebbe avere sempre uno stato chiaro: chi spedisce legittimamente a nome suo, quanta di quella posta è allineata (SPF/DKIM che tornano con il dominio giusto), e qual è la prossima mossa sicura. La piattaforma raccoglie i report aggregati DMARC in Postgres, classifica ogni IP sorgente, calcola uno score di prontezza da 0 a 100, e produce una raccomandazione di policy. Nessun cambio DNS automatico: la macchina consiglia, l’umano applica a mano. Questo non è pigrizia, è la garanzia di sicurezza principale.
Come funziona davvero
Lo score è una somma pesata deterministica, non una scatola nera:
score = round(
40 * clamp(pct_aligned_30d) # allineamento è tutto
+ 15 * int(dkim_present_for_primary_sources)
+ 15 * int(spf_within_10_lookups) # SPF entro 10 lookup DNS
+ 10 * days_ratio # osservazione / 14 giorni
+ 10 * (1 - clamp(unknown_sources_ratio))
+ 10 * (1 - clamp(spoofing_likely_volume_ratio))
)
Quaranta punti su cento vengono dall’allineamento: se la maggior parte della posta non torna allineata, non sei pronto per niente, punto. Da quello score esce una progressione monotona e cauta — mai il salto diretto che uccide la deliverability:
p=none
p=quarantine; pct=10 → 25 → 50 → 100
p=reject; pct=10 → 50 → 100
Con soglie dure in codice: reject solo sopra 90 di score, quarantine solo sopra 60, e comunque almeno 14 giorni di osservazione prima di chiedere pct=100. La classificazione delle sorgenti è altrettanto brutale e leggibile: oltre 500 messaggi da un IP non allineato e con ASN sconosciuto → spoofing_likely; IP del cluster locale → trusted; meno di 50 messaggi → unknown_low_volume. Regole, non vibes.
La spina: l’advisor che ho reso irrilevante
Poi ho aggiunto l’LLM. La tentazione era darci in pasto il dossier del dominio e fare quel che dice. Ho fatto l’opposto. L’advisor ha una catena di provider — un proxy Claude, poi OpenAI, poi il fallback a regole deterministiche — ma il punto chiave è che ogni percorso, incluso il successo del modello, passa per la stessa funzione di guardrail:
def validate_recommendation(payload, readiness_score, has_blocking_unknown=False):
if payload.get("recommended_policy") not in ALLOWED_POLICIES:
raise ValueError("invalid recommended_policy")
if policy == "reject" and readiness_score < 90:
raise ValueError("reject blocked by readiness guardrail")
if policy == "quarantine" and readiness_score < 60:
raise ValueError("quarantine blocked by readiness guardrail (<60)")
if has_blocking_unknown and policy in {"quarantine", "reject"}:
raise ValueError("enforcement blocked by unknown sources")
...
Se il modello raccomanda reject su un dominio con score 74, il guardrail lo boccia con un ValueError, e quel provider viene trattato come fallito: si passa al successivo nella catena. Il prompt di sistema dice al modello le stesse regole (“non raccomandare reject sotto 90”), ma non mi fido del prompt: mi fido del raise. Il modello è una fonte di ipotesi; l’autorità è il codice deterministico che le convalida.
La scoperta contro-intuitiva è arrivata sul fallback. Il fallback a regole è pensato per “non fallire mai” — deve sempre restituire qualcosa di sicuro alla dashboard. Ma il fallback deriva la sua raccomandazione dallo score… e lo score può scendere sotto soglia a metà finestra, mentre il dossier che sta valutando ne conteneva uno più alto. Risultato: la regola deterministica può raccomandare un enforcement che il guardrail poi rifiuta. Due pezzi della mia stessa logica in disaccordo. La soluzione non è farli combaciare a forza, è degradare in modo esplicito:
try:
validated = validate_recommendation(recommendation, readiness_score, has_blocking_unknown)
except ValueError as exc:
validated = {
"recommended_policy": "none", # in caso di dubbio, non fare niente
"recommended_pct": 100,
"rationale": f"safe fallback: {exc}",
"risk_level": "high",
"confidence": 0.4,
"generated_by": "rules:safe-fallback",
}
Quando anche il fallback viene bocciato, la risposta non è un’eccezione che rende l’advisor non disponibile: è “torna a p=none”, che è sempre sicuro. Meglio una dashboard che dice onestamente “non consiglio di muovermi” che una che si rompe. In più: cache SHA256 sul dossier canonico (TTL 6h) per non bruciare quota, e un circuit breaker per provider (3 fallimenti → parcheggiato 300s) per non incassare cascate di timeout.
Il resto è dry-run ovunque
Il rollout della 0.4.0 è arrivato a onde (A→G): scan DNS, snapshot Rspamd, validatore XSD dei report, gate d’invio outbound, tooling di rotazione DKIM, metriche Prometheus, CI. La regola d’oro scritta in cima al piano: nessun dato cancellato, nessuna email persa. Ogni worker è read-only fuori dal DB e idempotente — lo scan-dns inserisce una riga solo quando il valore DNS è cambiato rispetto all’ultimo snapshot, e logga il drift; non tocca mai il DNS. Il retention job gira in dry-run di default e conta soltanto finché non riguardo i numeri a mano. E apply_dns_change in produzione fa sempre e solo questo:
def apply_dns_change(*, dns_auto_apply, domain, new_value):
if not dns_auto_apply:
raise DnsApplyBlocked("DNS auto-apply disabled until Phase 4 approval workflow")
L’auto-apply è cablato su OFF. La feature “cambia il DNS da solo” non è mai stata attivata, e nel piano non lo sarà senza un’approvazione esplicita.
Gotcha onesti
- Drift doc/realtà, in due direzioni. Il conteggio dei test balla: un doc dice “157 passed”, il runbook di rollout ne dichiara “168”, e oggi nel repo ci sono 225 funzioni
test_su 28 file. I numeri nei changelog invecchiano più in fretta del codice. E il README si sottovaluta: dice “scaffold MVP locale, worker in dry-run”, ma i report interni mostrano già domini inp=quarantine; pct=100in produzione monitorata, con Gmail che confermadkim=pass / spf=pass / dmarc=pass. La realtà è più avanti della sua vetrina. - Il guardrail su
quarantineè arrivato dopo. Nella prima versione solorejectaveva una soglia; un provider poteva prescriverequarantinesu un dominio con dati praticamente a zero. Ho aggiunto la soglia a 60 quando ho realizzato che “meno di reject” non vuol dire “sicuro”. - Il circuit breaker è in-memory. Al riavvio del pod riparte pulito. Per un advisor a bassa frequenza va benissimo; se questo diventasse un servizio ad alto volume, quello stato andrebbe condiviso.
Come va
Shipped, e in uso. Un piccolo gruppo di domini è a p=quarantine; pct=100 con evidenza esterna positiva; altri sono in “watch”, in attesa di più volume prima di promuoverli. L’advisor LLM è in coda dietro gli altri gate: fino ad allora è la catena a regole a rispondere, che è esattamente il punto — funziona senza modello. Nessun cambio DNS è stato applicato automaticamente. Nemmeno uno.
Cosa ho imparato
Quando metti un LLM in un percorso operativo, la domanda giusta non è “quanto è bravo il modello”, è “cosa succede quando ha torto, e chi lo ferma”. La risposta migliore che ho trovato è far convergere ogni fonte — modello, regola, cache — sullo stesso guardrail deterministico, e progettare il fallimento come stato di prima classe: se tutto crolla, la raccomandazione è “non muoverti”. Nel dominio della posta questo si traduce in una regola quasi noiosa: la sicurezza non è nel consiglio, è nel raise che lo può bocciare. E il fatto che perfino la mia logica deterministica possa contraddire il guardrail mi ha ricordato che “deterministico” non vuol dire “sempre d’accordo con te stesso nel tempo”.