Novità

Una voce per data, la più recente in alto. Ogni voce dice cosa è cambiato, a chi può servire e come si adotta. Se non richiede nulla, lo dice. Il contratto completo è nella documentazione dell'API.

Versione del contratto in produzione: 1.2 (dal 29/9/2026). Un nome di modello non cambia mai i suoi vettori: se cambierà il modello, cambierà il nome.

2026-10-20 — Le chiavi dei nostri prodotti si rinnovano da sé: contratto di 90 giorni, 72 ore in cui la vecchia e la nuova valgono insieme, e il segreto nuovo va nel caveau — mai in una chat. La prima rotazione vera è il 10/12, e oggi nessuna vostra richiesta cambia

Cosa cambia per voi oggi: niente, e si misura. L'orologio del rinnovo gira ogni notte alle 03:30 (Europe/Rome) e stanotte ha detto quello che dirà anche domani: 5 chiavi di casa attive guardate, 0 da rinnovare, 0 in preavviso. Contratto 1.2 invariato: nessuna rotta nuova, nessun campo nuovo, nessun codice di errore nuovo, nessuna migrazione, nessuna chiave da riemettere.

Cos'è, in una frase. Una chiave dei nostri prodotti ha un contratto di 90 giorni. Quando li finisce, l'orologio ne fa nascere una nuova con gli stessi parametri, scrive il segreto nuovo nel caveau dentro la stessa transazione che la crea, e tiene valida la vecchia per 72 ore: in quelle ore le due chiavi valgono entrambe, e nessuna vostra richiesta cade nel passaggio da una all'altra.

LE DATE, misurate e non promesse, lette in sola lettura dalla nostra base il 20/10 alle 03:46Z. La prima rotazione vera è il 10/12/2026 e riguarda nis2 e lg231; il 11/12/2026 tocca a trpg e aiepod; il 6/1/2027 ad adp. Il preavviso è di 30 giorni, quindi dal 10/11 le prime due compaiono fra quelle «in preavviso» — un numero che cambia da sé, senza che nessuno tocchi niente. Da qui al 10/12 l'orologio continuerà a trovare 0 chiavi da rinnovare ogni notte: è il comportamento giusto di un orologio che fa il suo lavoro, non un orologio fermo.

La cosa che vi serve davvero sapere è una domanda, e il momento di farsela è adesso e non il 10/12: il vostro servizio rilegge la chiave dal caveau quando parte, o ne tiene una copia scritta a mano? Quello che dal 19/10 è in esercizio è la catena fino al caveau: l'orologio ruota, chiude il segreto nuovo nella cassetta della regia dentro la stessa transazione, e il serverino lo ritira. L'ultimo tratto — dal caveau al file d'ambiente del vostro prodotto — è un timer vostro, che la regia apre box per box, e quel tratto è esattamente la ragione per cui la finestra è di 72 ore e non di 24: per un prodotto che rilegge la chiave solo al riavvio quel tratto può arrivare la sera dello stesso giorno, cioè venti ore dopo le 03:30, e con 24 ore di grazia il margine su una catena di tre pezzi sarebbe stato di tre ore e mezza. Con 72 è di due giorni e mezzo. Se invece la chiave è incollata dentro un file di configurazione, un'immagine o una variabile scritta a mano, quella copia ha 72 ore: passate quelle riceve 403 KEY_EXPIRED, che è il codice e la forma di sempre. Il momento buono per togliere quella copia non è il 10/12 alle 03:30: è un giorno qualsiasi da qui a novembre, con le due chiavi ancora lontane dalla rotazione.

Cosa NON cambia, e ogni riga qui è misurata e non dedotta. La chiave nuova eredita owner, azienda (tenant), ambiti, limite al minuto, tetto mensile, contratto e persona digitale: stesse quattro rotte di calcolo, stessi limiti, stessi codici. La cache dei vettori è dell'azienda e non della chiave — la sua chiave è (modello, tenant, impronta del testo) — quindi dopo una rotazione nessun calcolo si ripaga. I tetti del contratto e dell'azienda sommano le righe di consumo, non le chiavi: la rotazione non vi regala caratteri e non ve ne toglie. Il tetto della chiave, invece, è per chiave, e una chiave nuova comincia il mese da zero: su tutte e cinque le chiavi di casa quel tetto è illimitato (misurato, 5 su 5), quindi per nessuna di loro quella differenza esiste.

Il minuto, durante le 72 ore in cui le chiavi sono due. Le due chiavi hanno due finestre al minuto separate (300 richieste al minuto ciascuna, per i nostri prodotti), ma quella del contratto è una sola e si somma fra tutte le chiavi che stanno sotto di lui: 2940 al minuto per il contratto dei prodotti del gruppo, 300 per quello di adp. Quindi per adp il minuto non si allarga nemmeno con due chiavi vive, perché il numero del contratto è lo stesso della chiave; e per gli altri la somma delle due resta molto sotto il tetto del contratto. Nessuno deve spostare traffico a mano, e nessuno va più veloce per il fatto di avere due chiavi vive.

Una cosa cambia davvero, e la diciamo prima che la scopriate da un grafico. GET /v1/usage risponde per chiave: dopo la rotazione la chiave nuova mostra i propri consumi, e lo storico dei mesi precedenti resta sul key_id vecchio. Se tenete un conto mensile per chiave, nel mese della rotazione ne vedete due righe invece di una, e la somma delle due è il vostro consumo vero. Il tetto del contratto, al contrario, continua a sommarle da sé: lì non c'è niente da ricomporre.

Chi NON ruota da sé, e il perché detto invece di lasciarvelo scoprire. L'orologio guarda solo le chiavi dei nostri prodotti e servizi, per un elenco scritto nella direttiva del titolare. Le altre 7 chiavi attive restano a mano, e per una di loro la ragione si capisce da sé: ruotare in automatico la chiave con cui si amministrano le chiavi vuol dire cambiarsi la serratura mentre la si tiene in mano. La chiave di prova passepartout ha una scadenza sua, il 29/10, e non è di casa: nessun orologio la rinnova, e quel giorno smette di rispondere come c'è scritto da quando è nata. Se usate una chiave di prova e vi serve una chiave vera, si chiede a chi vi segue.

Nessun testo, e nessun segreto in nessun giornale. Del rinnovo restano chi, quando e le due impronte — i primi 12 caratteri dello sha256 della chiave vecchia e di quella nuova — e mai il valore di nessuna delle due. Le impronte servono a una cosa sola: poter dire «quella nel caveau è la chiave di questa riga» senza che il segreto passi da una chat, da un log o da un file di lavoro. È la regola di sempre, e vale anche qui: il plaintext di una chiave si vede una volta sola.

Quando: in linea dal 19/10 alle 21:59Z, e l'istante è quello della leva che mancava, non di un annuncio. Verificato sul box in esercizio e in sola lettura, non dedotto dal nostro repo: l'unità dell'orologio dichiara da quell'istante il programma che porta il segreto nuovo al caveau, e la prova sta nel giornale dei due giri. Il giro del 19/10 alle 01:30:02Z — prima della leva — si chiudeva con la riga «il programma che scrive il segreto nuovo nel caveau non è impostato: il giorno che una chiave scade questo giro si fermerà senza ruotare niente»; il giro del 20/10 alle 01:30:03Z — dopo la leva — ha fatto lo stesso lavoro e quella riga non c'è più, perché non c'è più niente da avvisare. Fra i due, nessuna chiave è stata toccata: entrambi dicono «5 chiavi di casa attive guardate, 0 in preavviso», entrambi si sono chiusi con esito 0, e il motore del servizio ha 0 riavvii non chiesti. La catena intera — ruotare, consegnare al caveau, ritirare — era già stata provata per intero sulla macchina di prova, sugli stessi byte del programma che sta in esercizio.

Come si adotta: oggi niente da fare, e nessuna chiave da riemettere. L'unica cosa che vale la pena guardare è la domanda di sopra — se la chiave del vostro servizio arriva dal caveau o è scritta a mano da qualche parte — e si guarda adesso, con cinquantun giorni di margine, non la mattina della rotazione.

2026-10-17 — Le tre intestazioni X-RateLimit-* adesso arrivano anche sugli errori che nascono dopo l'autenticazione: 413 BATCH_TOO_LARGE, 413 TEXT_TOO_LONG, 400 EMPTY_INPUT, 429 QUOTA_EXCEEDED, su tutte e quattro le rotte di calcolo. Niente da fare, e nessuna chiave da riemettere

Cosa cambia per voi: se frenate da soli leggendo quelle intestazioni, adesso i numeri sono veri anche quando la richiesta è stata respinta. Prima su quegli errori le tre intestazioni sparivano, e il posto del minuto era già stato consumato: chi legge X-RateLimit-Remaining per regolare il ritmo teneva un numero più alto del vero di tanti quanti erano stati gli errori. Contratto 1.2 invariato: nessuna rotta nuova, nessun campo nuovo, nessun codice di errore nuovo, nessuna migrazione.

Il danno, a cifre, perché non è una questione di forma. Sette richieste che finiscono in errore consumano sette posti del minuto. Prima il client ne vedeva consumare zero: si credeva a 59 posti rimasti mentre ne aveva 51, e il freno che vi vendiamo — «così il vostro log non vede più errori di rate» — scattava in ritardo, prendendosi il 429 che doveva evitarvi. Adesso ne vede sette su sette: il conto che tiene in mano e quello del servizio coincidono.

Dove le intestazioni restano assenti, ed è voluto: gli errori di chiave (401, 403), il 422 di un corpo non valido e GET /v1/usage. Non è una dimenticanza. In quei casi il posto del minuto non è stato consumato — il 422 arriva perfino prima dell'autenticazione — e controllare i propri consumi resta gratis. Le pagine delle API lo dicono fra le eccezioni dichiarate, nelle due lingue.

Cosa NON cambia: il contratto resta 1.2, nessun prezzo, nessun limite più stretto per nessuno, nessuna chiave da riemettere. Le risposte 200 portavano già le tre intestazioni e le portano identiche, e il 429 RATE_LIMIT continua a portare il suo Retry-After. Nessun testo in più viene letto o registrato: di un rifiuto restano un'etichetta e un istante.

Quando: in linea dal 17/10 alle 00:00Z, col rilascio del motore. Verificato sul motore in esercizio, non dedotto dal nostro repo: il app.py che sta in /opt/agilev, copiato in sola lettura e dato come soggetto alle prove di questa cura, passa tutti e 23 i giudizi (il motore e i tre documenti che pubblicano la promessa), 0 KO, e il sorgente di prima ne fa cadere 11; il processo del motore è nato alle 00:00:03Z, cioè DOPO che quel file è stato scritto (00:00:02Z), quindi è quel codice a rispondervi. Non è un GET: queste intestazioni stanno dietro l'autenticazione, e provarle con la chiave di un cliente gli lascerebbe un rifiuto e dei caratteri in fattura per una richiesta che non ha chiesto. E dal bordo pubblico, con un GET senza chiave, /health dichiara il motore su dal 17/10 alle 00:00Z, col contratto 1.2 e l'ambiente produzione.

Come si adotta: niente da fare. Se il vostro client tiene le ultime intestazioni lette per frenare da solo — il nostro di riferimento lo fa — non c'è nulla da toccare: riceve numeri veri in più casi di prima.

E dello stesso giorno, sullo stesso 422, la seconda cosa: adesso porta un error.code, ed è BODY_INVALID. Il 422 è la risposta a un corpo che non si legge, ed era l'ultima risposta che poteva arrivarvi senza error.code: chi legge i nostri errori per codice teneva un ramo «non so» aperto per lei sola. Il conto delle risposte che possono arrivarvi senza error.code scende da UNA a NESSUNA. Era l'ultima aperta, e con questa si chiude.

E non vi rimanda più indietro i vostri testi nel corpo dell'errore. Misurato dal bordo pubblico, senza chiave, prima e dopo il rilascio: prima il corpo del 422 era di 123 byte e portava l'eco di quello che la sonda aveva mandato; dopo è di 402 byte, porta BODY_INVALID, e di eco ne porta ZERO. Un corpo d'errore che vi restituisce ciò che ci avete inviato è un vostro testo che riattraversa la rete e che può finire nel vostro giornale: da stamattina non accade più.

Quando: in linea dal 17/10 alle 03:37Z, col rilascio 9fdfaf6 del motore e con 0 riavvii non chiesti — e l'istante è quello del motore, non quello delle nostre pagine. Le pagine pubbliche che portano il conto nuovo (api e errori-dal-client, nelle due lingue) sono al bordo dalle 09:42:59Z dello stesso giorno, con una copia del sito senza riavvio del motore: misurate subito dopo, tutte e quattro dicono zero forme aperte.

Come si adotta, anche questa: niente da fare, e nessuna chiave da riemettere. Chi nel proprio lettore d'errore teneva un ramo «non so» per le risposte senza error.code adesso può togliere l'ultimo caso: il codice c'è su tutte.

2026-10-12 — Un terzo tetto di caratteri al mese, quello dell'azienda: somma tutte le chiavi di uno stesso cliente, comprese quelle fuori contratto. Oggi nessuna azienda ne ha uno, quindi nessuna vostra richiesta cambia

Cosa cambia per voi oggi: niente, e si misura. Un tetto dell'azienda esiste solo se ce ne scriviamo uno, e in questo momento nessuna azienda ne ha: 0 su 3 (misurato in sola lettura su una copia della nostra base, solo conteggi, nessun testo e nessun segreto). Nessun codice nuovo, nessun campo nuovo, nessuna rotta nuova, nessuna chiave da riemettere: contratto 1.2 invariato.

Cos'è, in una frase. Fino a oggi i tetti dei caratteri al mese erano due: quello della chiave e quello della licenza (il contratto), che si consuma insieme fra tutte le chiavi nate sotto di lui. Adesso ce n'è un terzo, quello dell'azienda, e la differenza è quali consumi somma: tutte le chiavi di quel cliente, comprese quelle che un contratto non ce l'hanno e quelle che avete ruotato o che abbiamo revocato dopo che avevano già consumato. Quando un tetto c'è e si riempie, la risposta è il 429 QUOTA_EXCEEDED che conoscete già, con lo stesso Retry-After — i secondi al primo istante del mese nuovo in UTC.

La cosa che vi serve davvero sapere: il messaggio dice QUALE dei tre tetti ha morso. «Quota mensile di caratteri esaurita» è la vostra chiave, «quota mensile della licenza esaurita» è il contratto, «quota mensile del tenant esaurita» è l'azienda. Senza quella parola chi riceve il 429 alza il numero sbagliato e il rifiuto resta identico: è l'unico motivo per cui le tre frasi sono diverse. Il tetto più stretto si nomina per primo, quindi se sforate il vostro sentite parlare del vostro.

Dove si leggono i numeri dell'azienda, e dove no, detto invece di lasciarvelo scoprire: i totali di tutte le chiavi di un cliente li vede chi amministra quel cliente, in GET /admin/usage. In GET /v1/usage non li mettiamo, e la ragione non è la pigrizia: quell'aggregato attraversa le persone digitali di una stessa azienda, e una chiave di una persona che leggesse i consumi di tutte le altre sarebbe esattamente il confine che stiamo chiudendo. La vostra chiave continua a vedere solo i propri consumi e il proprio contratto.

Perché l'abbiamo fatto, col danno vero e non in astratto. Con due tetti un'azienda non aveva nessun numero che la misurasse intera: due contratti sotto lo stesso cliente sono due tetti separati, e una chiave senza contratto — che da noi nasce come chiave di servizio — passava accanto al tetto del contratto per costruzione, perché quel tetto somma «le chiavi che nominano questa licenza». Un tetto che si aggira emettendo una chiave in più non è un tetto. Il tetto nuovo segue il tenant, cioè l'etichetta dell'azienda che ogni chiave porta dalla nascita, e per la stessa ragione la somma la leggiamo dalla riga di consumo e non dalla chiave: se la leggessimo dalla chiave, cancellarne una azzererebbe i caratteri già consumati.

Cosa NON cambia: il contratto resta 1.2, nessun prezzo, nessun limite più stretto per nessuno, nessun campo obbligatorio, nessuna migrazione, nessuna chiave da riemettere. Il 429 QUOTA_EXCEEDED è lo stesso codice di prima — un codice nuovo avrebbe rotto i client che lo gestiscono già — e GET /v1/usage risponde con gli stessi campi. Nessun testo in più viene letto o registrato: di un rifiuto restano un'etichetta e un istante.

Quando: in linea dal 12/10 alle 22:20Z, col rilascio del motore. Verificato sul motore in esercizio e sul nostro database in sola lettura, non dedotto dal nostro repo: il app.py in esercizio, dato alle prove del tetto come soggetto, le passa tutte e 22, e il sorgente di prima ne fa cadere 16 — e il processo del motore è nato DOPO che quel file è stato copiato, quindi è quel codice a rispondervi. Non è un GET: i tetti stanno dietro l'autenticazione, e provarli con la chiave di un cliente gli lascerebbe un rifiuto e dei caratteri in fattura per una richiesta che non ha chiesto. La prova che il motore nuovo sta davvero girando l'ha lasciata lui nel nostro database: la tabella del tetto dell'azienda c'è, e il codice di prima su una base nuova non la crea; letta in sola lettura ha zero righe, cioè nessun tetto, cioè nessuna vostra risposta è cambiata. E dal bordo pubblico, con un GET senza chiave, /health dichiara il motore su da questo istante, col contratto 1.2 e l'ambiente produzione.

Come si adotta: niente da fare. Se un giorno il vostro contratto avrà un tetto dell'azienda, lo saprete prima da chi vi segue, non da un 429.

2026-10-12 — Il campo tenant nel corpo: sulle quattro rotte di calcolo adesso la rotta lo dichiara e lo verifica. Corpo muto, il tenant è quello della vostra chiave; corpo che nomina il vostro, passa; corpo che nomina un altro, 403 TENANT_UNKNOWN. Facoltativo per tutti, e nessuna chiave da riemettere

Quello che vi può rompere lo diciamo prima del resto: una risposta che era 200 diventa 403 solo per chi scrive nel corpo un tenant che non è il suo. Chi non manda il campo — cioè tutti i client di oggi — non vede nessuna differenza: stessi vettori, stessa cache (nessun calcolo da ripagare), stessa fattura, stessi codici. Chi lo manda col proprio nome vede cambiare una cosa sola: il campo non torna più fra gli ignored_fields, perché adesso è una dichiarazione che verifichiamo invece di una parola che buttiamo via. Il 403 lo riceve solo chi nomina un tenant che non è quello della sua chiave, ed è contato fra i vostri rifiuti in GET /v1/usage (campo rejected), come tutti gli altri.

Cosa cambia per voi: una dichiarazione che il servizio accettava e non guardava, adesso la guarda. Sulle quattro rotte di calcolo (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) il campo tenant è facoltativo e si comporta così: corpo muto, il tenant è quello della vostra chiave e lo mette il motore — carattere per carattere quello che è sempre accaduto; corpo che nomina il vostro, 200 come prima; corpo che nomina un altro, 403 TENANT_UNKNOWN. «Sconosciuto» e non «non è il vostro» per una ragione sola: dirvi che un altro cliente esiste sarebbe già un dato suo, la stessa ragione per cui fra clienti diversi rispondiamo 404 e non 403. È additivo: contratto 1.2 invariato, nessuna rotta nuova, nessuna migrazione, nessuna chiave da riemettere.

Perché l'abbiamo fatto, detto col danno vero e non in astratto. Un campo che accettiamo e non guardiamo è peggio di un campo che non esiste. Fino a ieri {"tenant":"un-altro-cliente"} riceveva 200, calcolava e fatturava nel vostro tenant — quello della chiave, l'unico che il motore ha mai usato — e il nome scritto nel corpo vi tornava indietro fra gli ignored_fields. Chi scriveva quel campo per prudenza, o lo copiava dalle nostre rotte di amministrazione (dove il tenant c'era già e vale un'altra cosa), leggeva una dichiarazione accettata e credeva di avere un confine che non esisteva da nessuna parte. Un'illusione di isolamento è il contrario dell'isolamento: adesso il nome nel corpo e il nome sulla chiave devono coincidere, e a scegliere il tenant resta la chiave — il corpo non lo decide mai.

Cosa NON cambia: il contratto resta 1.2, e lo dice il bordo pubblico, non noi — riletto alle 16:53Z, insieme all'ambiente produzione. Nessuna rotta nuova, nessun prezzo, nessun limite più stretto, nessuna chiave da riemettere, nessun campo obbligatorio per nessuno. Il tenant delle rotte di amministrazione (/admin/*) non è questo e vale come prima. E nessun testo in più viene letto o registrato: di un rifiuto restano un'etichetta e un istante, mai la richiesta che l'ha preso.

Il secondo tempo, dichiarato adesso e non quando arriva: il campo tenant obbligatorio per tutte le chiavi è una cosa che vi diremo prima, con la data. Oggi il campo non è obbligatorio per nessuno, e non lo diventerà senza quell'annuncio: è l'altra metà della stessa regola di persona, e le due metà arrivano in due tempi per lo stesso motivo — un confine che nasce come un rifiuto a sorpresa è una rottura del contratto, non un isolamento in più.

Quando: in linea dal 12/10 alle 18:26Z, col rilascio del motore. Verificato sul motore in esercizio e dal bordo pubblico, non dedotto dal nostro repo: il app.py in esercizio, letto in sola lettura, porta la guardia che confronta il tenant del corpo con quello della chiave, e il processo del motore è nato DOPO che quel file è stato copiato — non è un GET, perché auth() viene prima della guardia e provarlo con la chiave di un cliente gli lascerebbe una riga fra i suoi rifiuti per una richiesta che non ha chiesto; e dal bordo pubblico, con un GET senza chiave, /health dichiara il motore su da questo istante, col contratto 1.2 e l'ambiente produzione.

Le misure, e cosa NON abbiamo misurato. Il contratto di questo campo è lo stesso nel documento e nella pagina che lo portano: le tabelle appaiate riga per riga danno 10 tabelle in comune, 54 righe confrontate, 0 divergenti — e fra quelle 54 c'è la riga nuova del 403 TENANT_UNKNOWN. Dal bordo pubblico, con un GET senza chiave — che non consuma né quota né richieste al minuto —, alle 16:53Z il contratto si legge 1.2. Una cosa non l'abbiamo misurata dal bordo, e preferiamo scriverla invece di contarla fra le verdi: il 403 TENANT_UNKNOWN dal bordo pubblico vorrebbe la chiave di un cliente, e una richiesta che quel cliente non ha chiesto gli lascerebbe una riga fra i suoi rifiuti — il rejected di GET /v1/usage è suo, non nostro. I vostri testi non li leggiamo e non li registriamo, nemmeno per misurare: le scene girano su modelli finti e basi usa-e-getta, e dalla produzione sono usciti solo conteggi. Come si adotta: niente da fare — e se volete scrivere il campo, scrivete il vostro nome e nient'altro.

2026-10-12 — Se la vostra chiave è di una persona, il campo persona adesso non dovete più mandarlo: il corpo che tace prende la persona della chiave. Il 400 PERSONA_MISSING non lo manda più nessuno

Cosa cambia per voi: una risposta che era 400 diventa 200, e nessuna che era 200 cambia. La voce dell'11/10 — quella qui sotto, che resta dov'è — vi diceva che una chiave emessa per una persona pretende il campo persona nel corpo, e che senza quel campo la risposta è 400 PERSONA_MISSING. Quella frase è superata da oggi: se il corpo tace, la persona la mette il motore, ed è quella scritta sulla chiave che ha aperto la porta — mai un'altra. Sulle quattro rotte di calcolo (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) non c'è niente da adottare, e non è un modo di dire: 0 chiavi su 18 sono di una persona oggi (misurato in sola lettura, solo conteggi), quindi nessuna vostra richiesta cambia risposta.

Quello che NON è cambiato lo diciamo prima del resto, perché è l'unica cosa che vi può rompere. Il 403 PERSONA_UNKNOWN è ancora lì: un corpo che nomina una persona diversa da quella della chiave resta 403, perché a nome di chi si calcola non lo scegliete voi — lo impone la chiave. E persona resta l'unica eccezione alla promessa «i parametri che non conosciamo li ignoriamo»: se la vostra chiave non è di nessuna persona e mandate il campo lo stesso, la risposta è 403, non un 200 con un campo ignorato. Se avevate messo il campo nel vostro client dopo la voce dell'11/10, potete toglierlo: con una chiave di persona il corpo muto adesso basta.

La riga del 400 PERSONA_MISSING nella tabella degli errori NON la cancelliamo. Per un giorno è stato un codice vero, e qualcuno può averlo scritto nel proprio client: la riga dice che non lo manda più nessuno e che tornerà col secondo tempo, quando il campo diventerà obbligatorio per tutti. Cancellarla farebbe sparire la domanda di chi lo trova scritto nel suo codice.

Perché l'abbiamo fatto, detto col danno vero e non in astratto. La regola dice «persona obbligatoria e imposta». Applicarla alla lettera oggi sarebbe stata una rottura del contratto pubblico: le chiavi che calcolano davvero avrebbero preso un 400 al primo tentativo, e la più grossa di tutte ne fa più di dodicimila al mese. Così il confine arriva in due tempi: adesso la persona è imposta dove c'è (e non si allarga di un millimetro — nessun valore predefinito cade su un'altra persona), e il campo obbligatorio per tutti avrà una data annunciata in anticipo invece di essere scoperta da un rifiuto.

Il secondo tempo, dichiarato adesso e non quando arriva: il campo persona obbligatorio per tutte le chiavi è una cosa che vi diremo prima, con la data. Le chiavi che fanno parlare più persone digitali — ce n'è una, ed è quella che consuma di più — restano senza persona legata fino a quel giorno: legarle a una persona sola sarebbe una persona falsa, cioè il contrario dell'isolamento che stiamo costruendo.

E una cosa dalla nostra parte, che nel contratto non si vede e ve la diciamo comunque. Il database dove stanno le vostre chiavi e i vostri contatori — mai i vostri testi, che non registriamo — poteva essere ricreato vuoto da un nostro programma di manutenzione, quello che ogni cinque minuti guarda se c'è una richiesta di licenza da inoltrare. Oggi è successo: alle 10:31:58Z un comando di un nostro banco di prova ha cancellato quel file, e cinque minuti dopo quel programma l'ha rifabbricato vuoto, uscendo senza errore — nessuna unità fallita, nessun allarme, e un database vuoto da fuori non si distingue da uno sano. Il servizio è stato ripristinato dalla copia delle 10:22:30Z: le 18 chiavi ci sono tutte e nessun testo di nessuno è coinvolto; quello che si è perso è al massimo una riga di consumo, quella della richiesta delle 10:31:20Z. Se i vostri conti di ottobre e i nostri divergono di una richiesta, è quella. Da oggi quel programma, se il database non c'è, si ferma e lo dice — col percorso che gli è stato dato e con quello risolto — invece di fabbricarne uno: l'unità fallisce, cioè qualcuno se ne accorge.

Quando: in linea dal 12/10 alle 15:23Z, col rilascio del motore — e quell'istante lo dice il motore di sé stesso (motore.da di GET /health), non lo deduciamo dall'ora in cui abbiamo copiato un file; riletto dal bordo pubblico alle 15:3xZ insieme a contract 1.2, che non è cambiato. Nessuna rotta nuova, nessun prezzo, nessun limite più stretto.

Le misure, e cosa non siamo riusciti a misurare. Le due cose di questa voce sono provate sul motore dispiegato e non sul nostro repo: la persona imposta con 4 casi su 4 sulla copia del file in esercizio (corpo muto 200 con la persona della chiave, altra persona 403, chiave senza persona 200), e il database che non si rifabbrica lanciando per davvero il programma contro un percorso inesistente — esce con errore e dopo, sul disco, non c'è niente. Una cosa non l'abbiamo potuta misurare, e preferiamo dirla: il comportamento dell'unità col database mancante vorrebbe togliere di mezzo il database vero, e quello non lo facciamo — abbiamo provato il programma che l'unità esegue, e letto le sue direttive. Come si adotta: niente da fare.

2026-10-12 — Chi amministra un contratto non può leggere quelli degli altri nemmeno su un database dove una nostra migrazione non è passata: il filtro del tenant non si svuota più. Non c'è niente da adottare, e nessuno è stato esposto

Cosa cambia per voi: niente da adottare. Le quattro rotte di calcolo (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) non sono toccate: nessun campo nuovo, nessun codice d'errore nuovo, contratto 1.2 invariato, nessuna migrazione del database. Cambia una cosa che vi riguarda e non passa da voi: la regola che tiene le vostre licenze, il vostro registro delle operazioni e la vostra fattura illeggibili a chi amministra un altro contratto adesso è scritta, e non più appoggiata a due nostri predefiniti che si annullavano a vicenda.

Nessuno è stato esposto, e non è una rassicurazione: è una misura. La condizione che apriva il buco era un nostro database in cui la colonna tenant sulla tabella delle chiavi non fosse stata aggiunta. Nel servizio in esercizio quella colonna c'è — letto in sola lettura, solo conteggi — e in quello stato un secondo predefinito portava comunque ogni amministratore al nostro tenant interno, cioè l'isolamento stava in piedi per coincidenza fra due predefiniti. Nessun dato di nessun cliente è stato leggibile da un altro cliente, in nessun momento. Ve lo diciamo lo stesso perché un isolamento che regge per coincidenza non è un isolamento: il giorno in cui uno dei due predefiniti cambia — e cambiano, è il nostro lavoro — il confine sparisce senza che nessun errore lo dica.

Il difetto, detto per quello che era. La funzione che aggiunge «e solo del tuo tenant» alle interrogazioni decideva se farlo guardando la bandiera di migrazione di un'altra tabella: quella delle chiavi. Ma le interrogazioni che proteggeva girano su tre tabelle diverse — licenze, registro, consumi — e le prime due portano la colonna del tenant nel proprio schema, cioè ce l'hanno su *ogni* database. Con quella bandiera giù il filtro sparisce da tutte e tre insieme. Adesso la colonna la dichiara chi fa l'interrogazione, l'unico che sa su quale tabella sta girando, e dove la colonna non c'è il risultato non è più «nessun filtro»: è vuoto per chiunque non sia il nostro tenant interno. Un confine che si chiude quando non sa, invece di aprirsi.

Cosa NON cambia: il contratto resta 1.2, e lo dice il bordo pubblico, non noi — riletto alle 09:31Z. Nessuna rotta nuova, nessun prezzo, nessun limite più stretto, nessuna chiave da riemettere, nessun campo nuovo. E nessun testo in più viene letto o registrato: questa cura tocca una condizione WHERE, non ciò che ci mandate.

Quando: in linea dal 12/10 alle 09:22Z, col rilascio del motore — e quell'istante lo dice il motore di sé stesso (motore.da di GET /health), non lo deduciamo dall'ora in cui abbiamo copiato un file; il motore.pid che risponde dal bordo pubblico è lo stesso processo che il box vede in piedi, e il file dispiegato ha il sha256 identico al sorgente rilasciato, quindi è quel codice a rispondervi. L'accettazione del rilascio: 25 controlli, 0 KO, nessun riavvio di troppo, 0 warning nel giornale del motore.

Le misure: 7 prove nuove (tests/test_filtro_del_tenant_che_si_svuota.py), di cui 6 rosse contro i sorgenti di prima — l'elenco delle licenze, la licenza singola, la revoca di una licenza altrui, il registro e la fattura: è il «prima» misurato, non raccontato. La settima guarda il verso opposto, dove la colonna davvero non c'è e il nostro amministratore interno deve continuare a vedere, perché una faccenda nostra non vi costa la risposta. bench/mutazioni-il-filtro-che-si-svuota.sh: 12 guasti su 12 visti, 0 ciechi, col gruppo di controllo sulle copie sane a 133 passati prima del giro — e quattro dei dodici sono la cura troppo larga, cioè il filtro messo dove non va, che toglierebbe a noi la vista sulle nostre cose senza dare a voi un grammo di isolamento in più. Banco intero sulla punta rilasciata: 2863 passate, 0 fallite, 3 saltate. I vostri testi non li leggiamo e non li registriamo, nemmeno per misurare: le scene girano su database usa-e-getta e dalla produzione sono usciti solo conteggi.

2026-10-11 — Una chiave può essere di una persona, e allora ogni richiesta dice a nome di chi calcola: campo persona, 400 PERSONA_MISSING e 403 PERSONA_UNKNOWN. Le vostre chiavi di oggi non devono fare niente

Cosa cambia per voi: niente da adottare, e non è un modo di dire. Da oggi una chiave può essere emessa per una persona digitale, e allora le quattro rotte di calcolo (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) pretendono il campo persona nel corpo: senza, 400 PERSONA_MISSING; con un nome diverso dal suo, 403 PERSONA_UNKNOWN. Nessuna chiave di oggi è di una persona: la colonna è nata vuota al riavvio e, letta in sola lettura sul servizio in esercizio, dice 0 chiavi su 17 con una persona — nessuna attiva, nessuna revocata. Il vostro corpo, i vostri vettori, la vostra cache e la vostra fattura restano quelli di ieri, su tutte e quattro le rotte.

L'unica cosa che oggi vi può rompere è provare il campo nuovo, e ve la diciamo prima del resto. Il contratto vi promette da sempre che i parametri che non conosciamo li ignoriamo: persona è l'unica eccezione, e lo è per forza. Se la vostra chiave non è di nessuna persona e mandate il campo lo stesso, la risposta è 403 PERSONA_UNKNOWN, non un 200 con un campo ignorato — perché calcolare, mettere in cache e fatturare a nome di un soggetto che nessuno ha dichiarato è esattamente la cosa che questa regola esiste per impedire. Finché non siete voi a chiederci una chiave di persona, il campo non si manda: non c'è niente da provare, e provarlo è l'unico modo di prendere un errore che prima non esisteva.

Perché l'abbiamo fatto, detto col danno vero e non in astratto. Nel servizio la persona non esisteva: zero colonne, zero campi, e al posto di un confine c'era una convenzione scritta nel codice — «una persona singola, per AgileV, è un tenant con una chiave sola». Una convenzione non partiziona niente, e la cache dei vettori è l'unico posto dove il contenuto di chi chiama incontra quello di qualcun altro: due persone dello stesso tenant si servivano il vettore a vicenda, cioè lo stato di una era visibile all'altra. Da adesso la cache si taglia per tenant + persona, e il confine se lo portano dietro l'emissione e — dove sarebbe sparito senza che nessuno se ne accorgesse — la rotazione: una chiave ruotata resta della stessa persona, da tutte e due le porte con cui la ruotiamo.

Cosa NON cambia: il contratto resta 1.2, e lo dice il bordo pubblico, non noi — riletto alle 15:43:26Z. Nessuna rotta nuova, nessun prezzo, nessun limite più stretto, nessun campo obbligatorio per chi non ha una persona. E nessun testo in più viene letto o registrato: della persona resta un'etichetta di 1-32 caratteri fra lettere, cifre, _, . e - — mai un nome di persona fisica, mai un'e-mail — e nemmeno quella esce dal servizio.

Quando: in linea dal 11/10 alle 15:29Z, col rilascio del motore — e quell'istante lo dice il motore di sé stesso (motore.da di GET /health), non lo deduciamo dall'ora in cui abbiamo copiato un file; riletto dal bordo pubblico alle 15:43:26Z, insieme a contract 1.2. Una cosa dal bordo pubblico non ve la possiamo mostrare, e la scriviamo invece di contarla fra le verdi: il 400 PERSONA_MISSING, perché nel mondo non c'è ancora nessuna chiave di persona a cui chiederlo. Quel rifiuto l'abbiamo provato sul file dispiegato e non sul nostro repo — /opt/agilev/app.py copiato in sola lettura e dato alle prove come soggetto: 38 prove passate, 0 fallite — e l'impronta sha256 di quel file è identica a quella del sorgente rilasciato, quindi è quel codice che vi sta rispondendo.

Le misure: bench/mutazioni-la-persona-che-manca.sh, 16 guasti su 16 visti, 0 ciechi, col gruppo di controllo a 155 passati prima del giro — e le due metà sono guardate insieme, perché una cura troppo larga (chiedere il campo anche a chi non ha una persona) romperebbe tutti voi mentre protegge nessuno. Nel banco che gira a ogni passo ci sono 38 prove (tests/test_persona.py): il campo che manca, il nome di un'altra, il campo mandato da una chiave senza persona, la cache che non deve passare un vettore, l'emissione e la rotazione che non devono perdere il confine, e la metà che non deve cambiare. Il contratto è lo stesso nei tre documenti e nelle due lingue: le tabelle appaiate riga per riga danno 53 righe confrontate, 0 divergenti. Banco intero sulla punta che porta il codice che vi risponde: 2819 passate, 4 saltate — e l'unica rossa del giro non era il servizio, ma un nostro foglio di rilascio a cui mancava proprio la riga con questi numeri, curato prima di pubblicare questa voce. I vostri testi non li leggiamo e non li registriamo, nemmeno per misurare: le scene usano modelli finti e basi usa-e-getta, e dalla produzione sono usciti solo conteggi.

2026-10-11 — Una chiave emessa per voi adesso nasce vostra: senza il campo tenant, POST /admin/keys e POST /admin/licenze rispondono 400 TENANT_MISSING invece di far nascere la cosa nel nostro tenant

Cosa cambia per voi: da adottare, niente — e una cosa che vi riguarda lo stesso. Le due rotte che fanno nascere una chiave o una licenza non sono vostre: stanno su una rete privata, fuori dal bordo pubblico, e le chiama solo chi amministra il servizio. Fino a ieri, se chi emetteva dimenticava di scrivere di chi è la cosa che stava creando, quella cosa nasceva nel nostro tenant interno. Una chiave emessa per voi e finita lì sarebbe stata una chiave i cui consumi non sono vostri: non li vedreste in GET /v1/usage, non starebbero nella vostra fattura, e la vostra quota non li fermerebbe. Da adesso quel campo dimenticato è un rifiuto — 400 TENANT_MISSING — e la cosa non nasce affatto.

Chi era esposto, misurato prima di cambiare e non dedotto dopo: nessuno. Le chiavi con l'ambito che apre quelle due rotte, in esercizio, sono zero — né attive né revocate, su 17 chiavi in tutto; il quaderno delle emissioni ha 15 righe e nessuna è arrivata da lì; e le 2 emissioni registrate portano il tenant scritto, quindi col codice nuovo sarebbero passate uguali. Nessuna chiave di nessun cliente è nata nel posto sbagliato. Ve lo diciamo lo stesso perché il danno sarebbe stato silenzioso: una chiave che funziona benissimo e paga la persona sbagliata non dà nessun errore a nessuno, e l'isolamento fra tenant è una cosa che vi promettiamo, non una comodità nostra.

Se amministrate un tenant, il vostro, non cambia niente: il campo che manca vale il vostro, l'unico che potreste scrivere. Chiedervi di ripetere ogni volta l'unica risposta ammessa sarebbe una rottura del contratto senza un grammo di isolamento in più. Il rifiuto riguarda solo chi può emettere per conto di altri.

Cosa NON cambia: il contratto resta 1.2; nessuna rotta pubblica, nessun campo, nessun codice d'errore nuovo sul bordo, nessun prezzo, nessun limite più stretto. Le due rotte toccate non stanno sul bordo pubblico, e non è una promessa: alle 11:38Z un GET da internet su /admin/keys e su /admin/licenze riceve il nostro 404 NOT_FOUND di 263 byte, lo stesso di un indirizzo inventato. Le quattro rotte di calcolo — quelle che chiamate davvero — non cambiano di un byte. E nessun testo in più viene letto o registrato: del rifiuto resta solo un'etichetta e un istante.

Quando: in linea dal 11/10 alle 11:21Z, col rilascio del motore — e quell'istante lo dice il motore di sé stesso (motore.da di GET /health), non lo deduciamo dall'ora in cui abbiamo copiato un file; riletto dal bordo pubblico alle 11:38:09Z, insieme a contract 1.2. Una cosa dal bordo pubblico non ve la possiamo mostrare, e la scriviamo invece di contarla fra le verdi: il 400 stesso, perché quelle rotte da fuori non esistono e nel mondo non c'è ancora nessuna chiave con l'ambito che le apre. Il rifiuto l'abbiamo provato sul file dispiegato, non sul nostro repo: il file del motore in esercizio copiato in sola lettura e dato ai test come soggetto — 18 prove passate, 0 fallite — e il processo in esercizio è nato 0,522 secondi dopo quel file, quindi è quel file che sta eseguendo.

Le misure: il banco delle mutazioni, 8 guasti su 8 visti, 0 ciechi, col gruppo di controllo a 96 verdi prima del giro — e uno degli otto è la cura troppo larga invece che troppo stretta, cioè il caso in cui avremmo chiesto il campo anche a chi ha un tenant solo. Nel banco che gira a ogni passo ci sono 18 prove: il rifiuto sulle chiavi, il rifiuto sulle licenze, la riga nel quaderno con l'azione giusta e nel tenant di chi ha provato, e la metà che non deve cambiare. Banco intero sulla punta rilasciata: 2784 passati, 0 falliti. I vostri testi non li leggiamo e non li registriamo, nemmeno per misurare: le scene usano modelli finti e basi usa-e-getta, e dalla produzione sono usciti solo conteggi.

2026-10-10 — GET /v1/usage adesso dice quando vi abbiamo risposto l'ultima volta e quante volte vi abbiamo respinti: due campi nuovi, last_at e rejected

Cosa cambia per voi: la sola finestra che vi diamo sui vostri consumi diceva quanto e mai quando. Nel mese in corso un cliente fermo dal primo e uno che ci ha chiamato un minuto fa erano la stessa riga. Da adesso GET /v1/usage porta due campi in più: last_at, l'istante dell'ultima chiamata riuscita della chiave su qualunque operazione (secondi epoch UTC, 0 = non ne ha mai fatta una), presente anche su ogni riga di usage mese per mese e per operazione; e rejected, una riga per mese, operazione e code con quante volte vi abbiamo respinti e quando l'ultima — i 403 (KEY_REVOKED, KEY_EXPIRED, SCOPE_NOT_ALLOWED, e i codici del contratto) e i 429 (RATE_LIMIT, QUOTA_EXCEEDED). Lista vuota è la risposta normale. Tutto gratis come il resto della rotta: non consuma quota, non consuma il minuto, si legge da un controllo automatico. Il contratto completo dei due campi è nella documentazione dell'API.

A che serve, detto col caso vero e non in astratto: i numeri del mese non dicono se un'integrazione si è fermata, perché una riga che non cresce è identica a un cliente che in quel mese non ha avuto lavoro. E un deploy che di notte parte con la chiave sbagliata, o con una chiave scaduta, vede i suoi 403 nell'istante in cui li prende e poi niente: il vostro client li ha persi, e fino a oggi non li scrivevamo da nessuna parte — vivevano in RAM, aggregati per rotta, azzerati a ogni riavvio del servizio (a ottobre il riavvio del 5/10 è stato il terzo del mese). Adesso le due metà della stessa domanda si leggono insieme: last_at più vecchio di quanto dovrebbe essere più una riga di rejected che cresce è la vostra configurazione e non il nostro servizio; last_at vecchio e rejected ferma vuol dire che da voi non sta partendo nulla.

Quattro limiti, detti prima che ci contiate sopra. (1) I 401 non ci sono, e non ci saranno: KEY_MISSING e KEY_INVALID arrivano da una chiave che non è nel nostro archivio, quindi non c'è nessuna chiave a cui addebitarli — e l'impronta di un segreto che non è nostro, di qualcuno che non è un nostro cliente, non è un dato che vogliamo conservare. Una chiave sbagliata di un carattere non lascia traccia qui: quella si vede solo dal vostro lato. (2) I numeri partono da oggi e non sono retroattivi: i mesi già scritti hanno last_at a 0 finché non arriva la prima chiamata riuscita, e i rifiuti di prima del rilascio non esistono per nessuno. (3) Su chiave revocata o scaduta questa rotta risponde 403 come tutte le altre, quindi la chiave che vorreste indagare è proprio quella che non vi apre più — e i rifiuti sono per chiave: con la propria chiave si leggono i 429 e i 403 SCOPE_NOT_ALLOWED, perché su quelli la chiave resta buona; per gli altri chiedeteceli. (4) Le liste sono tagliate: ultimi 24 mesi per usage, 48 righe più recenti per rejected, mese più recente prima. Se ruotate le chiavi, la chiave nuova parte da last_at a 0 e la vecchia resta ferma al suo ultimo istante: è il comportamento giusto, ma non sommatelo a mano credendo di leggere il contratto.

Cosa NON cambia: il contratto resta 1.2; nessuna rotta, nessun campo obbligatorio, nessun codice d'errore nuovo, nessun prezzo e nessun limite più stretto. I due campi sono additivi e stanno in una risposta che oggi leggete già: chi non li legge non si accorge di niente, e nessuna richiesta che oggi funziona cambia di un byte. Nessun testo in più viene letto o registrato: le due colonne nuove portano solo etichette (mese, operazione, codice) e numeri — del perché di un rifiuto non scriviamo la richiesta che l'ha preso, e non la scriveremo.

Quando: in linea dal 10/10 alle 10:23Z, col rilascio del motore — e quell'istante lo dice il motore di sé stesso (motore.da di GET /health), non lo deduciamo dall'ora in cui abbiamo copiato un file. Verificato dal bordo pubblico e non dal nostro repo, alle 10:45:19Z: un GET su https://api.agilev.agile.software/v1/usage con una nostra chiave di vigilanza risponde 200 e il corpo porta last_at accanto a rejected, con la lista vuota — che è la risposta normale. E il campo si sta già riempiendo dal traffico vero: alle 10:45:10Z una riga di consumi portava il proprio istante, 1.279 secondi dopo la nascita del processo in esercizio, mentre le righe scritte prima del rilascio restano a 0 — il limite (2) misurato, non promesso. Anche il limite (1) è misurato dallo stesso bordo: una chiave finta prende 401 KEY_INVALID e non lascia nessuna riga di rifiuto. Una cosa da fuori non ve la possiamo mostrare, e lo scriviamo invece di contarla fra le verdi: il last_at su ogni riga di usage, perché la nostra chiave di vigilanza non calcola niente e quindi non ha righe — quel pezzo lo prova il banco, che le righe se le fabbrica.

Le misure: dodici giudizi sul risultato — cercano una riga che nomini la chiave e porti un istante dentro la finestra del fatto, da qualunque tabella arrivi — da 2 ok / 10 KO a 12 ok / 0 KO; e due di quei dodici provano lo strumento del banco, perché gli altri dieci cadevano tutti insieme e un banco cieco direbbe la stessa cosa. Nel banco che gira a ogni passo ci sono 19 prove, e l'ultima famiglia tiene i due campi nel contratto nei tre documenti e nelle due lingue coi nomi presi dalla risposta e non battuti a mano. Il banco delle mutazioni: 12 guasti su 12 visti, 0 ciechi, col gruppo di controllo a 0 KO prima del giro — e uno dei dodici ha trovato un difetto della cura che il nostro giudizio non aveva visto: la tabella nuova si creava senza la prudenza delle altre due migrazioni, e nel posto che non si lascia scrivere sarebbe stato ogni rotta autenticata a 500 per una tabella che serve a noi. E dopo il rilascio, un banco preso dal bordo pubblico: 8 ok / 0 KO su 8 giudizi sul servizio in esercizio, più 1 giudizio «non misurato» — quello delle righe — segnato così apposta, perché un controllo su una lista vuota riesce sempre e contarlo fra i verdi vorrebbe dire dichiarare una prova che non ha guardato niente. I vostri testi non li leggiamo e non li registriamo, nemmeno per misurare: le scene usano modelli finti e basi usa-e-getta, e dalla produzione sono usciti solo conteggi.

2026-10-10 — La domanda di /v1/rerank era il solo campo senza il tetto che vi pubblichiamo, ed è quello che paghiamo una volta per documento: da adesso ha gli stessi 32.000 caratteri di un documento

Cosa cambia per voi: su /v1/rerank il conto che finisce nella vostra fattura è len(query) × numero di documenti + somma dei documenti. La moltiplicazione è giusta — il cross-encoder legge la coppia (domanda, documento) una volta per documento — ma la query era il solo campo del contratto senza il tetto che la tabella dei Limiti pubblica per un testo: 32.000 caratteri, 413 TEXT_TOO_LONG. Il campo senza tetto era proprio quello moltiplicato, quindi il massimo che una richiesta di rerank potesse costarvi non esisteva. Da adesso la domanda ha lo stesso tetto di un documento, e quel massimo è 8.192.000 caratteri: due volte i 4.096.000 che calcolate dalle prime due righe dei Limiti (il suo per i documenti, e uno per la domanda ripetuta).

I numeri, misurati e non stimati: una domanda da 200.000 caratteri con 128 documenti passava con 200 e scriveva 25.601.280 caratteri nella riga dei consumi, in una richiesta di 202 KB. Il corpo più grande che il bordo accetta (8.000.000 byte) ne scriveva 1.023.750.400, cioè 249,94 volte il massimo calcolabile dalla pagina. E costava tempo a chi stava in fila: la domanda viene tokenizzata una volta per documento dentro il lucchetto che serializza il modello, e quella richiesta lo teneva ~404 secondi — sei volte e mezzo il minuto su cui è tarato il limite per minuto, che era l'unico freno rimasto.

Nessuno di voi ci è arrivato, e lo sappiamo dai contatori: chi usa il rerank più di tutti manda 14,68 documenti per richiesta e una domanda sotto i cento caratteri, ricavata dalle sue colonne. Non è una porta che qualcuno ha forzato: è il caso normale di chi fa RAG — la «query» di una conversazione è spesso la domanda riscritta più la storia — e con i 30-50 candidati che raccomandiamo noi ogni carattere della domanda si paga 30-50 volte, senza che da nessuna parte fosse scritto. Non c'è niente da adottare se la vostra domanda sta sotto i 32.000 caratteri, e nessuna richiesta che oggi funziona smetterà di funzionare.

Se invece la superate: ricevete 413 TEXT_TOO_LONG, e il messaggio dice quale dei due campi ha sforato (query o documents) — su questa rotta quel codice ha due origini, e un errore che non dice quale vi lascia solo la strada di indovinare. Il tetto arriva prima della fattura: una richiesta rifiutata non consuma quota e non lascia nessuna riga nei vostri consumi.

Cosa NON cambia: il contratto resta 1.2; nessuna rotta, nessun campo, nessun codice nuovo (TEXT_TOO_LONG c'era già), nessun prezzo e nessun limite più stretto di quelli che la tabella dei Limiti pubblicava — la query adesso li rispetta, prima era l'eccezione non scritta.

Quando: in linea dal 10/10 alle 06:28Z, col rilascio del motore. Verificato sul motore in esercizio e dal bordo pubblico, non dedotto dal nostro repo: il app.py in esercizio, letto in sola lettura, porta il tetto sulla query e il processo del motore è nato DOPO che quel file è stato copiato — non è un GET, perché auth() viene prima del tetto e provarlo con la chiave di un cliente gli scriverebbe una riga di fattura; e dal bordo pubblico, con una nostra chiave di servizio, una domanda da 40.000 caratteri riceve 413 TEXT_TOO_LONG col campo query nel messaggio.

Le misure: sei scene in gabbia, da 14 ok / 7 KO a 22 ok / 0 KO. Il banco non si fida del proprio ricordo dei limiti: li legge dal codice del servizio e la promessa su top_n dal paragrafo della documentazione, così pretende quello che vi abbiamo scritto e non quello che crede. Il conto non è dedotto da una formula: la formula viene verificata su tre richieste misurate prima di essere usata per calcolare il massimo, e se non le predice il banco si ferma invece di dare un verdetto su un conto che non ha capito. Una scena legge la nostra base in sola lettura per contare quante chiavi col rerank avessero una quota mensile che potesse fermare quella richiesta: zero su nove. Una legge il tempo col tokenizer vero del reranker. E un controllo che deve restare verde prima e dopo: l'esempio pubblicato nella pagina dell'API costa esattamente quello che la formula predice — una cura che avesse cambiato il prezzo del caso normale lo avrebbe reso rosso, e sarebbe stata una cosa da dirvi. Più 9 prove (3 rosse sul motore di prima) e 6 guasti su 6 visti dal banco delle mutazioni, 0 ciechi, col gruppo di controllo a 0 KO prima del giro. I vostri testi non li leggiamo e non li registriamo, nemmeno per misurare: le scene usano modelli finti e basi usa-e-getta, e dalla produzione sono usciti solo conteggi.

2026-10-09 — Tre indirizzi che non sono mai stati nostri — /docs, /openapi.json, /redoc — smettono di rispondere al posto del contratto: da adesso danno il nostro 404

Cosa cambia per voi: su api.agilev.agile.software tre indirizzi rispondevano 200 senza che noi li avessimo mai dichiarati in nessuna delle cinque pagine in cui vi diamo il contratto — /docs (930 byte), /redoc (890) e /openapi.json (32.224). Non li scriviamo noi: li costruisce da sé il framework su cui gira il motore. Da adesso ricevono la stessa risposta di un indirizzo inventato: il nostro 404 con {"error":{"code":"NOT_FOUND",…}}, 263 byte, application/json.

Chi li ha letti, misurato e non stimato: il giornale del bordo, letto in sola lettura, dall'11/9 al 9/10 porta 16 richieste a quei tre indirizzi, da 4 chiamanti distinti. Tre sono clienti con la chiave e in tutto il periodo ne hanno chiesto uno o due, una volta ciascuno (su 24.578, 904 e 305 richieste a testa: nessuno ci costruisce sopra niente); il quarto non aveva chiave e il 2/10 ne ha chiesti 6 in nove secondi, insieme ad altri 85 percorsi inventati. Se però generavate il vostro client a macchina da /openapi.json, scriveteci: vi diamo uno schema nostro, mantenuto e versionato col contratto. Oggi non ce l'abbiamo, e in 28 giorni nessuno l'ha chiesto — per questo non lo pubblichiamo «per sicurezza» prima che serva a qualcuno.

Perché lo chiudiamo, due ragioni e nessuna è «per fare ordine». La prima: quelle pagine sono una seconda fonte del contratto che non manteniamo. Sono generate a macchina dal codice, non dicono le cose che contano per voi — i limiti, i codici d'errore, cosa consuma quota — e il giorno che divergono dalla documentazione dell'API, due fonti che si contraddicono sono peggio di una. La seconda riguarda il nostro mestiere: /docs e /redoc mandavano il browser di chi ci sta valutando a tre host di terzi (cdn.jsdelivr.net, fastapi.tiangolo.com, fonts.googleapis.com) a prendere il loro JavaScript e i loro caratteri. Su un servizio che vi vendiamo come sovrano — modelli aperti, dati in UE, i vostri testi che non escono mai — la prima pagina che un cliente apre non deve chiedere niente a un fornitore fuori. Le nostre pagine non nominano nessun host che non sia nostro, e ora vale per tutto il bordo.

Cosa NON cambia: nessuna rotta nostra, nessun campo, nessun limite, nessuna chiave, nessun prezzo; il contratto resta 1.2. Nessuna riga del motore cambia e il servizio non si riavvia: è una riga del nostro file del bordo e un caddy reload. Il contratto che vi serve sta dove è sempre stato: la documentazione dell'API, la tabella degli errori e gli esempi.

Quando: in linea dal 10/10 alle 06:18Z, col rilascio del bordo, del sito e degli esempi. Verificato da fuori, non dedotto dal nostro repo: un GET senza chiave sul bordo dell'API riceve su /docs, /openapi.json e /redoc il nostro 404 con NOT_FOUND, 263 byte ciascuno — lo stesso di un indirizzo inventato — al posto del 200 con le pagine del framework.

Le misure: bench/misura-la-vetrina-del-bordo.py — sette scene in una gabbia sul solo 127.0.0.1, con un motore finto che dichiara le stesse rotte del nostro e un bordo copiato alla lettera dal file vero: da 3 ok / 4 KO a 7 ok / 0 KO. Non sa a memoria quali sono le tre pagine del framework: chiede al framework stesso quali indirizzi si costruisce, così il giorno che ne aggiunge una quarta la scena la vede. Il gruppo di controllo è nella stessa gabbia: /embed arriva al motore con 200, e un indirizzo inventato riceve lo stesso 404 di 263 byte — cioè la cura non ha chiuso niente che vi serva. Una settima scena misura il costo di questa voce: nessuno dei cinque documenti con cui vi diamo il contratto nomina i tre indirizzi, quindi togliendoli non smentiamo niente di quello che vi abbiamo promesso.

2026-10-09 — Il turno del /v1/rerank adesso si passa sulle richieste che mandate davvero, non solo su quelle da 33 documenti in su

Cosa cambia per voi: niente nel contratto — resta 1.2, nessuna rotta, nessun campo, nessun limite nuovo — e una cosa nei tempi di chi sta in fila. Il 9/10 alle 08:49:03Z abbiamo messo in esercizio il turno del modello, e in questa pagina vi abbiamo scritto anche fin dove arrivava: «il turno si passa fra un passaggio e l'altro, e un /v1/rerank fino a 16 documenti è un passaggio solo». Quella riga era la misura di un difetto, non una sfumatura: chi usa il rerank manda in media 14,68 documenti per richiesta, cioè sotto quella soglia, e su quelle richieste il turno non si passava mai e chi arrivava dietro aspettava la richiesta intera.

Cosa abbiamo fatto: il rerank adesso ha un tetto suo, 4 coppie per passaggio. È il più grande che spezza la richiesta media in almeno tre passaggi, e non scendiamo a una perché ogni chiamata al modello ha un costo fisso che pagherebbe anche chi è solo. Non c'è niente da adottare: la rotta, la richiesta e la risposta sono quelle di ieri.

Quando: in linea dal 9/10 alle 14:35Z, col ritorno del motore (14:35:47Z esatte).

Le misure, e sono quelle della richiesta che esiste: la soglia oltre la quale il turno comincia a passare scende dal documento 33 al documento 5; un /v1/embeddings che arriva dietro a un rerank aspettava 15,45 documenti di quel rerank (618 ms) e adesso ne aspetta 5,47 (219 ms); e se chiudete la connessione a metà, il lavoro già cominciato che buttiamo via scende da 14 coppie su 14,68 a 4. I numeri «da 8,2 a 2,1 passaggi» che avevate letto il 9/10 erano quelli di una richiesta da 128 documenti, il massimo che accettiamo: nel traffico di ottobre quella richiesta non è mai arrivata.

Come lo sappiamo, e lo rifacciamo a ogni cura: un banco misura il turno sul motore nuovo e sullo stesso motore di prima (bf93814), perché «18 controlli ok» da solo non direbbe che il banco è un cancello: sul motore di prima gli stessi controlli danno 11 ok e 7 KO, e sono i sette che la cura ha chiuso. La media di 14,68 documenti viene dai nostri contatori (910 documenti in 62 richieste di ottobre): i vostri testi non li leggiamo e non li registriamo, nemmeno per misurare.

2026-10-09 — I quattro 403 che parlano del contratto, non della vostra chiave, adesso sono scritti nella tabella degli errori in italiano e in inglese, col rimedio vero: una chiave nuova non li cura

Cosa cambia per voi: niente nel contratto, e una cosa in quello che potete leggere. Da quando esiste il livello dei contratti, una chiave che sta sotto un contratto fermo riceve su ogni rotta di calcolo uno di quattro 403: LICENSE_SUSPENDED, LICENSE_EXPIRED, LICENSE_REVOKED, LICENSE_NOT_FOUND. Nessuno dei quattro era scritto in nessuna pagina del bordo: misurato il 9/10 alle 13:50Z con un GET senza chiave, le quattro pagine degli errori — api.html, en/api.html e le due errori-dal-client.html — ne nominavano zero. Stavano in un nostro documento che non pubblichiamo: scriverli lì non è pubblicarli. E la pagina dell'API apre con una promessa nostra — «il code è stabile: ramificate su quello» — e poi vi dà la tabella su cui ramificare: i quattro che potete ricevere non c'erano.

Il secondo pezzo riguarda il rimedio, e l'abbiamo provato eseguendolo: l'unica riga che oggi copre un 403 di questa famiglia è KEY_REVOKED, e dice «chiedetene una nuova». Sotto un contratto fermo quel consiglio non funziona, e non per una sfumatura: chiedendo di ruotare la chiave si riceve 409 LICENSE_NOT_ACTIVE, e chiedendo una chiave nuova sotto lo stesso contratto si riceve lo stesso 409. Cioè il cliente che legge la nostra pagina ci chiede una chiave nuova e noi non possiamo nemmeno emetterla. Il rimedio vero è l'opposto e riguarda il contratto, non la chiave — ed è esattamente il motivo per cui quel livello esiste: si riattiva il contratto, e tutte le sue chiavi riprendono nello stesso istante senza che voi cambiate niente.

Cosa trovate adesso: quattro righe nella tabella degli errori, dopo SCOPE_NOT_ALLOWED, in italiano e in inglese, e ognuna dice le tre cose che servono in quel momento — che il codice parla del contratto e non della chiave, che tutte le chiavi dello stesso contratto lo ricevono nello stesso istante, e che una chiave nuova sotto quel contratto non funziona. Per LICENSE_NOT_FOUND il rimedio è un altro e lo diciamo: è un guasto nostro, la vostra chiave punta a un contratto che non c'è più, e non c'è niente che possiate cambiare da parte vostra. Nelle due pagine degli errori dal client c'è un rimando alla tabella e non una copia: la tabella è una sola, così non può restare indietro.

Per chi ha una chiave oggi non cambia niente, e il numero è questo: nel database in esercizio, letto in sola lettura il 9/10 alle 13:55Z, ci sono 17 chiavi (11 attive, 6 revocate), zero contratti, zero chiavi legate a un contratto e zero richieste di contratto. Nessuna delle chiavi di oggi passa da nessuno dei quattro rifiuti: era una porta aperta adesso che nessuno aveva ancora attraversato. Il giorno in cui parte il primo contratto, il primo cliente che prende un 403 LICENSE_SUSPENDED cerca quel codice nella pagina che gli abbiamo dato — e adesso lo trova.

Cosa NON cambia: nessuna rotta, nessun codice nuovo (questi quattro il motore li manda già: nel app.py in esercizio sono 18 occorrenze, e quel file è byte per byte quello del nostro main — git hash-object dà 6e4e03f — su dal 9/10 alle 08:49:03Z con zero riavvii), nessun limite, nessun prezzo, nessuna chiave, nessun campo nuovo nelle risposte; il contratto resta 1.2. Il servizio non si riavvia per questa voce: sono pagine che pubblichiamo noi.

Quando: in linea dal 9/10 alle 14:35Z (14:35:46Z esatte, col rilascio del sito: una copia di file, nessun riavvio per queste righe). I quattro codici il motore li mandava già da prima; da quell'istante la tabella degli errori li nomina, in italiano e in inglese. La prova la rifate voi in un comando: un GET senza chiave su api.html e su en/api.html — non consuma né quota né richieste al minuto — ne trova 4 su 4 in ciascuna, misurato alle 14:58Z. Niente da adottare: la pagina che stavate leggendo è già quella nuova.

Le misure di questa voce: bench/misura-i-codici-del-contratto-che-non-pubblichiamo.py — nove scene, da 4 ok / 5 KO a 9 ok / 0 KO. Le scene a, b, c, d costruiscono il contratto con le rotte vere dell'amministrazione, gli mettono lo stato, chiamano /v1/embeddings e leggono il codice dalla risposta, non da un elenco scritto dentro il banco; r esegue il consiglio che pubblichiamo e guarda cosa torna; k e g sono i due gruppi di controllo (due codici che erano già pubblicati, e la chiave senza contratto che deve continuare a ricevere 200: una cura sulle pagine non può spostare di un carattere ciò che riceve un cliente di oggi). bench/mutazioni-i-codici-del-contratto.py — 8 guasti su 8 visti, 0 ciechi, 1 equivalente confermata (le stesse righe spostate nell'altra pagina del bordo: il banco pretende il bordo in due lingue, non il nome di un file), col «senza cura» preso dalla punta pre-cura e non dedotto. tests/test_codici_del_contratto.py — 3 prove che inchiodano ciò che deve restare vero prima e dopo, fra cui che i codici di contratto che un cliente può ricevere siano quattro: se arriva un quinto la prova diventa rossa, e chi lo aggiunge si accorge che c'è una tabella pubblica da allargare. La produzione non è stata toccata: GET senza chiave al bordo, letture in sola lettura del app.py dispiegato e del database in mode=ro, nessuna chiave di cliente usata per provare.

2026-10-09 — Nove cose che vi avevamo annunciato sono in linea, e ve lo diciamo con l'istante e con la prova; le risposte senza error.code che restano aperte sono una, non quattro

Cosa cambia per voi: niente nel contratto, e molto in quello che queste pagine vi dicevano. Il rilascio del 9/10 alle 07:31:16Z ha messo al bordo pubblico il nostro file del bordo, il sito e gli esempi; il motore in esercizio è quello dell'8/10 alle 16:42:27Z. Fino a stamattina nove voci di questa pagina dicevano «scritto e provato, non ancora in linea» e vi spiegavano cosa vi arrivava «fino a quel momento»: quel momento era passato, e la frase era diventata falsa nel verso opposto. Adesso ogni voce porta l'istante in cui è andata in linea e la prova misurata da fuori, non dedotta dal nostro repo.

Il numero che vi serviva davvero era un altro: la pagina dell'API e quella degli errori vi dicevano che le risposte senza error.code ancora aperte erano quattro — il 422, il 405, il 413 del corpo troppo grande e i tre 50x del motore che non risponde — e aggiungevano «tre su quattro sono curate nel nostro file del bordo e arrivano col prossimo reload». Quel reload è avvenuto. Ne resta aperta una sola: il 422. È il numero da cui decidete quanto grande fare il ramo «non so» del vostro lettore d'errore (regola 5), e un quattro al posto di un uno vi fa scrivere e mantenere codice per tre casi che non esistono più.

Le prove, una per voce, e si vede da dove vengono: un GET senza chiave sul bordo pubblico per quello che un GET può provocare — /esempi/agilev_client.mjs risponde 200 con 33.818 byte dove prima dava 404; GET /v1/embeddings risponde 405 con {"error":{"code":"METHOD_NOT_ALLOWED",…}}; un percorso che non esiste sul sito dà 404 con 2.283 byte di HTML e non più una pagina bianca. Per le due forme che per provocarle bisognerebbe rompere la produzione — il 413, che chiede un corpo da 8 MB, e i tre 50x, che chiedono il motore giù — il verdetto viene dal Caddyfile in esercizio letto in sola lettura, e lo diciamo invece di far finta di averle provate: lì ci sono i blocchi che scrivono BODY_TOO_LARGE e SERVICE_UNAVAILABLE. Per la guardia sui testi in chiaro abbiamo scaricato il client pubblicato ed eseguito il suo costruttore in gabbia: rifiuta http://api.agilev.agile.software, accetta http://127.0.0.1:8798 e https:// — tre strade, tre esiti diversi, quindi la guardia c'è e non è cieca. Per le voci del motore la prova è l'impronta: il app.py in esercizio è byte per byte quello del repo.

E quella cosa che vi avevamo annunciato come «non ancora in linea» adesso c’è: in linea dal 9/10 alle 08:49:03Z, col rilascio del motore. Il turno del modello si passa fra un passaggio e l'altro: prima una richiesta teneva il modello per tutta la sua durata, quindi chi arrivava dietro aspettava la richiesta intera e non un passaggio. La prova dell'istante, da fuori e non dedotta dal nostro repo: il app.py in esercizio è byte per byte quello del nostro main (git hash-object dà 6e4e03f) e il motore è su da quell’istante. Quanto vi costava, misurato al bordo sul traffico vero di ottobre e non in gabbia: 4 /v1/embeddings avevano la finestra sovrapposta a quella di un /v1/rerank lento, e 3 hanno pagato 3,536 s con un corpo di 85 byte, 10,878 s con un corpo di 69 byte, 17,279 s con un corpo di 72 byte — un corpo così piccolo non sta lavorando, sta aspettando; gli altri 21.981 embed di ottobre hanno mediana 0,010 s. Quanto vi costa adesso, e lo diciamo invece di arrotondare: al bordo non è ancora misurabile, perché dal rilascio nessuno ha ancora chiamato /v1/rerank né /v1/embeddings — un p95 su zero campioni sarebbe inventato, e lo rimisuriamo appena il traffico arriva. In gabbia, col modello finto rallentato, la richiesta piccola dietro alla grossa passa da 8,2 a 2,1 passaggi — 3,8 volte più veloce — e la grossa paga il 13% in più, ed è la fila. Nessun lavoratore in più, nessun byte di RAM in più, nessun campo del contratto cambiato.

Fin dove arriva, e lo scriviamo invece di lasciarvi dedurre dal numero migliore: il turno si passa fra un passaggio e l'altro, e un /v1/rerank fino a 16 documenti è un passaggio solo — ogni coppia domanda-documento è tagliata a 1.024 token, quindi i passaggi dipendono dal numero dei documenti e non dalla loro lunghezza. Su una richiesta di quella taglia chi arriva dietro aspetta ancora la richiesta intera, e i numeri «da 8,2 a 2,1 passaggi» qui sopra sono quelli di una richiesta da 128 documenti, il massimo che accettiamo. Sul traffico vero di ottobre, misurato al bordo il 9/10: i corpi dei /v1/rerank serviti stanno fra 59 byte e 14.056, e chi usa quella rotta manda in media 14,68 documenti per richiesta (910 documenti in 62 richieste) — cioè proprio la taglia su cui il passaggio coincide con la richiesta.

Cosa NON cambia: nessuna rotta, nessun codice di errore nuovo per voi, nessun limite, nessun prezzo, nessuna chiave, nessun campo nuovo nelle risposte; il contratto resta 1.2. Quello che cambia sono i tempi di chi manda richieste piccole mentre qualcun altro ne manda grosse, e le pagine, che adesso dicono quello che il bordo fa.

Come si adotta: niente da fare. Se avevate rimandato l'adozione di una di quelle nove voci perché «non ancora in linea», adesso trovate l'istante accanto a ognuna.

Le misure di questa voce: bench/misura-le-forme-aperte-al-bordo.py — sei scene, da 10 ok / 5 KO a 15 ok / 0 KO, col 404 dell'API come gruppo di controllo (verde prima e dopo) e il conto cercato nella frase che lo dice e non in una riga fisica, perché queste pagine raccontano anche quante erano prima; bench/misura-il-turno-del-modello.py — cinque scene, 16 ok / 0 KO contro 13 ok / 3 KO sulla punta pre-cura, con la scena che pretende che il modello resti di uno solo (presenze contemporanee ≤ 1), così togliere il lucchetto non è una cura che passa; tests/test_turno_del_modello.py — 12 prove sull'ordine in cui i passaggi entrano nel modello, 9 rosse sulla punta pre-cura; bench/mutazioni-il-turno-del-modello.py — 11 guasti su 11 visti, 0 ciechi, due strumenti indipendenti e il giro sulle copie sane verde prima. La produzione non è stata toccata: GET senza chiave al bordo, letture in sola lettura del Caddyfile in esercizio e del app.py dispiegato. Dopo il rilascio, la misura dal bordo di questa voce la rifà bench/misura-il-p95-dopo-la-fila.py: il p95 di /v1/embeddings e /v1/rerank prima e dopo l'istante, dal giornale del bordo in sola lettura, col taglio che rifà identici i numeri già pubblicati come gruppo di controllo del lettore — e una cella senza campioni che dice NON MISURABILE invece di portare un numero.

2026-10-09 — Chi ci chiama in Node ha un client di riferimento: esempi/agilev_client.mjs, un file solo, zero dipendenze, con i lotti, il freno e Retry-After già dentro

Cosa cambia: pubblicavamo tre client di riferimento — Python, PHP e l'adattatore LangChain — e nessuno dei tre è in JavaScript. Di Node, in tutto quello che pubblicavamo, c'era una riga: new https.Agent({ keepAlive: true }). Da adesso c'è esempi/agilev_client.mjs: un modulo ES per Node ≥ 18, un file solo da ricopiare, zero dipendenze (fetch globale e AbortSignal.timeout sono nella libreria standard; niente node_modules, niente package.json), con la stessa classe AgileV e gli stessi nomi dei gemelli — embed(), rerank(), usage(), health(), controllaDim(1024), spezza().

Perché lo scriviamo adesso, e il numero non è nostro orgoglio ma il vostro conto: nel giornale del bordo, letto in sola lettura, il 99,4% delle chiamate a pagamento di ottobre (5.936 su 5.970) arriva con User-Agent: node, e nessuno dei client che pubblicavamo può mandare quell'intestazione. I 243 rifiuti 429 del mese sono tutti di lì, il 5/10/2026 fra le 21:41 e le 21:43 UTC: in un solo minuto 300 richieste servite — il tetto di quella chiave — e 240 rifiutate, mentre ogni rifiuto portava Retry-After: 33. Il servizio aveva avvisato prima: X-RateLimit-Remaining era andato 11, 10, 9 … 1, 0 in 238 millesimi. Un client che legge Remaining: 0 e aspetta non avrebbe preso nessun rifiuto; uno che rispetta Retry-After ne avrebbe preso uno. Quel codice non lo abbiamo scritto noi, ma non gli avevamo dato niente da ricopiare: la mancanza è nostra.

C'è anche il motivo per cui ci si arriva: quelle richieste portano in media 1,83 testi l'una, contro i 64 per lotto che consigliamo. Gli stessi testi a lotti da 64 sono 159 richieste invece di 5.558 — il 97% in meno — e il tetto di 300 al minuto non lo si vede nemmeno. Il client nuovo i lotti li fa da sé, uno alla volta e mai in parallelo.

Le regole che porta dentro, le stesse della pagina dell'API: connessione riusata (col fetch globale il keep-alive c'è già, e la regola è non spegnerlo); lotti da 64 (massimo 128) uno alla volta; i limiti controllati prima di spedire; ritentativo solo su 429 RATE_LIMIT, 5xx e connessione caduta, con attesa 1-2-4-8 s oppure esattamente Retry-After quando c'è; al timeout il lotto si dimezza invece di essere ripetuto uguale; il freno su X-RateLimit-Remaining: 0; nessun ritentativo su 400/401/403/413 e su QUOTA_EXCEEDED; il rimando non seguito (un 307 su una POST rispedirebbe i vostri testi); il codice dedotto dallo stato quando la risposta non ha la nostra forma; e il 422 che vi dice quale campo è sbagliato senza rimandarvi indietro l'eco dei vostri testi. spezza() produce gli stessi frammenti del gemello Python, confrontati uno per uno.

Nello stesso lavoro abbiamo curato l'adattatore LangChain, che dichiarava «contratto 1.2» e non applicava quelle regole nemmeno in Python: _Client alzava l'errore su qualunque risposta diversa da 200 — nessun ritentativo, nessun Retry-After, nessun freno — e dentro un ContextualCompressionRetriever un 429 solo faceva cadere la catena. Adesso frena e ritenta come gli altri, su entrambi i rami del trasporto (requests se c'è, urllib se non c'è). AgileVEmbeddings, AgileVSparseEmbeddings, AgileVRerank e AgileVSpezza non cambiano firma e il percorso felice non cambia di un byte.

Cosa NON cambia: nessuna rotta, nessun limite, nessun prezzo, nessuna chiave, nessun campo nuovo nelle risposte, contratto 1.2, e il servizio non si riavvia per questa voce. Non abbiamo alzato il rate_per_min di nessuna chiave: il tetto ha fatto il suo lavoro, e a lotti da 64 quel traffico non lo incontra.

Come si adotta: ricopiate il file nel vostro progetto e sostituite le vostre chiamate dirette a fetch. import { AgileV, spezza } from "./agilev_client.mjs", poi const av = new AgileV() con la chiave in AGILEV_KEY e await av.embed(testi). Se preferite tenerlo dove lo trovate, scaricatelo: non prendete impegni sul fatto che l'indirizzo resti identico per sempre, perché un file che pubblichiamo noi può cambiare posto.

Quando: in linea dal 9/10 alle 07:31Z, col rilascio del bordo, del sito e degli esempi. Verificato da fuori, non dedotto dal nostro repo: un GET senza chiave su /esempi/agilev_client.mjs risponde 200 con 33.818 byte, dove prima rispondeva 404.

Le misure di questa voce: bench/misura-il-client-che-non-abbiamo.py — sei scene per quattro lingue in gabbia su 127.0.0.1, e il giudizio è su quello che il chiamante vede e su quello che il server ha ricevuto, mai su una lettura del sorgente: da 14 ok / 10 KO a 24 ok / 0 KO (Node da 0 su 6 a 6 su 6, LangChain da 2 su 6 a 6 su 6), col ritorno dopo un rifiuto a 2,01 s contro i 2 s chiesti; 18 prove nuove contro un server finto in-process, fra cui il lotto che si dimezza al timeout, l'eco detail[].input che non esce nel messaggio e otto richieste in fila che aprono due connessioni e non otto; e il freno dell'adattatore LangChain provato sui due rami del trasporto. La produzione non è stata toccata: solo letture, nessuna chiave di cliente usata per provare.

2026-10-09 — Il metodo sbagliato su una rotta che esiste non è più una risposta senza nome: 405 con METHOD_NOT_ALLOWED, e il messaggio vi dice quale metodo vuole quella rotta

Cosa cambia: se chiamate una rotta che esiste con il metodo che non è il suo — GET /v1/embeddings, GET /v1/richieste-licenza — ricevevate 405 con 31 byte di {"detail":"Method Not Allowed"}: nessun code su cui ramificare e detail come stringa. Da adesso quella risposta ha la nostra forma — {"error":{"code":"METHOD_NOT_ALLOWED","message":"…"}} — e il messaggio dice le tre cose che servono in quel momento: che la rotta esiste (non è un indirizzo da cercare), con quale metodo si chiama, e che la richiesta si è fermata prima dell'autenticazione, quindi non ha consumato né quota né richieste al minuto e si può ripetere identica col metodo giusto.

Il metodo giusto non lo scriviamo a mano, lo legge il bordo: il framework manda già, insieme al 405, l'intestazione standard Allow con il metodo di quella rotta, e il bordo la rilegge e la mette nel messaggio. Su /v1/embeddings dice POST, su /v1/models dice GET, e una rotta nuova non ha bisogno che nessuno aggiorni un elenco. L'intestazione Allow resta nella risposta: scrivendo questa cura abbiamo misurato che riscrivere il corpo la faceva sparire, e un nome nel corpo non si paga con un'intestazione standard in meno.

Avevamo scritto che questa restava, e la ragione era giusta a metà. La pagina dell'API diceva: «il 405 viene dal framework, e su una rotta che esiste col metodo sbagliato "rotta inesistente" sarebbe un consiglio falso, quindi quello resta». La seconda metà è vera e la rispettiamo ancora — il 405 non riusa il corpo del 404, e non vi manda a cercare un indirizzo che avevate giusto — ma escludeva una cura, non la cura.

Non era ipotetico, ed era uno. Sul giornale del bordo, letto in sola lettura dall'11/9 all'8/10, i 405 sono 16: 1 a un cliente vero (il 13/9/2026 alle 01:26:32Z, GET /v1/embeddings, dalla stessa sorgente che negli stessi giorni ci ha fatto 885 POST /v1/embeddings con 200: aveva l'indirizzo giusto e stava cercando la forma della chiamata, e quattro secondi prima aveva provato POST /embeddings), 4 a una revisione nostra che arriva da fuori, 11 a sonde partite dal box. Uno in ventotto giorni è poco, e non è il motivo per cui si cura: il motivo è quando capita, cioè nella prima mezz'ora di un'integrazione, che è l'unico momento in cui un cliente nuovo decide se il servizio è comodo.

Cosa NON cambia: nessuna rotta, nessun limite, nessun prezzo, nessuna chiave, nessun campo nuovo nelle risposte; lo stato resta 405, l'intestazione Allow resta quella di prima, la versione del contratto resta 1.2, e il servizio non si riavvia per questa voce — è tutta al bordo, basta un caddy reload. Se il vostro client deduce il codice dallo stato HTTP, come dice la regola 5, per voi non cambia una riga: prima quel 405 lo chiamavate BAD_METHOD da soli, ed era il comportamento giusto; adesso trovate anche un codice nel corpo, e un messaggio che potete mostrare perché è nostro.

Come si adotta: niente da fare. METHOD_NOT_ALLOWED è l'unico codice nuovo e vuol dire non ritentare uguale: cambiate il metodo, quello giusto è scritto nel messaggio e nell'Allow.

Quando: in linea dal 9/10 alle 07:31Z, col rilascio del bordo, del sito e degli esempi. Verificato da fuori, non dedotto dal nostro repo: un GET senza chiave su /v1/embeddings risponde 405 col corpo {"error":{"code":"METHOD_NOT_ALLOWED",…}} e il metodo giusto dentro il messaggio.

Il conto delle forme senza code: restano quattro — il 422, questo 405, il 413 del corpo troppo grande e i tre 50x del motore che non risponde — ma tre su quattro sono già scritte nel nostro file del bordo e arrivano con lo stesso caddy reload. L'unica che non chiudiamo noi è il 422: dargli la nostra forma è un cambio di contratto e si valuta a parte.

Le misure di questa voce: bench/misura-il-405-del-metodo.py — 12 scene in gabbia, da 6 ok / 6 KO a 12 ok / 0 KO, con tre gruppi di controllo; bench/mutazioni-il-405-del-metodo.py — 9 guasti su 9 visti, più 3 dichiarati e non contati; tests/test_il_405_del_metodo.py — 7 prove, fra cui che il motore mandi Allow su tutte le rotte pubbliche e che cinque 405 in mezzo non vi mangino nessuna delle richieste al minuto. La produzione non è stata toccata.

2026-10-08 — Mentre il motore si riavvia non ricevete più zero byte: il bordo vi dà 502/503/504 con SERVICE_UNAVAILABLE nella nostra forma, e lo stato resta quello vero

Cosa cambia: quando il motore dietro il bordo non risponde — si sta riavviando, oppure ha troppo lavoro insieme — il bordo vi fermava con un 502 (o un 503, o un 504) e zero byte di corpo, senza Content-Type. È la risposta che si prende chiunque mandi una richiesta mentre il motore si riavvia, cioè a ogni nostro rilascio: il motore riparte e la sua salute si aspetta fino a 180 secondi. Da adesso quella risposta ha la nostra forma — {"error":{"code":"SERVICE_UNAVAILABLE","message":"…"}} — e il messaggio dice le tre cose che vi servono in quel momento: la richiesta non è stata eseguita, non ha consumato né quota né richieste al minuto, e si può ripetere identica. Lo scrive il bordo, non l'applicazione: la vostra richiesta non è mai arrivata al motore, quindi non la conta nessuno.

Lo stato non viene schiacciato in uno: 502, 503 e 504 restano quelli che arrivano. Non è un dettaglio di eleganza: un 503 può venire anche dall'applicazione (MODEL_UNAVAILABLE, modello non presente su questa istanza), e il code è l'unica cosa che vi dice quale dei due vi ha risposto — se il motore non c'era, o se c'era e non ha quel modello.

Non era ipotetico, e stava già scritto nelle nostre pagine: il 15/9/2026 alle 12:43:55Z un cliente ha ricevuto un 502 del bordo due secondi dopo un nostro riavvio. Non ce l'ha segnalato: l'abbiamo letto noi nel giornale del bordo, e dal suo lato era un errore senza nome. Quella riga è rimasta per 23 giorni nella pagina degli errori come esempio di cosa non vi diciamo, e la risposta è rimasta muta. Adesso ha un nome.

Vale per tutti e due i nomi pubblici, e il secondo non è simmetria: https://agilev.agile.software/health è la rotta che questa pagina stessa vi indica per un controllo automatico («JSON, senza chiave»). Era quella che, nell'istante in cui il controllo conta — il servizio giù — vi rispondeva zero byte e vi faceva inciampare il json(). Da adesso anche il suo errore si legge.

Niente Retry-After su questa risposta, e il perché: il bordo non sa quanto durerà il fermo — un riavvio sono secondi, un rilascio che torna indietro minuti — e un numero inventato vi farebbe ritentare tutti insieme nell'istante sbagliato. Il 429 lo porta perché lì il numero lo sappiamo: il minuto nuovo, o il mese nuovo. Qui no, e il messaggio dice «fra qualche secondo».

E un'attesa che non cambia, ma è giusto che la sappiate: prima di rispondere da sé il bordo tiene la vostra richiesta fino a 30 secondi (lb_try_duration), ritentando il motore. Se il motore torna su in quel tempo, la richiesta passa e non vedete niente; se non torna, quei 30 secondi li avete aspettati. Era così anche prima: la differenza è che dopo l'attesa, adesso, c'è una risposta che si legge.

Cosa NON cambia: nessuna rotta, nessun limite, nessun prezzo, nessuna chiave, nessun campo nuovo nelle risposte; la versione del contratto resta 1.2, e il servizio non si riavvia per questa voce — è tutta al bordo, basta un caddy reload. Se il vostro client deduce il codice dallo stato HTTP, come dice la regola 5 della pagina degli errori, per voi non cambia nulla: prima quel 5xx lo chiamavate UPSTREAM_ERROR da soli, ed era il comportamento giusto; adesso trovate anche un codice nel corpo.

Come si adotta: niente da fare. Se ramificate su error.code, SERVICE_UNAVAILABLE è l'unico codice nuovo e vuol dire ritentare: la richiesta non è stata eseguita e non l'avete pagata.

Quando: in linea dal 9/10 alle 07:31Z, col rilascio del bordo, del sito e degli esempi. Verificato da fuori, non dedotto dal nostro repo: il Caddyfile in esercizio, letto in sola lettura, porta il blocco handle_errors 502 503 504 che scrive SERVICE_UNAVAILABLE; non lo provochiamo dal vivo, perché vorrebbe dire fermare il motore ai clienti.

Una correzione al conto che vi avevamo dato: la pagina degli errori e quella dell'API dicevano che le risposte senza error.code rimaste aperte erano tre (il 422, il 405 e il 413 del corpo troppo grande). Erano quattro: questa era la quarta, e non l'avevamo contata. Le pagine ora dicono quattro.

Le misure di questa voce: bench/misura-il-502-del-bordo.py — 9 scene in gabbia (porte alte sul solo 127.0.0.1, un motore finto che si spegne e si riaccende, un Caddy suo coi due blocchi veri copiati dal nostro file), da 6 ok / 3 KO a 9 ok / 0 KO, con due gruppi di controllo (col motore acceso tutto risponde; la pagina 404 del sito non si rompe); bench/mutazioni-il-502-del-bordo.py — 10 guasti su 10 visti, ognuno col rosso che deve far comparire e col giro sulle copie sane prima, più tre dichiarati e non contati perché non cambiano un byte di quello che ricevete; tests/test_il_502_del_bordo.py — 13 prove su quello che la gabbia non può vedere, fra cui che i due nomi dicano la stessa cosa e che i tre stati siano gli stessi che legge la nostra guardia del bordo. La produzione non è stata toccata: nessuna richiesta ai nomi pubblici, nessuna porta 80 o 443 aperta.

2026-10-08 — Un corpo troppo grande non vi risponde più 413 a vuoto: il bordo vi dà BODY_TOO_LARGE nella nostra forma; e i due limiti pubblicati non si raggiungono insieme con testo cinese, russo o greco

Cosa cambia: quando il corpo della vostra richiesta supera gli 8.000.000 byte, il nostro bordo vi fermava con un 413 e zero byte di corpo — nessun Content-Type, Connection: close. È lo stesso stato con cui il contratto vi promette {"error":{"code":"BATCH_TOO_LARGE",…}} e {"error":{"code":"TEXT_TOO_LONG",…}}, quindi un response.json() vi esplodeva e un lettore che ramifica su error.code restava muto. Da adesso quel 413 ha la nostra forma, con un codice nuovo: BODY_TOO_LARGE. Lo scrive il bordo, non l'applicazione: arriva prima dell'autenticazione e prima del motore, non consuma né quota né richieste al minuto, non porta intestazioni X-RateLimit-*, e il messaggio è fisso — non ripete niente del corpo che avete mandato, quindi nei vostri log non torna un frammento dei vostri testi.

Perché non era un caso di laboratorio, ed è la parte che ci riguarda: i due limiti che vi pubblichiamo — 128 testi per richiesta e 32.000 caratteri per testo — non ci stanno insieme sotto quel tetto se il testo è multibyte. In UTF-8 un carattere cinese, russo o greco pesa 2 o 3 byte: 128 testi da 32.000 caratteri CJK sono 24.576.545 byte, cioè 3,07 volte il tetto, e bastano 42 testi su 128 per superarlo. Chi indicizza in una di quelle lingue faceva esattamente quello che gli avevamo scritto e prendeva un 413 che non gli diceva niente — e il nostro 413 BATCH_TOO_LARGE, quello con la forma giusta, non lo vedeva mai, perché la richiesta all'applicazione non arrivava. L'abbiamo trovato noi il 5/10 misurando in gabbia, non ce l'ha segnalato nessuno.

E una terza cosa, piccola e velenosa: «8 MB» non sono 8 MiB. Misurato al byte: 8.000.000 passano, 8.000.001 no. Chi dimensionava i lotti a 8.388.608 byte sbatteva per 388.608 byte senza una riga che glielo dicesse. Adesso le pagine dei limiti dicono il tetto in byte, e il conto da fare prima di spedire è in byte (len(json.dumps(corpo).encode("utf-8"))), non in caratteri.

Cosa NON cambia: il tetto. Resta 8.000.000 byte: quanto deve pesare una richiesta è una decisione di carico e non la prendiamo dentro a questa voce. Nessuna rotta, nessun limite, nessun prezzo, nessuna chiave, nessun campo nuovo nelle risposte; la versione del contratto resta 1.2 e il servizio non si riavvia — questa è tutta al bordo, basta un caddy reload. Se il vostro client deduce il codice dallo stato HTTP, come dice la regola 5, per voi non cambia nulla e non c'è niente da ricopiare: prima quel 413 vi usciva come NO_CODE con lo stato 413, che era il comportamento giusto, e adesso trovate il codice già nel corpo.

Come si adotta: niente da fare. Se volete evitare il 413 invece di riceverlo, il conto è in byte e il nostro client di riferimento manda lotti da 64 — con cui non ci si arriva nemmeno in cinese.

Una correzione a quello che vi avevamo scritto: la pagina degli errori vi diceva che le risposte senza error.code «erano quattro, dal 5/10 sono due». Erano tre: questo 413 era la terza e non l'avevamo contata. Le pagine dicevano tre, e dall'8/10 dicono quattro: la voce qui sopra conta la quarta, il 502/503/504 del motore che si riavvia, trovata lo stesso giorno. Il conto scenderà quando le cure saranno davvero in linea, una per una.

Quando: in linea dal 9/10 alle 07:31Z, col rilascio del bordo, del sito e degli esempi. Verificato da fuori, non dedotto dal nostro repo: il Caddyfile in esercizio porta il blocco handle_errors 413 che scrive BODY_TOO_LARGE; non lo provochiamo dal vivo, perché vorrebbe dire spedire alla produzione un corpo da 8 MB.

2026-10-08 — I tre client che pubblichiamo rifiutano http:// verso un indirizzo pubblico, e l'adattatore LangChain non segue più il rimando: cambia il comportamento, e ve lo diciamo prima

Cosa cambia, ed è un cambio di comportamento: i tre file che questa pagina vi dice di copiare nel vostro progetto — agilev_client.py, agilev_client.php, agilev_langchain.py — accettavano AGILEV_URL=http://api.agilev.agile.software senza una parola. Da adesso un indirizzo http:// verso un nome o un indirizzo pubblico si ferma subito, alla costruzione del client, prima di aprire la connessione: ricevete un errore che nomina l'indirizzo e dice cosa fare. Se nel vostro AGILEV_URL c'è http:// e punta a noi, oggi funziona e domani no: è questa la riga da leggere.

Perché: il nome dell'API risponde anche sulla porta 80, e lì risponde 308 verso https. Un rimando arriva dopo: quando il nostro bordo lo scrive, la vostra chiave e i vostri testi hanno già attraversato Internet in chiaro. Che il nostro bordo quel corpo non lo legga non cambia niente, perché il danno è sul filo e non nei nostri log. E la pagina dell'API vi diceva, per il 307 della barra finale, che «seguire il rimando è sicuro»: vero di quel 307, che l'app scrive dentro la sessione cifrata, non vero del 308 della porta 80 — che nessun nostro documento nominava. Adesso lo nominano tutti.

Non è mai successo, e lo diciamo col numero: sul giornale del nostro bordo, dall'11/9 a oggi, le richieste arrivate in chiaro sul nome dell'API sono 309, da 45 chiamanti distinti, tutte finite col 308; 13 avevano un corpo, e zero di quelle 13 andava verso una nostra rotta. Sono scanner, non clienti. Nessun testo di nessun cliente è uscito in chiaro: l'esposizione era aperta, non consumata.

http verso casa resta ammesso, e non per comodità: se puntate AgileV su 127.0.0.1, su una vostra rete privata (10.…, 192.168.…) o su localhost, il client non dice niente e funziona come prima — quel filo non passa da Internet. La guardia è sull'indirizzo e non sullo schema, apposta: un «https e basta» romperebbe ogni fronte privato, compreso il nostro. Per un nome vostro dentro una VPN non risolviamo niente (una risoluzione DNS dentro un costruttore è una sorpresa): c'è l'uscita esplicita in_chiaro_ammesso=True in Python e $inChiaroAmmesso = true in PHP, da scrivere nel vostro codice.

E l'adattatore LangChain non segue più i rimandi (allow_redirects=False su GET e POST). Passava per requests, che i rimandi li segue da sé: su un 308 della porta 80 il corpo — cioè i vostri testi — veniva rispedito, e voi ricevevate 200. Non ne restava traccia nemmeno nel vostro programma. Gli altri due client non li hanno mai seguiti. Se il vostro codice si appoggiava a quel comportamento, ora su un 3xx ricevete AgileVErrore con codice REDIRECT e la cura nel messaggio.

Cosa NON cambia: niente del servizio. Nessuna rotta, nessun codice di errore, nessun limite, nessun prezzo, nessuna chiave, nessun campo nuovo nelle risposte; la versione del contratto resta 1.2 e il servizio non si riavvia. Il bordo guadagna le tre intestazioni di sicurezza che il nome delle pagine aveva già (Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy) — ma quelle parlano ai browser, e per un programma la protezione è la guardia qui sopra, non loro.

Come si adotta: controllate AGILEV_URL. Se comincia con https://, non c'è niente da fare. Se comincia con http:// e non punta a casa vostra, cambiatelo in https:// — ed è una cosa da fare anche se non ricopiate i nostri client, perché quella chiave e quei testi stanno passando in chiaro adesso. Se ricopiate i nostri client, prendete la copia nuova da /esempi/.

Quando: in linea dal 9/10 alle 07:31Z, col rilascio del bordo, del sito e degli esempi. Verificato da fuori, non dedotto dal nostro repo: il client PUBBLICATO, scaricato dal bordo ed eseguito in gabbia, rifiuta http://api.agilev.agile.software con un ValueError e ammette http://127.0.0.1:8798 e https://: le tre strade danno tre esiti diversi, quindi la guardia c'è e non è cieca.

2026-10-08 — Un indirizzo sbagliato sul sito non è più una pagina bianca: risponde una pagina nostra, col 404 e nella vostra lingua; e /esempi/ ha il suo indice

Cosa cambia: su questo sito un percorso che non esiste rispondeva con il 404 di scorta del nostro bordo — zero byte di corpo e nessun Content-Type, cioè una pagina bianca nel browser, senza niente che dicesse che il sito c'è e niente da cui tornare indietro. Adesso risponde una pagina nostra con i rimandi alle pagine che cercavate, in italiano o in inglese secondo dove stavate (/en/… resta in inglese), e con lo stato 404 e non 200: un vostro programma che guarda lo stato non crede di aver trovato quello che cercava.

E /esempi/ adesso ha un indice: quell'indirizzo ve lo abbiamo pubblicato noi il 2/10, in questa pagina, per ricopiare i client di esempio — ed era una cartella senza indice, cioè esattamente una di quelle pagine bianche. Ora elenca i sette file con una riga su cos'è ognuno: i due client di riferimento (Python, PHP), i tre esempi di ricerca ibrida (senza database, in PHP, su Qdrant), l'adattatore LangChain e lo script della prova dal posto. L'indirizzo non lo ritiriamo e non lo cambiamo: l'avete letto, resta quello.

Perché non ce ne eravamo accorti, e lo diciamo: delle due parti del nostro bordo, quella dell'API teneva il suo giornale e quella del sito no — in Caddy si accende per sito. Su 48.805 righe di giornale del bordo, le richieste servite col nome del sito erano zero: ogni rimando rotto che un visitatore incontrava era invisibile per sempre, pagine bianche comprese. Adesso il blocco del sito ha il suo giornale, e l'indirizzo di chi visita viene cancellato dal giornale stesso: ci basta il percorso per sapere che una pagina nostra manda in un posto che non c'è.

Cosa NON cambia: niente dell'API. Nessuna rotta, nessun codice di errore, nessun limite, nessun prezzo, nessuna chiave; la versione del contratto resta 1.2 e il 404 dell'API — {"error":{"code":"NOT_FOUND",…}}, 263 byte — è quello del 29/9 e non è stato toccato. Il servizio non si riavvia: questa voce è una copia di pagine e una ricarica della configurazione del bordo.

Come si adotta: niente da fare. Se avete un controllo automatico che chiede una pagina del sito e si aspettava un corpo vuoto sul 404, adesso il corpo c'è (HTML, text/html); lo stato resta 404.

Quando: in linea dal 9/10 alle 07:31Z, col rilascio del bordo, del sito e degli esempi. Verificato da fuori, non dedotto dal nostro repo: un GET su un percorso che non esiste risponde 404 con 2.283 byte di HTML e Content-Type: text/html, e /esempi/ risponde 200 col suo indice.

2026-10-07 — La cache è separata per cliente, e ogni risposta vi dice quanti dei vostri testi non sono passati dal modello (usage.cached_texts, campo nuovo)

Cosa cambia, e perché ve lo diciamo noi: dal 15/9 un testo già visto di recente viene servito da una cache in RAM senza ricalcolo. Oggi, in produzione, quella cache serve il 98,7% dei testi che ci arrivano — 19.866 su 20.128 dall'ultimo riavvio, lo leggete anche voi nella pagina di stato — e nessun campo della risposta vi diceva se il vostro testo era fra quelli. Adesso usage.cached_texts dice quanti testi di quella richiesta li avevamo già, e GET /v1/usage lo dice per mese, accanto a texts. Serve a una cosa concreta: il nostro cliente più grande, a ottobre, ci ha mandato quarantacinque volte lo stesso testo e non aveva modo di accorgersene — una riga nel suo codice, non una nostra.

I testi serviti dalla cache sono fatturati come gli altri, e non lo cambiamo con questa voce: la quota e il prezzo si misurano sui caratteri che ci spedite, usage.chars non cambia, e la pagina di stato adesso lo dice nella riga che pubblica il riuso, dove prima c'era un numero e nessuna spiegazione. Se dopo aver letto cached_texts volete spedire meno, il risparmio è vostro e si fa nel vostro client: tenete voi i vettori dei testi che non cambiano.

LA CACHE È SEPARATA PER CLIENTE, e questa è la parte che riguarda la riservatezza. Fino a oggi l'impronta di un testo in cache era (modello, sha256(testo)): non conteneva chi aveva fatto calcolare quel vettore, quindi il vettore di un cliente veniva servito a un altro — e si vedeva dal tempo, perché un testo servito dalla cache torna in una frazione del tempo dell'inferenza (misurato in gabbia: 12 ms contro 315). Da fuori quel tempo è una risposta a una domanda che nessuno deve potervi fare: «questo testo esatto qualcun altro l'ha già spedito?». Adesso nella chiave della cache c'è il cliente: «già visto» vuol dire visto da voi, su tutte le strade che la scrivono — /v1/embeddings, /embed, return_sparse e l'accorpamento delle richieste che arrivano insieme. Le chiavi dello stesso cliente, comprese quelle ruotate, continuano a condividere la cache: il taglio è fra clienti, non fra chiavi.

Quanto costa a voi la separazione, misurato: in ottobre i colpi di cache fra clienti diversi erano al massimo 42 su 19.866 — lo 0,2% del riuso — perché il riuso di oggi è di ciascuno su se stesso. Nessuno di voi vedrà le risposte diventare più lente.

Come si adotta: niente da fare. Se validate lo schema delle risposte, aggiungete cached_texts fra i campi di usage (un intero, sempre presente) e fra quelli delle righe di /v1/usage. Nessuna firma è cambiata nelle richieste, la versione del contratto resta 1.2, e la risposta di /embed — contratto congelato — non cambia affatto: per quella rotta il numero sta in GET /v1/usage.

Quando: in linea dal 8/10 alle 16:42Z, col rilascio del motore. Verificato da fuori, non dedotto dal nostro repo: misurato il 9/10 alle 08:15Z, il app.py in esercizio portava lo sha256 del commit di questa voce, e /health diceva il motore su da quel rilascio.

2026-10-07 — Un top_n zero o negativo su /v1/rerank non taglia più dalla coda: ricevete tutti i documenti e la risposta lo dice in ignored_values (campo nuovo, compare solo se succede)

Cosa cambia: top_n si applica solo se è positivo. Un top_n negativo finiva in una fetta dalla coda della lista — su 4 documenti, top_n: -1 vi restituiva i 3 migliori meno il peggiore, e top_n: -4 ve ne restituiva zero, in entrambi i casi con 200 e con tutti e quattro i documenti addebitati. Un top_n: 0 non tagliava niente e ve li restituiva tutti, mentre la frase che vi avevamo pubblicato («results ha min(top_n, documents) elementi») diceva zero. Adesso zero e negativo vogliono dire la stessa cosa, nessun limite: ricevete tutti i documenti riordinati e la risposta porta "ignored_values": ["top_n"], presente solo quando un valore c'era e non si è potuto applicare. Una richiesta scritta bene non vede nessun campo nuovo. Un documento che manca da results non assomiglia a un errore: con top_n: -1 la lista arrivava ordinata, plausibile e incompleta, e chi la ricuciva ai propri testi per posizione indicizzava il testo sbagliato. I nostri due client pubblicati calcolavano l'atteso con un min nudo e accusavano noi («attesi -1 elementi in results»): adesso un top_n negativo lo fermano da soli, prima di spedire. In linea dall'8/10 alle 16:42Z, col rilascio del motore: misurato il 9/10 alle 08:15Z, il app.py in esercizio portava lo sha256 del commit di questa voce, quindi un top_n negativo non taglia più dalla coda.

2026-10-07 — Se il vostro contratto è fermo, anche GET /v1/usage risponde 403; e se un contratto ce l'avete, in contract trovate i suoi numeri (campo nuovo, null per tutte le chiavi di oggi)

Cosa cambia: GET /v1/usage era l'unica rotta del contratto pubblico che non passava dalla nostra porta d'ingresso — i suoi controlli erano scritti una seconda volta, nel corpo della rotta — e si erano fermati a settembre: guardavano la chiave (revocata, scaduta) e non il contratto sopra di lei. Risultato: una chiave sotto un contratto sospeso, scaduto, revocato o sparito riceveva 403 da ogni rotta di calcolo e 200 con i suoi consumi da questa. Adesso la rotta passa dalla stessa porta delle altre: sullo stesso contratto fermo risponde con lo stesso codice — LICENSE_SUSPENDED, LICENSE_EXPIRED, LICENSE_REVOKED — che ricevete dal calcolo nello stesso istante.

Il campo nuovo, e a cosa serve: sotto un contratto la quota mensile e il limite al minuto sono del contratto e le chiavi li consumano insieme, quindi i numeri della vostra chiave non bastano a prevedere il 429 che arriva. In contract ci sono i numeri del contratto accanto a quelli della chiave, con gli stessi nomi: {"license_id":"lic_…","quota_chars_month":5000000,"rate_per_min":600,"chars":3120044,"month":"2026-10"}. Fra i due tetti vale sempre il più stretto, e chars è la somma delle chiavi del contratto nel mese. Prima una chiave leggeva «10 su 90» mentre il contratto era già a «70 su 100», e il rifiuto arrivava senza che nulla, nella risposta, lo avesse annunciato.

Quello che NON cambia, ed è la parte che riguarda tutti voi: chi non ha un contratto — oggi siete tutti voi, nel nostro archivio non c'è nessun contratto attivo — legge esattamente la risposta di prima, campo per campo, con contract a null. E la rotta resta gratis: non consuma una delle vostre richieste al minuto, non porta le intestazioni X-RateLimit-* e funziona anche col minuto esaurito. Quella promessa era il rischio di questo cambio — la porta d'ingresso un posto nel minuto lo prende — e c'è una prova scritta apposta che la tiene: minuto esaurito con due richieste di calcolo, cinque letture dei consumi di fila, tutte 200.

Un caso che riguarda pochissimi, e lo diciamo: una chiave con ambito prova e senza data di fine (che dalla nostra riga di comando non nasce: è una riga scritta a mano nel nostro archivio) prendeva 403 KEY_EXPIRED sul calcolo e 200 qui. Adesso prende 403 anche qui. Era la stessa mezza chiave di prima, vista dall'altro lato.

A chi serve: a chi ha un pannello che legge questa rotta per sapere come sta il proprio conto — e lì cambia la lettura, perché un 403 su /v1/usage adesso vuol dire «chiamate il commerciale», non «chiamate il tecnico». Per NIS2, LG231, trpg, aiepod, core e adp oggi non cambia niente: nessuno di voi ha un contratto, e la vostra risposta è identica. Nessuna firma è cambiata, la versione del contratto resta 1.2.

Come si adotta: niente da fare. Se validate lo schema della risposta, aggiungete contract fra i campi attesi (null o un oggetto); se leggete i consumi da un controllo automatico, trattate un 403 LICENSE_* come «contratto fermo» e non come «chiave da rifare».

Quando: in linea dal 8/10 alle 16:42Z, col rilascio del motore. Verificato da fuori, non dedotto dal nostro repo: misurato il 9/10 alle 08:15Z, il app.py in esercizio portava lo sha256 del commit di questa voce.

Le misure di questa voce: bench/misura-i-consumi-che-ignorano-il-contratto.py — 4 ok / 6 KO prima, 10 ok / 0 KO dopo, su dieci scene costruite con le nostre rotte vere di amministrazione (fra cui due gruppi di controllo che devono restare verdi: la chiave senza contratto e la chiave sotto un contratto attivo); tests/test_consumi_del_contratto.py, 12 prove, di cui 8 rosse sul motore di prima e 3 dichiarate gruppo di controllo; bench/mutazioni-i-consumi-del-contratto.py — 7 guasti su 7 visti dal banco.

2026-10-06 — Un /v1/rerank che chiudete per timeout non vi viene più contato, e quanto costa in tempo la ricetta che vi consigliamo (nessuna firma cambia; una riga da mettere nel vostro client)

Cosa cambia: se chiudete la connessione mentre stiamo calcolando un rerank, quella richiesta non entra nei vostri consumi. Era già così quando il calcolo era fatto di più passaggi — fra uno e l'altro guardiamo se siete ancora collegati — ma un rerank fino a 16 documenti è un passaggio solo, e di passaggi successivi non ce n'è: lì non guardavamo niente e la richiesta veniva contata. Adesso, prima di scrivere il consumo, guardiamo una volta. Il controllo è uno e sta fuori dal ciclo, quindi non dipende più da quanti passaggi ha avuto il calcolo.

Perché lo diciamo con questo tono: perché la pagina dell'API vi prometteva già che una richiesta chiusa dal client «non viene calcolata né contata», e su /v1/rerank quella frase non era vera per i lotti piccoli. Non è un miglioramento che ci siamo inventati: è una promessa che non stavamo mantenendo, e uno dei nostri clienti nel mese in corso ha avuto 24 rerank contati e mai consegnati — tutti chiusi dal suo client a 30 s, nessuno dei quali gli è arrivato. Il numero del mese in corso lo trovate nella chiusura del mese, col suo nome: una richiesta chiusa prima della risposta è abbandonata, si conta, si registra e non si addebita.

Il tempo che costa un rerank, perché è la causa a monte: il rerank costa per carattere della coppia (domanda + documento), non per documento — misurato sui nostri contatori il 6/10, ~2,1 ms per carattere. La ricetta che pubblichiamo da sempre («30-50 candidati dall'indice, poi /v1/rerank») con documenti da 662 caratteri costa ~43 s: dentro i 120 s che consigliamo per i lotti, e fuori da un timeout di 30 s. Il paragrafo sui timeout parlava di lotti di embedding, non di rerank, e chi ha letto la ricetta e messo 30 s si è trovato nel caso peggiore. Le due leve, in ordine: accorciate i candidati (gli stessi 30 tagliati a 200 caratteri costano ~13 s, e il reranker legge comunque i primi ~1.024 token di ognuno) e alzate il timeout a 120 s. I numeri sono adesso accanto alla ricetta, sulla pagina dell'API.

A chi serve: a chi usa /v1/rerank con pochi candidati, cioè a chi segue la nostra ricetta. Nessuna firma è cambiata, nessun campo è nuovo, la versione del contratto resta 1.2, e nessuna risposta che oggi è 200 cambia: cambia solo che un 499 che prima pagavate ora non lo pagate.

Come si adotta: niente da fare per non essere più contati. Se volete smettere di prendere 499, alzate il timeout a 120 s o accorciate i documenti — il client di riferimento che pubblichiamo fa già la prima cosa.

Quando: in linea dal 8/10 alle 16:42Z, col rilascio del motore. Verificato da fuori, non dedotto dal nostro repo: misurato il 9/10 alle 08:15Z, il app.py in esercizio portava lo sha256 del commit di questa voce.

2026-10-06 — Un campo che sbagliate di nome non spariva in silenzio per caso: adesso la risposta lo elenca in ignored_fields (nessuna richiesta scritta bene cambia di un byte)

Cosa cambia: se mandate un campo che la rotta non dichiara — dimensions invece di niente, top_k invece di top_n, truncate invece di truncation, o semplicemente un nome battuto a macchina male — il campo non viene applicato (come prima) ma adesso la risposta lo dice: "ignored_fields": ["dimensions"], presente solo quando ce n'è almeno uno. Lo stato resta quello di prima: niente che oggi riceve 200 riceverà qualcosa di diverso, e una richiesta scritta bene non vede nessun campo nuovo. Prima era silenzio: misurato su 10 rotte su 10, un campo inventato veniva accettato con lo stesso stato di una richiesta sana e senza che niente lo nominasse. Un campo obbligatorio sbagliato lo dicevamo da sempre (422): il silenzio riguardava solo i facoltativi.

Dove vi serve davvero: dimensions è il parametro di OpenAI sulla rotta che chiamiamo OpenAI-compatibile, e da noi le dimensioni sono 1024 e non si scelgono — chi arriva da text-embedding-3-small (1536) o da voyage-3-lite (512) lo scriveva e riceveva 200 con un vettore da 1024, cioè esattamente la misura che non gli stava nell'indice; top_k è il nome di Cohere e col nome sbagliato ricevevate tutti i documenti invece dei primi k; truncate da noi è truncation. La tabella sta nella documentazione dell'API accanto ai campi.

Sulle rotte di amministrazione il rifiuto è secco, e lì è una nostra mano: 400 FIELD_WRONG_NAME sulle tre coppie di nomi che le due rotte sorelle usano per gli stessi concetti (rate_per_min/limite_al_minuto, quota_chars_month/quota_mensile, tenant_id/tenant), col nome giusto nel messaggio. Una chiave chiesta con limite_al_minuto: 300 nasceva a 120 e una chiesta con tenant_id nasceva nel nostro tenant, cioè con i suoi consumi fuori dalla fattura del cliente. Per voi non cambia niente: /admin/* dal posto di un cliente non è raggiungibile.

A chi serve: a chi scrive il client in casa, a chi arriva da un altro fornitore e a chi usa un adattatore scritto per un altro. Nessuna firma è cambiata, nessun campo è nuovo nelle richieste, la versione del contratto resta 1.2.

Come si adotta: niente da fare. Se volete un controllo in più, leggete ignored_fields e alzate la voce quando c'è: è il segnale che qualcuno nel vostro codice sta scrivendo un nome che noi non conosciamo.

Quello che NON facciamo: un campo sconosciuto sulle rotte /v1/* non viene rifiutato. Voyage e Cohere danno un 400; noi no, non oggi: sarebbe un cambio di contratto verso client che non vediamo — i corpi delle richieste non li registriamo e non li registreremo — e un 400 nuovo fermerebbe chi oggi funziona. Se si farà, lo leggete qui prima, con una data.

2026-10-06 — Su quale orologio è tagliato il vostro mese: alle 00:00Z, e il 429 QUOTA_EXCEEDED vi dice quanti secondi mancano (nessuna firma cambia; il «Quando» di questa voce porta l'istante in cui è andato in linea)

Cosa è già vero oggi, e non l'avevamo mai scritto: la vostra quota mensile riparte quando cambia l'etichetta AAAA-MM dell'orologio UTC, cioè al primo istante del mese nuovo: alle 00:00Z del giorno 1, che a Roma sono le 01:00 (le 02:00 con l'ora legale), non la vostra mezzanotte. Lo stesso orologio taglia la fattura e le etichette che vi rende GET /v1/usage. È così da sempre e non cambia niente — vale per la fattura di ottobre come per quelle di prima — ma fino a ieri non stava scritto in nessuna delle pagine in cui vi promettiamo una «quota mensile»: dieci promesse, zero orologi. Se avete lavori che consumano quota all'inizio del mese, quello è l'istante da aspettare.

Perché ve lo diciamo adesso: perché quell'etichetta non la decidevamo noi. La chiedevamo all'orologio locale del processo, cioè a un file di configurazione del sistema operativo (/etc/localtime) che non sta in nessun elenco di dispiegamento e che nessun test nominava. Il nostro box è a UTC, quindi il risultato era giusto — ed è giusto anche adesso, byte per byte: nessuna riga della vostra fattura si sposta. Ma era giusto per configurazione e non per costruzione, e la differenza si misura: se quel file cambiasse, per Europe/Rome sarebbero 19 ore all'anno attribuite al mese sbagliato, 52 per New York, 96 per Shanghai, 144 per Auckland — e nessun totale potrebbe accorgersene, perché le due somme resterebbero coerenti fra loro e sbagliate rispetto al vostro calendario. Adesso il mese si taglia a UTC per costruzione, e una prova che gira lo stesso codice sotto quattro fusi lo tiene lì.

Cosa cambia per voi: il 429 QUOTA_EXCEEDED porta Retry-After. Fino al rilascio di questa voce non lo portava per una scelta che avevamo scritto noi — «la quota riparte col mese, non è un ritentativo» — e il risultato è che un cliente a quota non ha nessun numero da aspettare: lo scopre riprovando. Il numero che mandiamo è i secondi che mancano al primo istante del mese nuovo in UTC, da 1 a 2.678.400, e vale la pena dire cosa non è: non è la mezzanotte di stanotte (chi va a quota il 3 e aspettasse la mezzanotte prenderebbe un altro 429, e un numero che fa aspettare per niente è peggio di nessun numero) e non è un tempo di attesa consigliato. È la distanza da quando la vostra quota riparte. Se vi serve tornare a lavorare prima, non aspettate: chiedete un aumento di quota col modulo della landing (POST /v1/richieste-licenza, pubblico e senza chiave).

Due Retry-After, un nome solo, due significati — ed è la riga da mettere nel vostro client: sul 429 RATE_LIMIT sono i secondi al minuto nuovo (da 1 a 60, la finestra è al minuto di orologio); sul 429 QUOTA_EXCEEDED sono i secondi al mese nuovo. Si distinguono dal code nel corpo, non dall'intestazione. I nostri tre client di riferimento ritentano solo su RATE_LIMIT, e su QUOTA_EXCEEDED quel numero non lo leggono nemmeno: se ricopiate i nostri non c'è niente da fare. Se il client è vostro e onorate Retry-After su qualunque 429, guardate il code prima: un client che onora due milioni e mezzo di secondi resta fermo un mese.

A chi serve: a chi ha una quota mensile, cioè a tutti. Nessuna firma è cambiata, nessun campo è nuovo, la versione del contratto resta 1.2, e nessuna risposta che oggi è 200 cambia di un byte: l'unica differenza visibile è un'intestazione in più su un 429 che già ricevete.

Quando: in linea dal 9/10 alle 08:49Z, col rilascio del motore e delle pagine API. Verificato da fuori, non dedotto dal nostro repo: un GET senza chiave su /api.html e /en/api.html porta la riga «il Retry-After dice i secondi al mese nuovo (00:00Z del giorno 1)» in entrambe le lingue, e le prove del contratto rifatte sul app.py IN ESERCIZIO (sha256 uguale a quello del repo, processo su dalle 08:49:03Z) trovano l'intestazione sul 429 QUOTA_EXCEEDED.

2026-10-05 — Una licenza si può chiedere da soli: POST /v1/richieste-licenza è viva, e la landing dice i piani e il confronto coi numeri (in linea dal 5/10, 08:40 di Roma; per chi è già cliente non cambia niente)

Cosa cambia: due cose che erano scritte e non erano raggiungibili. La prima è POST /v1/richieste-licenza, l'unica nostra rotta pubblica e senza chiave — fino a ieri rispondeva 404 perché viveva solo nel nostro codice e non in quello in esecuzione. Adesso risponde: quattro campi obbligatori (chi, contatto, piano, uso), 201 con {"n": <numero>} se la chiamate in JSON, una pagina col numero della richiesta se le mandate un <form>, 5 richieste in un'ora per indirizzo e poi 429 RATE_LIMIT. Il contratto per intero, coi sei errori con il nome, sta nella pagina dell'API. La seconda è la landing, in italiano e in inglese: i tre piani (prova, standard, enterprise) coi loro numeri — quota del mese, richieste al minuto, durata — il modulo per chiedere una licenza, e il confronto con Voyage e OpenAI.

Il confronto, e la regola che conta più della tabella: nella colonna nostra ci sono solo numeri misurati da noi, e ognuno ha il banco che lo produce; nella colonna loro solo ciò che chiunque può verificare da sé — dimensioni del vettore, dove gira il modello, come si paga. Non c'è nemmeno una misura loro, e non ci sarà: per misurare un fornitore bisogna mandargli dei testi, e da noi i testi non escono. Perciò la riga della qualità del recupero dice i nostri numeri (R@1 0,825 e nDCG@10 0,907 col denso, 0,925 e 0,968 con l'ibrido, su 26 brani di normativa pubblica e 20 query) e per gli altri dice di prendere una licenza di prova e misurare voi sul vostro corpus, che è l'unico su cui poi lavorerete.

A chi serve: a chi arriverà dallo store e a chi ci sta valutando adesso. Per NIS2, LG231, trpg, aiepod e core non cambia nulla, e questa volta va detto per esteso: la versione del contratto resta 1.2, nessuna firma è cambiata, nessun campo è nuovo sulle rotte che usate, e le chiavi di oggi non sono state messe sotto licenza — quota, limite al minuto e scadenza delle vostre chiavi sono quelli di ieri, byte per byte. Spostarle sotto un contratto è una decisione che non prendiamo noi di nascosto in un deploy: quando si farà, lo leggete qui prima.

Cosa non potete ancora fare da soli, e lo scriviamo invece di lasciarvelo scoprire: amministrare la vostra licenza. Le dieci rotte /admin/licenze/* esistono e sono provate, ma stanno dietro il nostro tunnel e dal posto di un cliente non ne arriva nessuna (misurato: 0 su 16 rotte amministrative dal di fuori, e 16 su 16 da dentro). Finché non c'è una console vostra, chiavi e licenze le emettiamo noi su vostra richiesta — ed è quello che fa il modulo.

Le misure di questa voce, fatte dal bordo pubblico e non sui nostri file (33 letture, nessuna POST, nessuna chiave, zero consumato): le voci del servizio in mano a un cliente passano da 45 su 58 a 57 su 58; GET /v1/richieste-licenza risponde 405 — «la rotta c'è, il metodo è un altro» — dove ieri rispondeva 404; la trappola che ci faceva più paura è chiusa in tutte e due le lingue. Quella trappola era questa: la landing è statica e si pubblica copiando file, mentre la rotta del modulo richiede un riavvio, e sono due catene di deploy diverse. Pubblicare solo la pagina avrebbe dato a qualcuno un modulo che risponde 404 dopo che ha scritto il suo nome, il suo contatto e cosa gli serve. Le due metà sono arrivate insieme, e adesso un banco rifiuta di lasciar pubblicare una pagina la cui chiamata il servizio non serve ancora.

L'unica voce che oggi un cliente non ha, e non la nascondiamo: stato.json, il file dei contatori dietro la pagina di stato, non si riscrive più ogni cinque minuti come vi avevamo promesso. Non vi diciamo di quanti minuti è vecchio, perché quel numero cresce da sé mentre leggete e saremmo di nuovo noi a darvi una misura vecchia. E non vi diciamo che è fermo per sempre, perché non è vero, e questa riga è la correzione di una nostra di qualche ora prima che lo diceva: l'ultima scrittura che abbiamo misurato è del lunedì 5/10/2026, 14:18:27 UTC, e il campo generato dentro il file può essere più recente di questa riga. Il motivo è che quel programmino ci riesce di tanto in tanto, per caso: gira ogni cinque minuti chiuso in una gabbia che non gli lascia aprire il database, e arriva in fondo solo quando qualcos'altro, nello stesso istante, tiene già aperto quel database: in questo modo di scrivere (WAL) anche una lettura ha bisogno di un file di servizio accanto al database, e la gabbia non le lascia crearlo. Quel qualcos'altro non siete voi, e questa riga corregge la nostra di stamattina che diceva «lo decide il vostro traffico»: sui giri veri del 5/10 fra la mezzanotte e le 21:10 UTC — 249 giri, 7 riusciti — davanti a ognuno dei 7 riusciti c'erano zero richieste vostre di quelle che aprono il database, contro 3,3 di media davanti ai falliti (bench/misura-quando-lo-stato-riesce.py --giorno 2026-10-05 --fino 21:10:00). Fra la mezzanotte e le 21:10 UTC del 5/10 ci è riuscito 7 volte su 249. Chiamarci più spesso non rinfresca la pagina, e la pagina ferma non dice niente del servizio. L'età la leggete da voi, ed è l'unico modo di averla giusta: il campo generato dice quando il file è stato scritto, e la pagina di stato da oggi lo confronta con il vostro orologio. Non è un guasto del servizio e non riguarda le vostre chiamate. Causa trovata, cura scritta e provata, in coda per il prossimo dispiegamento; fino ad allora, se la pagina di stato vi serve, guardate /health, che è in diretta e non passa da quel file.

2026-10-01 — La barra in fondo all'indirizzo: cosa è quel 307, e perché non è un errore (niente cambia nell'API; una riga nuova per chi scrive il client)

Cosa cambia: niente nel servizio. Cambia quello che sta scritto, e per una risposta che c'è da sempre e che non avevamo mai nominato. Se chiamate una rotta vera con una barra finale — POST /v1/embeddings/ invece di POST /v1/embeddings — non ricevete 200 e non ricevete un nostro errore: ricevete 307, con il corpo vuoto e un Location: verso la stessa rotta senza barra. Lo scrive il framework sotto di noi (redirect_slashes), non il nostro codice, e il 307 conserva metodo e corpo: se il vostro client segue i rimandi, non ve ne accorgete nemmeno. Se non li segue — e tanti li disattivano sulle POST, ed è una scelta prudente, perché un rimando su una POST rispedisce il corpo — vedete uno stato che non è 200 e non ha error.code.

Perché non lo chiamiamo «una terza forma senza codice»: perché non è un errore. Le due forme senza code restano il 422 e il 405, e sono risposte che dicono «avete sbagliato». Il 307 dice «è la stessa risposta, all'indirizzo giusto». Ma è uno stato che potete ricevere senza error.code, e la nostra regola 5 — il codice deducetelo dallo stato HTTP — elencava 404, 405, 5xx e «tutto il resto»: il 3xx cadeva in «tutto il resto», cioè nel ramo che non sa cosa fare. Adesso ha il suo ramo, nella pagina dell'API e dentro i tre client di riferimento, che restituiscono REDIRECT e la cura invece di un codice «non so».

Quello che vi serve sapere, tutto misurato (con un servizio di prova e un bordo di prova su porte alte; dal vivo solo letture, senza chiave):

A chi serve: a chi scrive il client in casa e a chi arriverà dallo store. Per NIS2, LG231, trpg, aiepod e core non cambia nulla e non c'è niente da ricopiare: i nostri tre client non possono mettere quella barra da soli, perché tagliano la barra finale dell'indirizzo di base — la barra nasce da un indirizzo scritto a mano, da un curl, da un client di terzi.

Come si adotta: non scrivete la barra finale. Se usate un client vostro, aggiungete il ramo 3xx: togliere la barra, oppure seguire il Location. Se ricopiate i nostri, il ramo c'è già. La versione del contratto resta 1.2, nessuna firma è cambiata, nessun campo è nuovo.

Quello che non affermiamo, e la decisione che non è nostra: non abbiamo corretto niente. Spegnere quel rimando si può, in due modi — toglierla al bordo (un caddy reload) o nell'app (una riga e un riavvio in finestra) — e il secondo renderebbe la risposta uniforme per tutte e otto le rotte. Ha però un prezzo che va detto: chi oggi funziona senza accorgersi di niente (requests, curl -L) riceverebbe un 404. La scelta è una decisione nostra da prendere in finestra, non una cosa da fare di soppiatto, e fino ad allora questo è il comportamento e adesso è scritto.

2026-10-01 — Il 404 che vi arriva quando cercate la rotta a tentoni adesso ha un codice (in linea dall'1/10, 02:00 di Roma; niente da ricopiare)

Cosa cambia: un percorso che non esiste sotto /v1/ — POST /v1/embed (fra /embed e /v1/embeddings), /v1/embedding, /v1/reranking, /v1/keys, /v1/ — non risponde più {"detail":"Not Found"} senza code. Risponde {"error":{"code":"NOT_FOUND","message":"rotta inesistente: le rotte pubbliche sono …"}}, la stessa forma di tutti gli altri nostri errori e lo stesso identico corpo che già ricevevate per un URL di base sbagliato. E quel messaggio adesso elenca tutte e otto le rotte pubbliche: prima ne nominava sei, e le due mute erano POST /v1/tokenize e POST /v1/richieste-licenza, cioè le due più nuove — quelle che uno sta cercando proprio quando legge quel messaggio.

Da dove nasce: dalla voce del 29/9 qui sotto, riletta col servizio in mano invece che sulla carta. Quella voce promette «quando bussate dove non c'è niente — URL di base sbagliato, percorso fuori dall'API, rotta cercata a tentoni — il bordo non risponde più not found in testo semplice». Misurato dall'esterno l'1/10, appena il bordo è andato in linea: vero per 13 percorsi su 13 fuori da /v1/, falso per 9 su 9 dentro. Il motivo è una scelta giusta che nessuno aveva finito di seguire: il bordo lascia passare /v1/* intero, e deve farlo, se no una rotta nuova sotto /v1/ la fermerebbe lui invece di arrivare al servizio — ma proprio per questo tutto quello che comincia per /v1/ arrivava a FastAPI, che per un percorso sconosciuto scrive il suo 404 nudo. La riga della tabella «rotta che non esiste → {"detail":"Not Found"}» stava anche nella pagina dell'API, tre righe sopra quella del bordo: le due righe insieme dicevano la cosa giusta, il paragrafo sopra di loro no.

La decisione, e perché quella: si riscrive al bordo, non nell'app. Caddy guarda lo stato della risposta che gli torna dal motore e, se è un 404, mette il nostro corpo. Costa un caddy reload — nessuna riga di codice del servizio, nessun riavvio, nessuna finestra di notte — mentre cambiare il 404 di FastAPI dentro l'app sarebbe stato un deploy con riavvio per una risposta che non calcola niente. Guardare lo stato e non il corpo è un'informazione in meno, e regge per una ragione che abbiamo inchiodato in un test invece di ricordarcela: nessuna rotta pubblica risponde 404 di proposito (i nostri 404 KEY_NOT_FOUND e 404 LICENSE_NOT_FOUND stanno tutti sotto /admin/*, che sul nome pubblico non passa nemmeno), quindi un 404 che arriva da lì è sempre quello del framework. Se un domani una rotta pubblica volesse un 404 suo, quel test cade prima del deploy. Il 405 non si tocca: la rotta esiste e il metodo no, e dire «rotta inesistente» a chi ha quasi indovinato sarebbe un consiglio falso — resta la forma senza code, dichiarata.

A chi serve: a chi scrive il client in casa, a chi arriverà dallo store, e a chiunque stia integrando adesso — perché «a tentoni» non è un modo di dire: /v1/embed è l'errore che si fa avendo letto che esistono /embed e /v1/embeddings. Per NIS2, LG231, trpg, aiepod e core non cambia nulla: i vostri indirizzi sono giusti e questa risposta non la ricevete. Se il codice lo deducete dallo stato HTTP come dice la regola 5 dei nostri client, le due forme si comportavano già allo stesso modo — ed è il motivo per cui quella regola è scritta così.

Come si adotta: niente da fare, niente da ricopiare, nessuna firma cambiata, nessun campo nuovo. La versione del contratto resta 1.2. I client di riferimento davano già NOT_FOUND a un 404 e continuano a darlo.

Le misure (bench/misura-bordo-non-trovato.py: un bordo di prova su porte alte, le stesse 20 richieste contro il Caddyfile di prima e quello di adesso, nessuna richiesta al servizio vero): risposte con un error.code 13 su 20 → 20 su 20, di cui 0 su 7 → 7 su 7 fra quelle a tentoni sotto /v1/; rotte pubbliche nominate dal messaggio 6 su 8 → 8 su 8; corpi distinti fra tutti i punti 2 → 1 (devono essere uno: se /metrics o /admin/* rispondessero diverso da un percorso inventato, la risposta direbbe che esistono — e adesso non si distingue nemmeno un percorso arrivato all'app da uno fermato prima). Invariati, e sono le controprove: il 405 del metodo sbagliato ancora 2 su 2 un 405, le rotte vere ancora 2 su 2 servite dal motore, echi del percorso nella risposta 0 su 3. Banco: 8 prove nuove, fra cui le due guardie che tengono ferma la premessa (la riscrittura solo sul nome pubblico e solo sul 404; nessuna rotta pubblica con un 404 suo) e quella che confronta l'elenco del messaggio coi decoratori di server/app.py, così non invecchia alla prossima rotta.

Quello che non affermiamo: che il banco lo avesse già misurato. Diceva 13 su 13 e zero KO il 29/9, e quel verde era su una domanda più piccola di quella scritta in cima al file, per due difetti suoi: il motore finto rispondeva 200 a qualunque percorso — quindi la scena sotto /v1/ non produceva nemmeno un 404 da guardare — e il conto escludeva l'unica riga /v1/* che c'era, con una nota che diceva «passa all'app e non è del bordo». Adesso il motore finto sbaglia come FastAPI e legge le rotte vere dai decoratori dell'app, e quelle richieste entrano nel conto: è per questo che il numero del banco è passato da 13 punti a 20, e il confronto fra i due non si fa.

2026-09-30 — Ruotare una chiave già scaduta consegnava una chiave nata morta (niente da fare per voi; nessuna modifica all'API pubblica)

Cosa cambia: nulla di ciò che chiamate voi. Cambia lo strumento con cui noi amministriamo le chiavi (/admin, sulla nostra rete privata, fuori dal contratto pubblico): chiedere la rotazione di una chiave già scaduta ora riceve un rifiuto esplicito — 400 KEY_EXPIRED, «una chiave scaduta non si ruota: la nuova nascerebbe già scaduta» — invece di una chiave nuova. E la revoca di una chiave scaduta ora dice was: "scaduta" invece di was: "active", con la stessa parola che usa il nostro comando da riga di comando.

Da dove nasce: dallo stesso difetto di ieri sera e di stamattina, la terza volta che lo incontriamo, e stavolta non in un numero pubblicato ma in un'operazione che consegna un segreto. status in AgileV prende due soli valori, active e revoked: expired non lo scrive nessuno, perché la scadenza è un'altra colonna (expires_at) e il servizio la guarda a ogni richiesta. Quindi una chiave scaduta resta active per sempre, e il controllo della rotazione — if status != "active" — la lasciava passare. Il seguito faceva due danni in una sola risposta: la finestra di grazia della chiave vecchia veniva fissata a min(adesso + 24 ore, scadenza), cioè a un istante già passato; e la chiave nuova ereditava la stessa scadenza, cioè nasceva scaduta. La risposta era un 201 con "status": "active" e dentro il plaintext della chiave — che si mostra una volta sola — e quella chiave prendeva 403 KEY_EXPIRED alla prima richiesta. Chi amministrava restava con in mano un segreto bruciato, una chiave che non funziona, e nessun modo di farsela ridare.

La decisione, e perché quella: si rifiuta, non si accetta emettendo una chiave viva. La rotazione promette una cosa sola — nessun cliente resta senza chiave a metà del cambio — e su una chiave già scaduta quella promessa è vuota: il cliente è senza chiave da prima che qualcuno ruotasse. Accettare vorrebbe dire scegliere al posto di chi amministra una scadenza che nessuno ha chiesto (+24 ore? nessuna? la vecchia portata avanti?), cioè prolungare un accesso scaduto dentro un'operazione che si chiama «rotazione». Se una chiave a termine deve rivivere, la decisione è di chi la emette e si scrive dove si vede: con l'emissione. Un rifiuto leggibile costa una riga di documentazione; una chiave nata morta costa una consegna, un cliente fermo e un segreto sprecato.

A chi serve: a nessuno di voi in pratica — /admin non è raggiungibile da Internet e non fa parte del contratto — e lo diciamo comunque per la ragione di stamattina, che vale per la terza volta: se controllate le vostre chiavi da un vostro pannello, status e scadenza sono due cose diverse, e una chiave scaduta resta active nel campo status. Se volete sapere se una vostra chiave è ancora buona, la risposta sta in expires_at (GET /v1/usage ve lo dice) o, più semplicemente, nel 403 KEY_EXPIRED che ricevete. Per NIS2, LG231, trpg, aiepod e core non cambia nulla: le vostre chiavi non hanno scadenza (expires_at: 0), quindi nessuna vostra rotazione è mai passata di lì.

Come si adotta: niente da fare. Nessuna modifica all'API pubblica, nessuna firma cambiata, nessun campo nuovo nelle vostre risposte, nessun file da ricopiare. La versione del contratto resta 1.2.

Le misure (bench/misura-la-rotazione-di-una-scaduta.py, sei scene su database usa-e-getta, il nostro database di produzione non viene nemmeno aperto, zero richieste al servizio): 29 ok 9 KO → 38 ok 0 KO. Tre delle sei scene sono il gruppo di controllo — rotazione di una chiave viva senza scadenza, rotazione di una chiave viva con scadenza futura, revoca di una chiave viva — ed erano verdi prima e restano verdi: il rifiuto non ha toccato la rotazione di tutti i giorni. Banco: 1.185 test raccolti (erano 1.167), 18 nuovi, di cui 10 cadono sul codice di prima; 11 mutazioni su 11 viste. La revoca non cambia comportamento di un carattere: una chiave scaduta si revoca lo stesso ed è idempotente come prima — cambia solo il referto, e un referto non è un permesso.

Quello che non affermiamo: che sia già successo. Oggi in produzione nessuna chiave ha una scadenza, quindi questo difetto non ha ancora consegnato nessuna chiave morta: è un errore armato, e lo arma esattamente ciò che stiamo per portare in linea — le chiavi di prova, che una scadenza ce l'hanno per costruzione, e la grazia della rotazione, che una scadenza la scrive. La seconda rotazione della stessa chiave, dopo che la prima grazia è passata, ci sarebbe finita dentro da sola.

2026-09-30 — E anche le richieste del mese sulla nostra pagina di stato contavano le nostre chiavi (niente da fare per voi; nessuna modifica all'API)

Cosa cambia: su stato.html, sotto «Questo mese», le due colonne Richieste e Testi elaborati ora contano soltanto i clienti. Prima comprendevano anche le chiavi con cui facciamo collaudo e monitoraggio noi, e quella di prova della nostra regia. Sotto la tabella c'è una riga nuova che dice quante richieste ha lavorato il servizio in tutto e da dove viene la differenza, così il conto si può rifare: nessuna cifra sparisce, cambia a quale domanda risponde quella in tabella. Sul nostro box, per settembre 2026, le richieste di embedding passano da 1.139 a 1.128 e i testi da 3.867 a 3.848; sul reranking, che di richieste ne ha poche, da 10 a 8 e da 21 a 16 — un quinto delle richieste non era di clienti.

Da dove nasce: dalla stessa domanda di stamattina, fatta ai due numeri accanto a quello che avevamo corretto. Il numero dei clienti l'avevamo rifatto alle prime ore del 30/9 (voce qui sotto: da 7 a 5, perché fra i «clienti» c'erano anche le nostre chiavi); richieste e testi stavano nello stesso file, tre righe più su, e li calcolava una riga che non collegava il consumo alla chiave che l'aveva fatto. Senza quel collegamento una riga di consumo non ha un proprietario, quindi il numero era «il lavoro che il servizio ha fatto per chiunque», mentre la pagina — e la voce del 21/9 qui sotto — lo promettevano come «aggregati su tutti i clienti». Lo stesso file, nello stesso istante, escludeva tre chiavi dal conto dei clienti e pubblicava il loro consumo come se fosse dei cinque.

A chi serve: a nessuno di voi in pratica, e lo diciamo per la stessa ragione di stamattina: quei numeri sono pubblici e li leggete anche voi. Se avete citato le richieste di settembre 2026, i valori dei clienti sono 1.128 (embedding) e 8 (reranking). Nei vostri consumi non cambia niente: GET /v1/usage vi ha sempre detto soltanto i vostri, e la fattura già escludeva le nostre chiavi — è da lì che abbiamo preso la regola, invece di scriverne una seconda.

Come si adotta: niente da fare. Nessuna modifica all'API, nessun riavvio, nessun campo nuovo nelle vostre risposte.

Le misure (bench/misura-consumi-pubblicati.py, scene in database usa-e-getta, il nostro database vero solo in lettura, zero richieste al servizio): 8 ok 37 KO → 45 ok 0 KO, con una prova di quadratura su ogni numero pubblicato (totale = clienti + nostre + regia + non attribuibili). Banco: 1.117 test raccolti (erano 1.092), 25 nuovi — tre girano davvero il codice della pagina, non solo il file che legge —, 18 mutazioni su 18 viste. La pagina continua a non pubblicare nessun nome di cliente, di azienda o di chiave, e nessun volume di caratteri: solo numeri.

Quello che non affermiamo: che la differenza fosse grande. Su embedding è l'1 %; su reranking è un quinto, e cresce con il nostro collaudo, cioè con qualcosa che non c'entra con voi. E come stamattina: il numero in pagina resta quello di prima fino al prossimo allineamento del sito, perché lo scrive un programma che gira sulla copia installata.

2026-09-30 — La nostra pagina di stato diceva «7 clienti» e i clienti erano 5 (niente da fare per voi; nessuna modifica all'API)

Cosa cambia: su stato.html la riga dei clienti diceva «clienti con chiave attiva» e il numero seguiva quella frase invece della realtà. Ora dice «clienti che possono usare il servizio adesso», e accanto si legge quello che resta fuori: quanti clienti hanno la chiave scaduta e quante chiavi non sono di clienti (le nostre, e quella di prova della regia). Sul nostro box il numero passa da 7 a 5. Nell'API non cambia niente: nessuna firma, nessun errore, nessun campo nuovo nelle vostre risposte.

Da dove nasce: da una domanda semplice fatta a un numero che pubblichiamo — a quale domanda risponde? Quel numero era COUNT(DISTINCT owner) sulle chiavi con status='active' e la parola embed o rerank fra gli ambiti, e sbagliava in quattro modi che avevamo già imparato altrove. (1) status non sa niente delle scadenze: una chiave scaduta resta active per sempre, e noi la contavamo fra i clienti mentre il servizio le risponde 403 KEY_EXPIRED — lo stesso difetto che avevamo corretto ieri sera nella fattura. (2) Il nome della chiave (owner) è un'etichetta che sceglie chi la chiede: due aziende diverse che chiamano core la loro chiave venivano contate come una. (3) Le nostre chiavi di servizio finivano fra i clienti: 2 dei 7. (4) L'ambito si controllava per sottostringa, quindi un ambito che si chiamasse embed-lab sarebbe passato e l'ambito prova — che invece concede embed e rerank — non veniva visto.

A chi serve: a nessuno di voi in pratica, e lo diciamo comunque per due ragioni. La prima è che quel numero è pubblico e lo leggete anche voi: se un giorno l'avete citato, il valore giusto per settembre 2026 è 5. La seconda è che il primo dei quattro difetti riguarda anche voi, se controllate le vostre chiavi da un vostro pannello: status e scadenza sono due cose diverse, e una chiave scaduta resta active nel campo status. Se volete sapere se una vostra chiave è ancora buona, la risposta sta in expires_at (GET /v1/usage ve lo dice) o, più semplicemente, nel 403 KEY_EXPIRED che ricevete. Per NIS2, LG231, trpg, aiepod e core non cambia nulla: le vostre chiavi non hanno scadenza (expires_at: 0).

Come si adotta: niente da fare. Nessuna modifica all'API, nessun riavvio, nessun file da ricopiare.

Le misure (bench/misura-clienti-attivi.py, scene in database usa-e-getta, il nostro database vero solo in lettura, zero richieste al servizio): 9 ok 14 KO → 23 ok 0 KO; sul box clienti_attivi 7 → 5, chiavi non di clienti 3, clienti con la chiave scaduta 0. Banco: 1.069 test raccolti (erano 1.037), 32 nuovi, 22 mutazioni su 22 viste. La pagina continua a non pubblicare nessun nome di cliente, di azienda o di chiave: solo numeri.

Quello che non affermiamo: che il difetto della scadenza ci abbia già fatto pubblicare un numero sbagliato per quel motivo. Oggi non abbiamo nessuna chiave scaduta, quindi il 7 era gonfiato dalle nostre due chiavi e non dalle scadenze: quella parte era un errore armato, con una data già scritta nel calendario. E il numero in pagina resta 7 fino al prossimo allineamento del sito, perché lo scrive un programma che gira sulla copia installata.

2026-09-29 — I client che vi diamo noi: l'indirizzo non sparisce più in silenzio (nessuna modifica all'API; ricopiate i file solo se vi serve)

Cosa cambia: nei sei esempi che pubblichiamo (agilev_client.py, agilev_client.php, agilev_langchain.py, ricerca_ibrida.py, ricerca_ibrida.php, qdrant_ibrido.py) la chiave si è sempre fermata quando mancava; l'indirizzo no. Se AGILEV_URL era configurata e non arrivava, il client andava in silenzio sull'indirizzo pubblico https://api.agilev.agile.software, e ogni prova da riga di comando restava verde. Adesso valgono quattro regole, uguali in tutti e sei: (1) l'ambiente si legge da tutte le sorgenti che la stanza può avere — in PHP getenv(), $_SERVER e $_ENV, con getenv() che vince perché è l'ambiente del processo e non qualcosa che arriva con la richiesta; (2) un valore presente ma vuoto è un errore che nomina la variabile, mai un ripiego; (3) il client dice da dove ha preso l'indirizzo (sorgente_indirizzo / sorgenteIndirizzo: argomento|getenv|server|env|ambiente|predefinito) e della chiave dice la sorgente e mai il valore; (4) c'è una guardia facoltativa, indirizzo_obbligatorio=True / new AgileV(..., indirizzoObbligatorio: true), che si ferma se l'indirizzo finisce sul predefinito pubblico.

Da dove nasce: dal quarto limite che abbiamo pubblicato stamattina, misurato da trpg in casa loro — $_SERVER['LLM_API_URL'] è null sotto Apache mentre getenv() e $_ENV portano il valore. L'avevamo scritto come avvertimento per il vostro codice e non l'avevamo mai girato contro il nostro. Censiti i sei file: la chiave si fermava 6 volte su 6, l'indirizzo mai.

A chi serve, e quanto: a chi punta AgileV su un indirizzo non pubblico — un fronte privato, il secondo box, lo smistatore, una WireGuard. Per voi l'indirizzo che sparisce non è un errore: è i vostri testi che escono su Internet invece di restare sulla rete privata, e non ve lo dice nessuno. Se usate l'indirizzo pubblico e basta, non cambia niente per voi: chi non configura nulla ha esattamente il comportamento di prima, ed è un test, non una promessa. Se il vostro stack è PHP dentro un server web (Apache con mod_php, oppure php-fpm, che con clear_env = yes — il valore predefinito — svuota l'ambiente del processo e lascia solo $_SERVER), questa voce vi riguarda anche se non avete mai visto il problema: è la forma che non si vede.

Come si adotta: niente da fare sull'API, nessuna firma cambiata, nessuna chiamata diversa. Se avete ricopiato i nostri esempi, ricopiateli da https://agilev.agile.software/esempi/; se avete scritto il client in casa, la regola utile in una riga è: un valore di configurazione presente ma vuoto è un errore, non un ripiego, e in PHP leggetelo da tutte e tre le sorgenti. Diagnosi in un comando quando «da riga di comando funziona e sotto il server web no»: php ricerca_ibrida.php --dove, oppure python agilev_client.py --prova, che ora stampano indirizzo e sorgente (mai la chiave).

Le misure (bench/misura-indirizzo-che-sparisce.py, gruppo di controllo preso da git, nessuna richiesta al servizio): celle in silenzio che finivano sull'indirizzo pubblico 4 → 0; client che con la chiave vuota spedivano il testo alla produzione pubblica con un Bearer vuoto 2 → 0; celle non osservabili 4 → 0 (in ricerca_ibrida.php l'indirizzo si decideva dentro embed(), quindi non era controllabile senza fare una richiesta, nemmeno da noi). Banco: 889 test raccolti (erano 845), 44 nuovi, 22 mutazioni su 22 viste.

Quello che non affermiamo: la stanza di php-fpm non l'abbiamo misurata noi — php-fpm non è installato sul nostro box, e la riga di clear_env è documentazione, non una nostra misura. Quella di Apache è di trpg. La nostra è la riga di comando, dove $_ENV è vuoto con il variables_order predefinito. Tre stanze, tre risultati diversi: è il motivo per cui si leggono tutte e tre le sorgenti invece di sceglierne una.

2026-09-29 — La quarta di quelle quattro si chiude: il 404 del bordo prende la forma del contratto (in linea dall'1/10, 02:00 di Roma; per voi non c'è niente da fare)

Cosa cambia: quando bussate dove non c'è niente — URL di base sbagliato, percorso fuori dall'API, rotta cercata a tentoni — il bordo non risponde più not found in testo semplice. Risponde {"error":{"code":"NOT_FOUND","message":"rotta inesistente: le rotte pubbliche sono GET /health, GET /v1/models, POST /v1/embeddings, POST /v1/rerank, POST /embed, GET /v1/usage su https://api.agilev.agile.software"}} con Content-Type: application/json, cioè la stessa forma di tutti gli altri nostri errori. Il messaggio è fisso: non ripete il percorso che avete chiesto, quindi niente di quello che mandate torna indietro nei vostri log.

Da dove nasce: dalla voce qui sotto. Delle quattro risposte senza error.code che avevamo censito stamattina, tre le scrive FastAPI dentro l'app e si toccano solo col deploy; la quarta la scrive il bordo, e per cambiarla basta un caddy reload. Era anche la peggiore delle quattro, perché è quella che prende chi sta ancora cercando l'indirizzo: nella misura del log di settembre, 7 delle 20 risposte senza codice arrivate a un cliente vero erano di questo tipo.

A chi serve: a chi scrive il client in casa e a chiunque arriverà dallo store. Per NIS2, LG231, trpg, aiepod e core non cambia nulla: se il codice lo deducete dallo stato HTTP come dice la regola 5, le due forme si comportano già allo stesso modo — ed è esattamente il motivo per cui quella regola è scritta così. Chi invece leggeva il corpo per capire cosa fosse successo, adesso trova un code anche qui.

Come si adotta: niente da fare, niente da ricopiare, nessuna firma cambiata. I client di riferimento davano già NOT_FOUND al 404 del bordo e continuano a darlo.

La misura (un bordo di prova su porte alte, le stesse 13 richieste contro il Caddyfile di prima e quello di adesso, nessuna richiesta al servizio vero): risposte del bordo con un error.code 0 su 13 → 13 su 13, tutte NOT_FOUND, tutte application/json; corpi distinti 1 prima e 1 dopo — /metrics e /admin/* rispondono 404 apposta per non ammettere di esistere, e continuano a essere indistinguibili da un percorso inventato; 0 su 3 percorsi «parlanti» tornati indietro nella risposta; 2 su 2 rotte vere ancora servite (un 404 più bello che si mangiasse le rotte buone sarebbe un guasto). La scena del 13/9 rifatta identica — cinque richieste in quattro secondi di qualcuno che cercava la rotta — passa da 0 su 4 risposte con un codice a 4 su 4; la quinta era il 405 dell'app, che resta.

Stato: in linea dall'1/10/2026, alle 02:00 di Roma (il caddy reload della finestra; nessun riavvio del servizio, nessuna interruzione). Verificato dall'esterno subito dopo: https://api.agilev.agile.software/ e dodici altri percorsi fuori dall'API rispondono 404 con code: NOT_FOUND e Content-Type: application/json. Il 422 e il 405 dell'app restano come sono; il 404 dell'app no, l'ha chiuso il bordo l'1/10 — vedi la voce in cima a questa pagina.

2026-09-29 — Le risposte che AgileV non scrive: quattro, e adesso sono censite (vale già sulla 1.1 in linea)

Cosa cambia: niente nel servizio. Cambia quello che vi diciamo e quello che fanno i client di riferimento. Fino a ieri la pagina dell'API prometteva che il 422 fosse l'unica risposta senza {"error":{"code":…}}. Non era vero: sono quattro, e due sono già capitate a clienti veri.

QuandoCosa riceveteChi la scrive
corpo della richiesta non valido422 con {"detail":[{…}]}FastAPI
metodo HTTP sbagliato (GET su /v1/embeddings)405 con {"detail":"Method Not Allowed"}FastAPI
rotta che non esiste404 con {"detail":"Not Found"}FastAPI
URL di base sbagliato, o percorso fuori dall'API404 con not found in testo semplice, nemmeno JSONil bordo (Caddy)

Questo è il censimento del 29/9. Delle quattro, le due righe del 404 si sono chiuse: la quarta quel giorno stesso (in linea l'1/10 alle 02:00), la terza l'1/10 — vedi la voce in cima alla pagina. Oggi restano il 422 e il 405.

Da dove nasce: dalla verifica in campo dei consumatori. Leggendo il log del nostro bordo di settembre in sola lettura abbiamo trovato che il 13/9 alle 01:26:32Z un cliente ha chiamato /v1/embeddings in GET e ha ricevuto un 405, e che il 15/9 alle 12:43:55Z un altro ha ricevuto un 502 perché due secondi prima avevamo riavviato il servizio. Nessuno dei due ce l'ha segnalato, e non per distrazione: dal loro lato era un errore senza nome. In tutte queste risposte detail è una stringa (o il corpo non è JSON), quindi cadono tutte nello stesso ramo del lettore d'errore di un client — quello che non sa cosa fare — e lì i client, compresi i nostri, mettevano ?.

A chi serve: a chiunque scriva il client in casa, cioè a tutti e sei i consumatori. È la regola 5 di «Se il client lo scrivete voi» e della pagina autonoma gli errori visti dal vostro client: nessuna risposta deve uscire dal vostro client con codice «non so». Il codice mettetecelo voi, deducendolo dallo stato HTTP e mai dal corpo — quei corpi non sono nostri e nessuno ha promesso cosa ci metterà dentro. 404 → percorso sbagliato, non ritentate; 405 → metodo sbagliato, non ritentate; 5xx senza forma → ritentate; tutto il resto → un codice che dica «senza codice» e riporti lo stato. Sono quattro comportamenti diversi, e con ? erano uno solo. Corollario: il corpo di quelle risposte non mettetelo nel messaggio — una pagina d'errore di un proxy di mezzo può contenere qualunque cosa e finirebbe nei vostri log etichettata come un errore di AgileV.

Come si adotta: se usate i nostri client (agilev_client.py, .php, agilev_langchain.py), ricopiateli: nessuna firma è cambiata e i quattro casi escono con NOT_FOUND, BAD_METHOD, UPSTREAM_ERROR e NO_CODE — codici messi dal client, non cercateli nelle nostre risposte. Vale subito, con la 1.1 che è in linea oggi: la misura è stata rifatta catturando i corpi d'errore dal codice in produzione e dalla 1.2, con lo stesso risultato, quindi non c'è nessun deploy da aspettare.

La misura, in due parti. In gabbia (bench/misura-errori-client.py, 15 scene × 3 client): righe in cui chi chiama non vede un codice 12 su 45 → 0 su 45, righe con l'eco dei vostri testi 0, vettori disallineati in silenzio 0. Dal vivo, sul log del nostro bordo di settembre in sola lettura (nessuna richiesta al servizio, nessun indirizzo nell'uscita): su 4.310 richieste servite, 20 risposte senza un codice del contratto sono arrivate a qualcuno che il servizio lo usa — 13 su una rotta vera e 7 mentre cercava la rotta. Dentro quei sette c'è la scena che ha fatto nascere la regola: il 13/9 fra le 01:26:28 e le 01:26:32Z una sola sorgente ha provato POST /embeddings, GET /embeddings, GET /api/embeddings, GET /api/v1/embeddings e infine GET /v1/embeddings — quattro 404 e un 405, nessuno dei cinque con un codice. Qualcuno stava cercando il nostro indirizzo a tentoni e non gli abbiamo detto niente di utile nemmeno quando l'ha trovato.

Stato: fatto, e non richiede il deploy della 1.2. Delle quattro, due si sono chiuse e le ha chiuse il bordo: il 404 fuori dall'API il 29/9 (in linea dall'1/10 alle 02:00) e il 404 dell'app l'1/10. Restano il 422 e il 405: dare al 422 la nostra forma è un cambio di contratto e si valuta a parte, annunciato prima; il 405 viene dal framework e su una rotta che esiste col metodo sbagliato «rotta inesistente» sarebbe un consiglio falso, quindi resta.

2026-09-29 — Chiavi di prova con scadenza, e un errore nuovo: 403 KEY_EXPIRED (in produzione dal 29/9/2026)

Cosa cambia: una chiave può avere una data di fine. GET /v1/usage la dice nel campo nuovo expires_at (secondi epoch UTC; 0 = non scade), e quando la data è passata ogni richiesta risponde 403 KEY_EXPIRED invece di funzionare. Nasce anche un ambito nuovo, prova: copre /v1/embeddings, /embed, /v1/rerank e /v1/tokenize — cioè il servizio — e non copre /metrics né l'amministrazione delle chiavi. Una chiave con ambito prova deve avere una scadenza: senza, non viene accettata.

Da dove nasce: dal mandato del titolare del 29/9 sui servizi sovrani. Ogni servizio della suite deve poter essere provato con una chiave passe-partout a termine, la stessa per tutti i servizi, senza aprire un contratto e senza che qualcuno debba ricordarsi di spegnerla a mano.

A chi serve, e chi non deve fare niente: a chi vuole far provare AgileV (lo store, i clienti terzi, le prove interne). Per NIS2, LG231, trpg, aiepod e core non cambia nulla: le vostre chiavi non hanno scadenza (expires_at: 0) e continuano a funzionare esattamente come prima — è misurato, ed è il primo controllo che abbiamo scritto.

Come si adotta: nulla da fare. Se un giorno ricevete una chiave a termine, l'unica cosa utile è leggere expires_at da GET /v1/usage (che non consuma richieste al minuto) e avvisare prima che scada, invece di scoprirlo da un 403. Su KEY_EXPIRED riprovare non serve: serve una chiave nuova.

Stato: in produzione dal 29/9/2026 (deploy della 1.2 alle 18:09 di Roma; GET /health risponde contract: 1.2).

2026-09-29 — Una nostra scrittura di contabilità non vi costa più la risposta (e i consumi sono in difetto, mai in eccesso)

Cosa cambia: se il nostro archivio dei consumi ha un guasto — disco pieno, file bloccato da un altro processo — la vostra richiesta viene servita lo stesso. Prima no: il calcolo era già stato fatto, e la risposta veniva buttata via per darvi un 500. Niente da cambiare da parte vostra, nessun campo nuovo, nessuna risposta diversa quando va tutto bene.

Da dove nasce: da una domanda che ci siamo fatti guardando il nostro codice prima del deploy, e da una lezione che ci ha passato trpg («due riparazioni entrambe giuste che, messe in fila male, aprono il buco: se avete qualcosa di simile in casa, guardatelo prima del prossimo deploy»). La scrittura della fattura stava dopo il calcolo e prima della risposta, senza protezione. Misurato costruendo il guasto per davvero — un disco pieno vero e un file bloccato vero, non una finta — il conto era: 4 risposte perse su 4, e in metà dei casi il lavoro era già fatto e pagato in CPU. Con il file bloccato aspettavate anche 10 secondi in più prima di ricevere l'errore.

Il prezzo, detto per intero, perché è vostro: la riga di consumo si perde davvero, e non la recuperiamo. Quindi i numeri di /v1/usage e la fattura che ne esce possono essere più bassi del vero; non saranno mai più alti. Per la stessa ragione, durante un guasto del genere una chiave con quota mensile può sforare: preferiamo servirvi e perdere il conto piuttosto che negarvi una risposta per un numero che serve a noi. Da parte nostra un contatore dice sempre quante righe sono andate perse, così sappiamo quando i conti del mese sono in difetto e di quanto.

Quanto dura l'attesa, e chi la paga: al primo guasto smettiamo di provare a scrivere per 30 secondi, così nessuna richiesta paga l'attesa del file bloccato. Allo scadere dei 30 secondi riprova una sola richiesta, per vedere se l'archivio è tornato: le altre passano subito. Senza questa regola, allo scadere riprovavano tutte insieme — misurato il 29/9 con un file bloccato vero: 8 richieste su 8 ferme per 10,1 s, una volta ogni 30 secondi per tutta la durata del guasto; adesso 1 su 8. L'altra faccia, che è vostra: la richiesta che fa da sonda e quelle che le passano accanto (una finestra di 46 millisecondi) perdono la riga di consumo anche se l'archivio nel frattempo era tornato. È sempre il verso giusto dell'errore — in difetto, mai in eccesso.

Chi può accorgersene: chi ha un ripiego su 500 e chi tiene il conto delle proprie richieste (trpg, core). Se nei vostri registri avete 500 isolati con error.code: "INTERNAL" in passato, una parte può essere stata questa — e il numero di caratteri che avete visto fatturato in quei mesi può essere leggermente più basso di quello che avete mandato.

Misura, banco in gabbia con database temporanei e produzione mai interrogata: risposte perse 4 su 4 → 0 su 4; una raffica di 3 richieste uguali durante il guasto passava da 30,1 s e nessuna servita a 10,0 s e tutte e 3 servite. 545 test veloci verdi, 23 sul modello vero, 14 mutazioni su 14 viste.

2026-09-29 — La fila del lavoro si legge: coda in /health, e ok resta vero anche quando è piena

Cosa cambia: GET /health porta un campo in più, coda, che dice quanto è occupata la fila del lavoro. Aggiuntivo: chi non lo legge non vede cambiare nulla, nessuna risposta di calcolo cambia, e ok resta true anche con la fila piena — di questo sotto, perché è la parte che conta.

"coda":{"stato":"satura","posti":40,"occupati":40,"in_attesa":8}

Quattro nomi: libera (sotto l'80 % dei posti), carica (oltre, ma un posto c'è), satura (nessun posto libero, o c'è già qualcuno in attesa: chi arriva aspetta), indecidibile (non siamo riusciti a leggere il contatore — vuol dire non lo sappiamo, non va tutto bene). Accanto al nome ci sono i numeri, così il conto lo potete rifare voi.

Da dove nasce, ed è il rovescio della voce qui sotto. Ieri abbiamo scritto che dal 1.2 /health risponde anche sotto carico, e che «la fila si legge nei tempi, non nella salute». La prima metà è vera e misurata; la seconda era una rinuncia. trpg ce l'ha girata in faccia nel modo giusto, il 29/9, partendo da un difetto trovato in casa propria: quattro dei loro servizi scrivevano {"status":"ok"} e niente altro, quindi i loro quattro database potevano essere morti tutti e ogni spia restava verde. La loro domanda a noi era: il vostro /health, adesso che risponde sempre, sa dire anche «vivo, ma in fila»? O il campo che il prodotto pubblica sa dire solo sì e no? Sapeva dire solo sì e no. Misurato prima di correggere, con 48 richieste in volo per 28 secondi: 0 letture su 104 nominavano la fila.

La parte che non si vede e che vi riguarda: il terzo nome sta fuori dal campo che i controlli binari guardano. ok resta true e il codice resta 200 anche con la fila satura, per scelta. Se avete una vigilanza, un orchestratore o un grep che decide «vivo/morto» da ok o dal codice HTTP, continuate a leggere quelli: un servizio in fila è vivo, e la risposta a «morto» è un riavvio — cioè la cosa peggiore da fare mentre sta lavorando. Anche questa l'abbiamo imparata da trpg, che l'ha pagata: mettendo il terzo nome dentro il campo che Docker guarda, la loro «salute onesta» dichiarava morto un servizio vivo ogni volta che la fila era piena. Da noi la guardia che lo impedisce si chiama FILA_PIENA ed è una controprova che pretende che nulla cambi colore: gira su sette scene, compresa quella in cui non sappiamo rispondere.

Come si adotta: niente da cambiare. Se volete usarlo, serve a leggere i vostri tempi — con la fila satura il tempo che misurate è attesa, non calcolo — e a decidere di rallentare da soli. Il freno vero resta il 429 con Retry-After e le intestazioni X-RateLimit-*: la fila non vi respinge, vi fa aspettare. La nostra agilev-prova-dal-posto.sh ora lo stampa come riga informativa, e la pagina di stato lo scrive accanto alla spia senza cambiarne il colore.

Tre limiti, detti prima che ci contiate sopra. È un'istantanea, non una media: dice com'era nell'istante della lettura. Un campo assente non è libera: un servizio più vecchio di questo contratto non lo manda affatto, e allora il vostro controllo deve dire «non lo so» — la lista vuota di model_unknown insegna la stessa cosa. E occupati conta i posti dentro il servizio: non conta /health (non ne occupa uno) né quello che è ancora fermo al bordo.

Misura, gabbia da 4 GB, database temporanei, porta di prova, produzione mai interrogata: con 48 richieste insieme, 95 letture su 95 portano il campo, occupati 40, in_attesa 8 (le 48 in volo contate una per una), ok vero in tutte le letture, 0 sonde perse, mediana 9 ms; a servizio libero il gruppo di controllo dice libera con 0 occupati. Prima: 0 su 104.

2026-09-29 — model_unknown: la lista che vi dice se la vostra configurazione manda un nome che non conosciamo

Cosa cambia: GET /v1/usage porta un campo in più, model_unknown — quante volte la vostra chiave ci ha mandato un campo model che non riconosciamo, per mese e per rotta, con l'ora dell'ultima volta. Aggiuntivo: chi non lo legge non vede cambiare nulla, e nessuna risposta di calcolo cambia.

"model_unknown":[{"month":"2026-09","op":"rerank","requests":4,"last_at":1790651460}]

Da dove nasce, e non è un'idea nostra. Ce l'ha chiesto trpg il 28/9, con una frase sola: il nome del modello lo mandiamo da una variabile d'ambiente e vogliamo accorgercene prima dei nostri clienti. Oggi un nome che non conosciamo viene servito lo stesso e non succede niente — è una scelta di continuità, spiegata in Il campo model — quindi una configurazione sbagliata non fa rumore da nessuna parte. Il giorno in cui arriverà 400 MODEL_UNKNOWN, invece, sarebbero errori. Questa lista è il modo di accorgersene adesso, con mesi di anticipo.

Come si adotta: leggete /v1/usage da un controllo automatico e allarmatevi se model_unknown non è vuoto. Guardarlo non consuma né quota né richieste al minuto (misurato), quindi può stare in un controllo periodico senza pesare sulla fattura. I nomi validi sono quelli di /v1/models.

Quattro limiti, detti prima che ci contiate sopra. La lista vuota non vuol dire che la configurazione è giusta: "", null e il campo assente valgono come default e non vengono contati — se la vostra variabile d'ambiente è vuota, qui non si vede, e l'unico modo di accorgersene resta il model della risposta. E /embed (formato storico) non guarda il campo affatto: chi lo usa avrà sempre la lista vuota. Terzo limite, dal 29/9 e ce l'ha detto trpg misurandolo in casa: la lista è per chiave. Se un domani le vostre installazioni avranno una chiave loro, i nomi che mandano non compaiono sotto la vostra. E il primo limite, al rovescio, per chi ha un ripiego nel codice: se la vostra variabile vuota fa scattare un ?: 'nome-di-un-altro-fornitore', quel nome viene contato — quindi per voi la lista è più utile, non meno. Quarto limite, dal 29/9, misurato da trpg in casa propria: se il vostro codice legge la configurazione da $_SERVER invece che da getenv(), sotto un server web quel valore può essere assente — nel loro contenitore $_SERVER['LLM_API_URL'] è null sotto Apache mentre getenv() e $_ENV portano il valore. Lì non c'è nessun ripiego da contare: c'è una configurazione che sparisce dentro il server web e funziona in ogni prova da riga di comando. Da noi si vede come un nome che manca, o come una chiave che non compare affatto. La forma da riconoscere: la stessa chiave che alterna il nome giusto e nessuna riga, senza che nessuno abbia toccato niente.

Che cosa registriamo, e che cosa no. Solo numeri: chiave, mese, rotta, quante volte, quando l'ultima. Il nome che ci avete mandato non lo scriviamo da nessuna parte — arriva da una vostra variabile d'ambiente e può contenere qualunque cosa, e il nostro database si copia ogni notte. Voi sapete che cosa ci avete messo; a noi basta sapere che non lo riconosciamo. /metrics resta un aggregato senza identificativi di cliente, come è sempre stato scritto: l'attribuzione sta dove sta già la fattura, e la vede solo il proprietario della chiave.

Perché lo abbiamo fatto adesso. Perché la condizione che ci eravamo dati in pubblico per il 400 MODEL_UNKNOWN — «sapremmo che qualcuno sbaglia ma non chi avvisare» — era ferma, e non si sbloccava da sé. Letto in produzione il 29/9 alle 03:2x: 4 delle 7 richieste di rerank riuscite dal riavvio del 27/9 portavano un nome non riconosciuto, e non c'era modo di dire di chi fossero. C'era anche una seconda cosa che non tornava: il contatore vive in memoria e ripartiva da zero a ogni riavvio, quindi la prima condizione — «resta stabilmente a zero» — si soddisfaceva riavviando il servizio. Misurato in gabbia su tre clienti finti, due dei quali con la configurazione sbagliata: sul codice 1.1 in linea il verdetto è non lo so, e dopo un riavvio diventa non sbaglia nessuno. Ora è questi due, prima e dopo il riavvio.

2026-09-29 — Quaranta richieste insieme: /health non rispondeva più, e un 500 che era nostro

Cosa cambia: due difetti nostri, trovati dalla stessa misura e corretti, e una riga in più nei limiti. Nessuno dei due si vede col traffico di tutti i giorni; entrambi si vedono quando arrivano molte richieste nello stesso istante — cioè la notte di un deploy annunciato, quando i vostri banchi partono tutti insieme appena leggete l'avviso.

Il primo: /health taceva. Le richieste in volo occupano i posti di una fila che ne ha quaranta, e fino al contratto 1.1 /health stava in quella fila come tutte le altre rotte. Con 48 richieste di calcolo insieme, quindi, la salute non rispondeva affatto: misurato il 29/9 con sonde da 10 s ogni 0,25 s, tutte scadute, 20 s di silenzio di fila nel caso peggiore, in 5 giri su 5. Chi chiamava /health per sapere se il servizio era vivo leggeva «morto» su un servizio che stava lavorando — e la risposta a un servizio morto è un riavvio, che è la cosa peggiore da fare in quel momento. Dal 1.2 /health non passa più da quella fila ed è quindi un controllo di vita valido anche sotto carico: sulla stessa scena, 0 sonde perse su 883. Se /health vi dice ok:true mentre le vostre richieste sono lente, non è un guasto: è la fila, e la fila si legge nei tempi — dal 29/9 la fila si legge nella salute, campo coda: voce in cima a questa pagina.

Il secondo: un 500 su un servizio sano. Nella stessa misura, 33 risposte su 48 sono tornate 500 con il servizio perfettamente in salute. Causa, dal nostro journal: RuntimeError: Already borrowed. Il tokenizer di bge-m3 è un oggetto che va configurato a ogni chiamata, e da noi lo usavano due strade diverse senza un lucchetto comune — il conteggio dei token (quello che decide la vostra quota e il 413) e il modello che tokenizza per conto suo. Due fili nello stesso istante, e l'oggetto si rifiuta. Corretto: il conteggio ha adesso una copia sua. Misura del prima e del dopo sulla stessa scena, cinque giri per parte: non-200 in 5 giri su 5 → 0 giri su 5, cioè 0 su 480 richieste. Il difetto è in produzione oggi (contratto 1.1) e non è mai scattato: a settembre il massimo di richieste in volo insieme è stato 3 su 1.084 richieste, e ci vuole una coincidenza di microsecondi fra due strade. Con 8 richieste insieme, misurato, non si vede nemmeno una volta: per questo è rimasto invisibile.

Come si adotta: niente da cambiare nel client, e una cosa da sapere. Nei limiti c'è una riga nuova: le richieste simultanee non sono limitate, il tetto per minuto non le governa — una chiave da 120 al minuto può averne quaranta in volo senza prendere un 429. Dal 1.2 farlo è innocuo, ma resta inutile: il calcolo è uno alla volta, quindi quaranta richieste insieme finiscono nello stesso tempo di quaranta in fila, e nel frattempo i vostri timeout corrono. Per indicizzare vale quello che vi diciamo da sempre — un lotto alla volta, fino a 128 testi, timeout 120 s — e ora c'è anche il numero per cui lo diciamo. Se avete una vigilanza che guarda /health con un timeout di pochi secondi, dal 1.2 potete alzare la fiducia; prima del deploy, no.

2026-09-29 — I primi secondi dopo un riavvio: un 500 che ora ha un codice, e un rosso che non dovete credere

Cosa cambia: due cose, e la prima riguarda il vostro banco, non il nostro servizio. Il servizio, dopo un riavvio (deploy compreso), apre la porta subito e carica i modelli dopo: /health risponde 200 con "loaded":false e i modelli arrivano in 8 secondi a cache calda, ~40 a freddo. In quella finestra le richieste di calcolo non vengono rifiutate: aspettano e poi rispondono giuste. Ma un controllo che legge loaded e dà rosso tinge di rosso un servizio sano: il nostro banco della prova dal posto, lanciato in quella finestra, tornava 3 KO con il coseno a 1,000000, cioè col controllo che conta verde. Corretto: agilev-prova-dal-posto.sh ora aspetta (--attesa-avvio, default 90 s) e dice quanto ha aspettato; dà KO solo se i modelli non arrivano, e allora si ferma senza spendere richieste. Se avete riscritto il banco in casa — come ha fatto trpg — la riga da aggiungere è questa: prima di giudicare, aspettate che /health dica loaded:true. Un rosso in quella finestra non è il guasto per cui la prova esiste.

La seconda è un difetto nostro, trovato e corretto la notte del 29/9, e vale la pena saperlo perché cambia una risposta che potreste ricevere. In quella stessa finestra /v1/rerank (e /v1/embeddings) potevano rispondere 500 Internal Server Error in testo semplice, senza {"error":{"code":…}}: era il secondo errore senza la nostra forma dopo il 422, e chi ramifica su error.code ci vedeva ?. Causa misurata: i modelli si caricano in un thread a parte, e una richiesta che entra nel frattempo ne faceva costruire un secondo in parallelo; il modello si costruisce senza un fiato e si rompe al primo uso. Nel caso peggiore, misurato, il processo restava a servire 500 per sempre — Restart=always non lo rialza, perché non muore: risponde sbagliato. Frequenza misurata su 8 avvii a freddo con 4 richieste sparate al primo istante: la maggior parte dei giri. In produzione non è mai scattato, e sappiamo perché: i tre riavvii di settembre (19, 23 e 27/9) sono tutti fra le 06:02 e le 06:37 UTC, e in tutti e tre ci sono state zero richieste nei primi 60 secondi. Il deploy della 1.2 è l'opposto: lo annunciamo noi, e voi girate il banco appena lo leggete.

Come si adotta: niente da cambiare nel vostro client. Due cose da sapere. (1) Dal contratto 1.2 un guasto nostro non previsto esce come 500 INTERNAL con la nostra forma e col type dell'eccezione: {"error":{"code":"INTERNAL","message":"guasto interno (NomeEccezione): …","type":"NomeEccezione"}}. Mai il messaggio dell'eccezione — un messaggio di libreria può portarsi dietro un pezzo del vostro corpo, e i vostri testi non escono. Riprovare una volta ha senso; se si ripete, mandateci stato e type: il traceback e il contatore agilev_imprevisto_totale{tipo=…} ce li abbiamo noi, voi non dovete registrare nient'altro (e in particolare non il corpo). (2) Se il vostro client ha un timeout basso, sappiate che la prima richiesta dopo un riavvio può prendersi i secondi del caricamento: non è lentezza del calcolo, è il modello che arriva.

2026-09-28 — Un vettore che è già nell'indice: da quale fornitore viene? Si ricava, e adesso è misurato

Cosa cambia: nel servizio nulla. Cambia una frase nostra che era sbagliata. Nella Regola 4, contromisura (d), avevamo scritto: scrivete il modello accanto al frammento, «costa niente oggi, è impossibile domani». La prima metà è giusta, la seconda no — e l'avevamo scritta senza misurarla, ripetendo la conclusione di trpg, che la sera del 28/9 ci aveva scritto: «il nostro indice ha 442 frammenti di un fornitore e 29 dei vostri, tutti a 1024 valori, la tabella non registra il modello da nessuna parte, non sapremo mai se è sano». Se accanto al vettore c'è ancora il testo, la provenienza si ricava: si rifà l'embedding del testo e si guarda il coseno col vettore salvato. C'è ora una contromisura (e), ed è la seconda che vale sull'indice che avete già.

Misurato il 28/9 col modello vero in gabbia di memoria su 64 frammenti nostri, zero richieste alla produzione (13 scene, 0 KO): stesso testo e nostro modello danno coseno 1, con uno scarto massimo di 2,2·10-12 — e di 3,9·10-12 perfino attraverso il nostro deploy 1.1 → 1.2, quindi il verdetto è lo stesso prima e dopo. Un altro spazio da 1024 valori dà |coseno| ≤ 0,10. In mezzo non c'è niente: fra il peggiore dei nostri (0,7537, magazzino a un bit) e il migliore degli estranei (0,1011) ci sono 7 volte. Regge anche col magazzino compresso — a mezza precisione (float16, 0,99999998) regge perfino il verdetto esatto, quindi dimezzare lo spazio non costa niente di questa prova; con int8 (0,9998) e con un bit (0,7537) resta il verdetto sullo spazio e col testo ritoccato dopo la scrittura (0,9755). Al posto di un fornitore esterno — che non usiamo e di cui non abbiamo i vettori — l'«altro spazio» è un'isometria del nostro: stessa dimensione, stessa norma, stessa geometria interna, base diversa; un modello vero allenato per conto suo può solo essere meno allineato con noi di così.

A chi serve: a chi ha un indice scritto in più riprese o passato da un fornitore a un altro senza la (d) — a trpg per i 442 frammenti che consideravano perduti, e a chiunque dei sei stia costruendo l'indice adesso. (1) Se salvate float32 il verdetto è esatto: o il coseno è 1 a dodici cifre, o quel vettore non è nostro. (2) Non serve l'indice intero per sapere se è misto: 29 frammenti a campione bastano per accorgersi con fiducia 95 % di una contaminazione del 10 % (5 se è il 50 %, 299 se è l'1 %). Etichettare tutto un indice da 471 frammenti costa due minuti di servizio, una volta sola.

Come si adotta: niente da cambiare nel client. Il protocollo in sei passi è scritto per essere riscritto nel vostro linguaggio, perché sui box Agile l'harness vieta di eseguire codice scaricato da fuori: la provenienza dei vettori (copia: artefatti), soglie anche in API. Il tranello, l'unico che sballa tutto l'indice insieme: se l'indicizzatore prepara il testo prima di mandarlo (titolo davanti, taglio, prefisso tipo query: , minuscole), il vettore salvato è di quel testo. Misurato: coseno 0,7548 nel caso peggiore, quindi non vi fa dire «estraneo» (il verdetto sullo spazio resta «nostro»), ma la soglia esatta 1e−6 non regge più su nessun frammento e chi usa solo quella condanna un indice sano per intero. Rifate l'embedding passando per la stessa funzione dell'indicizzatore. E un controllo di sanità del vostro banco prima di credergli: un frammento che sapete nostro deve dare 1, un vettore di numeri casuali normalizzati deve dare qualcosa intorno a zero; se il secondo non è piccolo state confrontando male (vettori non normalizzati, o byte letti con l'endianness sbagliata).

2026-09-28 — Il nostro deploy muove i vettori che avete nell'indice? Misurato: solo le richieste grosse, e di 4·10-12 di coseno

Cosa cambia: nel servizio nulla. Cambia che una promessa che vi avevamo fatto adesso ha un numero sotto. Vi abbiamo chiesto di rimisurare gli stessi testi dopo il deploy della 1.2 e di fermarvi se il coseno col vostro riferimento congelato scende sotto 0,9999. Quella soglia era una promessa nostra e nessuno l'aveva verificata — e c'era una ragione per dubitarne: la 1.2 divide le richieste grosse in più passaggi nel modello (lotti a budget di token, per non finire in memoria esaurita), mentre la 1.1 le manda in un colpo solo. Raggruppare i testi diversamente vuol dire padding diverso, e in virgola mobile gli ultimi bit possono cambiare.

Misurato sul modello vero mettendo le due strade a confronto sullo stesso oggetto modello, così che l'unica variabile fosse il raggruppamento: una richiesta che sta in un passaggio solo (fino a 16 testi, o testi corti) dà vettori identici bit per bit, coseno 1,00000000; una richiesta che supera il budget e viene divisa — 10 testi da 1.822 token, 2 passaggi — muove 2 vettori su 10, coseno peggiore 0,9999999999960902 e scarto massimo su una componente 3,06·10-7. Sul riordino l'ordine non cambia né a 10 né a 40 documenti (punteggi entro 4·10-11).

A chi serve: a tutti e sei i prodotti che devono rimisurare dopo il deploy. La soglia concede 10-4 e il deploy ne consuma quattro cento milionesimi: la regola diventa quindi più forte, non più debole — se la vostra misura del «dopo» scende sotto 0,9999, non è il cambio di raggruppamento: è un guasto vero, ed è nostro. Fermatevi e scriveteci, come d'accordo. Senza questa misura, un cliente che avesse visto 0,9999999999 invece di 1,00000000 ci avrebbe scritto per un guasto inesistente — lo stesso tipo di falso allarme del confronto sui nomi, qui sotto.

Come si adotta: niente da cambiare. Un consiglio che rende la vostra misura più sensibile: congelate il riferimento su frasi corte, poche per richiesta, così la richiesta sta sempre in un passaggio solo, il confronto prima/dopo torna esatto bit per bit e qualunque movimento che vedete è vero. È quello che ha fatto trpg, ed è il motivo per cui la loro riga dice 1,00000000 e non «circa 1». Il dettaglio con la tabella è in API e nella pagina del protocollo. Misurato anche il costo: i lotti a budget non costano tempo (65,2 s → 64,4 s sui testi lunghi), tengono giù la memoria a parità di velocità e di vettori.

2026-09-28 — La Regola 4 aveva un inciampo: la (a) si confronta sulla dimensione, mai sul nome (e la dimensione si conta sui float)

Cosa cambia: nel servizio nulla, oggi e col deploy della 1.2 (misurato sulle due versioni, esito identico). Cambia la Regola 4, che da stamattina consigliava «all'avvio un GET /health e confrontate con il vostro indice» senza dire su quale valore. trpg l'ha messa in opera la sera stessa e ci ha rimandato l'inciampo: in /health il campo model è il modello sottostante (bge-m3), mentre /v1/models elenca il nome di listino (agilev-m3), che è anche quello che vi torna da /v1/embeddings. Sono due vocabolari: chi legge la (a) come «confrontate model e dim» e confronta il nome si trova un disallineamento a ogni avvio, con il servizio perfettamente sano. La controprova che abbiamo misurato dopo la loro segnalazione è più netta di così: /v1/embeddings dice agilev-m3, /embed dice bge-m3, e i due vettori sono identici bit per bit. Il nome non è un segnale di salute; la dimensione sì, perché si misura. E c'è un secondo falso allarme, che nessuno ci aveva ancora segnalato e che abbiamo trovato misurando: len(vettore) == dim regge solo sui float — con output_dtype binary o ubinary un vettore da 1024 arriva come 128 valori (1024 bit impacchettati) e con encoding_format: base64 come una stringa: 7 combinazioni su 10 farebbero scattare la guardia a torto. E una guardia che scarta a torto, in silenzio, è esattamente il danno della Regola 4.

Come si adotta: se usate i nostri tre client, ricopiateli (agilev_client.py, agilev_client.php, agilev_langchain.py): la (a) e la (c) ora sono dentro, in una riga sola all'avvio — av.controlla_dim(1024), $av->controllaDim(1024), emb.controlla_dim(1024). Legge dim da /health (senza chiave, senza consumo), si ferma con DIM_MISMATCH se non è la dimensione del vostro indice e non guarda il nome; da quel momento un vettore della misura sbagliata è un BAD_RESPONSE («attesi vettori da 1024 valori, ricevuti da 512») invece di entrare nell'indice. Nella regola c'è ora anche una (d), ed è di trpg: scrivete il modello accanto al frammento nel momento in cui costruite l'indice — il loro ne ha 442 di un fornitore e 29 dei nostri, tutti a 1024 valori e nessun campo che dica quale, e l'informazione per accorgersene non è mai stata dichiarata. Costa niente oggi; ricavarlo domani si può, a una chiamata per frammento — è la contromisura (e), misurata nella voce in cima a questa pagina, e corregge l'«impossibile» che avevamo scritto qui.

2026-09-28 — I nostri client rendevano muti i nostri errori: corretti, e tre regole per chi il client lo scrive da sé

Cosa cambia: nel servizio nulla. Cambiano i tre client di riferimento (agilev_client.py, agilev_client.php, agilev_langchain.py): ricopiateli, nessuna firma è cambiata e sul percorso felice non cambia niente. Due difetti, entrambi trovati da trpg sul proprio client il 28/9 e poi misurati sui nostri. (1) error e detail sono strutture, non stringhe: infilarle in un messaggio produce la parola Array in PHP e il repr di un dizionario in Python. Da loro ogni nostro errore diventava «Voyage API error: Array» — e non in un log, nella risposta della loro API a un utente: KEY_MISSING, KEY_INVALID e RATE_LIMIT non arrivavano mai a chi guarda. Nei nostri client la stessa cosa succedeva sul 422, l'errore che un client nuovo incontra per primo: codice ? e messaggio pieno dei vostri testi, perché detail[].input è l'eco di quello che avete inviato. Ora il 422 diventa BAD_BODY con corpo della richiesta non valido - body.input: Field required — solo loc, msg, type, mai l'eco. (2) Un 200 di forma inattesa (lotto mutilato, proxy di mezzo) passava per successo con meno vettori dei testi: da trpg tornava NULL in silenzio, nei nostri client tornava una lista più corta. Ora è un errore BAD_RESPONSE con attesi 2 elementi in data, ricevuti 1. BAD_BODY e BAD_RESPONSE li mette il client, non il servizio: non cercateli nelle nostre risposte.

A chi serve: a tutti, ma soprattutto a chi il client se lo scrive in casa — cioè quasi tutti, perché l'harness dei box Agile vieta di eseguire codice scaricato da fuori. Le quattro regole, misurate e non consigliate a occhio, sono nella documentazione dell'API e in una pagina autonoma da ricopiare: gli errori visti dal vostro client. La quarta l'hanno trovata loro stessi tirando il filo di un nostro avviso, e dice che «una lista vuota non è una risposta»: un indice costruito con un modello e interrogato con un altro non dà errore, dà zero risultati. La terza è quella che sporca un indice senza far rumore: contate che data abbia tanti elementi quanti i testi inviati e che gli index siano 0…n-1; se ne arrivano meno e voi accoppiate con uno zip, indicizzate il testo sbagliato col vettore sbagliato e ve ne accorgete mesi dopo, come qualità di ricerca che peggiora.

Come si adotta: ricopiare i tre file dagli esempi; oppure, se il client è vostro, guardare i tre punti uno per uno. La prova che dura un minuto: fate fallire una chiamata per davvero (chiave sbagliata, e un corpo con un campo mancante) e guardate con gli occhi il messaggio che arriva a chi lo legge. Se contiene «Array», un repr di dizionario, un ? o un pezzo dei vostri testi, avete trovato il difetto. La misura sui nostri: 12 scene × 3 client — righe senza un codice nostro da 15 a 3, righe con l'eco dei vostri testi da 9 a 0, vettori disallineati in silenzio da 3 a 0; 17 test nuovi e 11 mutazioni che li fanno cadere.

2026-09-28 — Il campo model oggi non è validato: nessun nome viene rifiutato, e le quattro condizioni prima che diventi 400

Cosa cambia: nel servizio nulla, oggi e col deploy della 1.2. Cambia che ora è scritto, e misurato: un banco prova 4 rotte × 9 nomi sul codice 1.1 in produzione e sulla 1.2 e l'esito è identico sulle due — zero 400. Nessun nome di modello viene rifiutato: voyage-3, modello-che-non-esiste e persino "" o null ricevono una risposta 200, servita e contata in fattura come tutte le altre. Il model di una richiesta di embedding mandato al rerank funziona, e viceversa. /embed non guarda il campo affatto. La tabella completa è nella documentazione dell'API.

A chi serve: a chi il nome del modello lo prende da una variabile d'ambiente o da un file di configurazione — cioè quasi tutti. Due conseguenze pratiche. La prima: un nome sbagliato oggi non dà errore, quindi un refuso in configurazione resta invisibile finché non lo cerchi. La seconda, peggiore: "" e null non sono nemmeno contati come sconosciuti, quindi una variabile d'ambiente vuota non lascia traccia da nessuna parte. Il controllo che regge è uno solo, ed è gratis: ogni risposta dichiara il modello usato (/v1/rerank → "model":"agilev-rerank", /v1/embeddings → "model":"agilev-m3", /embed → "model":"bge-m3"). Confrontate quello, non quello che avete spedito. Chiesto da trpg il 28/9, dopo che l'avevano misurato dal loro box: «vogliamo accorgercene prima dei clienti».

Come si adotta: niente da fare oggi. Quando la validazione arriverà — 400 MODEL_UNKNOWN — non arriverà di sorpresa: prima succederanno, in quest'ordine, tutte e quattro queste cose. (1) Il contatore dei nomi sconosciuti resta a zero in modo stabile: oggi non lo è affatto — letto in produzione il 28/9, 3 delle 6 richieste di rerank riuscite dal riavvio del 27/9 portavano un nome non riconosciuto. (2) Il contatore diventa attribuibile: oggi è un totale per operazione, senza chiave; sapremmo che qualcuno sbaglia ma non chi avvisare, e finché non sappiamo a chi scrivere non si cambia nulla. (3) Voce qui e messaggio diretto a ogni titolare di chiave, con almeno 30 giorni fra l'avviso e il cambio. (4) Il cambio va in un deploy da solo, mai insieme ad altro, così se rompe qualcosa si sa cosa tornare indietro.

2026-09-28 — I corpi che AgileV non accetta: /embed vuole texts, /v1/rerank vuole documents (e il 422 non ha code)

Cosa cambia: nel servizio nulla; cambia la pagina, che finora taceva su due errori veri. La prima prova dal posto tornata da un cliente (trpg, 28/9 17:53Z: 22 controlli, 0 KO, coseno 1,00000000 su tre frasi, mediana 33 ms dal loro box) ha segnato «rotto» il formato storico: non era rotto, era il corpo. POST /embed accetta solo {"texts":[…]}; col corpo OpenAI {"model":…,"input":[…]} risponde 422. Specularmente {"texts":[…]} su /v1/embeddings dà 422. Sul rerank vale l'opposto di quel che sembra: il nome del modello non fa fallire nulla — {"model":"agilev-m3", …} su /v1/rerank funziona e risponde "model":"agilev-rerank" — mentre documents è obbligatorio e input lì non esiste. Misurato oggi sul codice 1.1 in produzione e sulla 1.2: non cambia col deploy.

Il 422 non ha la nostra forma {"error":{"code":…}} (qui avevamo scritto che era l'unica: era falso, e il 29/9 le abbiamo contate — sono quattro, vedi la voce del 29/9): è il corpo di FastAPI (detail[], nessun code), arriva prima dell'autenticazione (corpo sbagliato e chiave mancante = 422, non 401), non consuma quota né richieste al minuto, non porta le intestazioni X-RateLimit-* — e vi rimanda indietro ciò che avete inviato: un lotto da 64 testi (89.600 caratteri) torna come 89.909 byte di errore. Se il vostro client registra i corpi d'errore per intero, quei testi finiscono nel vostro log.

A chi serve: a chi sta portando un client nuovo o migrando da un SDK OpenAI (il caso di trpg), e a chiunque scriva la gestione degli errori: oggi ramificare solo su error.code lascia scoperto il 422, che va riconosciuto dallo stato HTTP. Chi già lavora non deve fare nulla: nessun corpo valido cambia comportamento.

Come si adotta: niente da cambiare. Per un client nuovo usate /v1/embeddings col corpo OpenAI; /embed resta congelato per chi integrò AgileVoyage. Nel vostro log degli errori registrate stato e error.code, non il corpo intero. Dare al 422 la forma degli altri errori (con un code stabile, senza rimandare indietro i vostri testi) è un cambio di contratto: si valuta dopo il deploy della 1.2, annunciato qui prima di farlo. Tabella aggiornata nella documentazione dell'API.

2026-09-28 — Una prova da eseguire sul vostro box: agilev-prova-dal-posto.sh

Cosa cambia: nasce uno script da eseguire voi, sul vostro box, non sul nostro: 17 controlli in un comando, solo bash, curl e python3, nessuna libreria. Controlla la rete e il certificato visti da voi, /health e /v1/models, che gli errori siano quelli documentati (401 KEY_MISSING, 401 KEY_INVALID), chi è la vostra chiave e quali ambiti ha, il formato storico /embed e il rerank, i tempi dalla vostra parte e che i contatori dei vostri consumi si muovano esattamente di quello che avete speso. Il controllo che conta davvero è un altro: il coseno con un vettore custodito. La frase fissa «Il contratto scade il 30 settembre» ha il suo vettore congelato dentro lo script; se il coseno resta ~1, i vettori che ricevete oggi sono compatibili con quelli che avete già in Qdrant, in pgvector o in MySQL. Se scende, il vostro indice non è più confrontabile con le query nuove: è il guasto più silenzioso che AgileV possa avere e si vede solo da lì.

A chi serve: a tutti quelli che hanno una chiave (NIS2, LG231, trpg, taxai, aiepod, il core quando passerà). Dal nostro box sappiamo che il servizio risponde e quanto ci mette, ma non sappiamo se la vostra rete, la vostra chiave e il vostro client stanno bene: finora l'unica risposta era «nessuno si è lamentato».

Come si adotta: la pagina artefatti.agile.software/agilev-prova-dal-posto.html porta lo script dentro di sé e una riga lo riestrae byte per byte (l'impronta sha256 attesa è scritta nella pagina); dopo il prossimo allineamento del sito lo stesso file sarà anche qui, su prova-dal-posto.html e in esempi/agilev-prova-dal-posto.sh. Rimandateci solo l'ultima riga, quella che comincia con RIGA:; mai la chiave. Non legge i vostri dati: spedisce tre frasi fisse scritte nel file, non stampa mai la chiave e non la fa comparire in ps. In modalità piena costa 7 richieste di poche decine di caratteri, contate nei vostri consumi; con --niente-consumo fa solo i controlli che non toccano la fattura. Quando rifarla: dopo ogni deploy annunciato qui — il prossimo è il passaggio al contratto 1.2 — e prima di una reindicizzazione grossa.

Aggiornamento del 28/9 sera: se il vostro box non può eseguire codice scaricato da fuori (è la regola dell'harness di trpg-01, ed è una buona regola), non vi serve il nostro file: la stessa prova è ora descritta come protocollo — controlli, risultati attesi, regole di condotta e il formato della riga RIGA: — su protocollo-prova-dal-posto.html, da riscrivere nel vostro linguaggio come ha fatto trpg. Con un vantaggio: il riferimento per il coseno congelatelo voi, nel vostro repo, adesso che in linea c'è ancora la versione con cui avete indicizzato. Ci serve la riga, non il nostro script.

2026-09-22 — spezza() nei client di riferimento: i documenti lunghi si spezzano prima di spedirli

Cosa cambia: i client di riferimento (esempi/agilev_client.py e .php) hanno una funzione in più, spezza(testo, massimo=1500, sovrapposizione=150, intestazione="") (in PHP AgileV::spezza): frammenti da al più 1.500 caratteri tagliati dove il testo si taglia da solo (a capo, fine frase, poi fra le parole, a metà parola solo se una parola è più lunga del frammento), con le ultime frasi intere del frammento precedente ripetute in testa al successivo (fino a 150 caratteri) e l'intestazione che gli date («Art. 25-septies Rubrica») ripetuta in ogni frammento. Python e PHP producono gli stessi frammenti (lo pretende un test). Il servizio non cambia: è codice che gira da voi.

A chi serve: a chi indicizza documenti interi (NIS2 e LG231 con gli articoli di legge, trpg con le schede): un articolo da 55.928 caratteri oggi è un 413 TEXT_TOO_LONG; con spezza() sono 44 frammenti che stanno in una richiesta (misurato: 40 s, 66 frammenti al minuto), ognuno con il numero dell'articolo in testa, che è ciò che fa ritrovare le citazioni per numero. Sui 153 articoli del corpus normativo: 468 frammenti, nessuno oltre 1.500 caratteri o 400 token, ogni frase ritrovata intera in almeno un frammento.

Come si adotta: ricopiate il file del client nel vostro progetto e chiamate av.embed(spezza(testo, intestazione=titolo)); la misura di 1.500 caratteri è quella delle nostre prove e il 22/9 l'abbiamo confrontata con 1.000 e 2.000 sui 153 articoli: l'ibrido sta fra 0,754 e 0,779 di nDCG@10, una o due query su 69, nessun vincitore netto, quindi il default non cambia; 2.000/200 è la misura migliore e più economica su normativa lunga (362 vettori invece di 460, stesso tempo di calcolo), quindi chi indicizza articoli di legge può passare massimo=2000, sovrapposizione=200: sono parametri. Con LangChain: AgileVSpezza(intestazione_da="titolo").split_documents(docs) in esempi/agilev_langchain.py fa la stessa cosa al posto di RecursiveCharacterTextSplitter (che non ripete l'intestazione), con i metadati ereditati più frammento/frammenti. Funziona già con la 1.1: non dipende dal deploy. I file aggiornati sono nel repo e arrivano sul sito al prossimo allineamento (nessun riavvio). Dettagli in API, «Prestazioni».

2026-09-22 — Il 429 dice quando riprovare: Retry-After e X-RateLimit-* (in produzione dal 29/9/2026)

Cosa cambia: sul 429 RATE_LIMIT il servizio manda Retry-After con i secondi che mancano al minuto nuovo (da 1 a 60: la finestra è al minuto, il numero è esatto), e su ogni risposta autenticata tre intestazioni informative: X-RateLimit-Limit (richieste al minuto della vostra chiave), X-RateLimit-Remaining (quante ne restano in questo minuto), X-RateLimit-Reset (secondi al minuto nuovo). Il 429 QUOTA_EXCEEDED non ha Retry-After: la quota riparte col mese. Corpi e codici d'errore invariati; gli errori di chiave (401, 403) non portano intestazioni di rate.

A chi serve: a chi indicizza in lotti (aiepod, che fa l'84 % delle richieste, e trpg): invece di indovinare con 1-2-4-8 s si aspetta il numero giusto e si riparte al minuto nuovo, né prima (altro 429) né dopo (tempo perso). X-RateLimit-Remaining permette di rallentare prima del 429.

Come si adotta: nulla da fare. Chi usa il client di riferimento (esempi/agilev_client.py / .php, aggiornati oggi) lo ha già: aspetta esattamente Retry-After quando c'è (fino a 60 s) e l'attesa crescente (tetto 30 s) solo quando non c'è; una connessione riusata che il server ha chiuso nel frattempo (keep-alive scaduto) la rifà subito senza contarla come ritentativo. Dal 22/9 (stessa 1.2) i due client frenano anche prima del 429: quando una risposta dice X-RateLimit-Remaining: 0 la richiesta dopo aspetta X-RateLimit-Reset secondi prima di partire, così il vostro log non vede più errori di rate (misurato il 27/9: 5 richieste con una chiave da 2 al minuto, 429 ricevuti 2 → 0, e lo stesso tempo totale, perché la finestra è al minuto di orologio: il freno non rallenta, toglie le richieste a vuoto). Dettagli in API, «Errori». Stato: pronto e provato (220 test veloci e 10 sul modello vero, riprovati il 27/9; prove sul servizio 1.2 di sviluppo), in produzione dal 29/9/2026 col deploy della 1.2: GET /health risponde contract: 1.2 e le intestazioni ci sono.

2026-09-21 — Client di riferimento Python e PHP: le regole di integrazione già scritte nel codice

Cosa cambia: due file da copiare nel vostro progetto, esempi/agilev_client.py (solo libreria standard) e esempi/agilev_client.php (solo ext-curl/ext-json), con la stessa classe AgileV: embed(testi), rerank(domanda, candidati), usage(), health(). Dentro ci sono le regole che abbiamo imparato misurando il traffico di settembre, così non dovete riscoprirle: la connessione TLS si riusa (la stretta di mano si paga una volta), i lotti partono uno alla volta (64 testi, massimo 128) con timeout 120 s, i limiti (128 testi, 32.000 caratteri) si controllano prima di spedire, si ritenta solo su 429 RATE_LIMIT, 5xx e connessione caduta con attesa crescente (o Retry-After), mai su 400/401/403/413 e su quota esaurita, e al timeout il lotto si dimezza invece di essere ripetuto uguale (il caso peggiore del 12/9: 47 lotti identici rimessi in coda da un timeout a 31 s). Il rerank legge i primi 800 caratteri di ogni candidato, il valore misurato il 21/9. Nessuna modifica all'API, nessun riavvio: funzionano già con la 1.1 (lo sparso e le richieste abbandonate arrivano con la 1.2).

A chi serve: a trpg (PHP: il timeout corto e i ritentativi del 12/9 non possono più succedere), ad aiepod, NIS2, LG231 e a chiunque scriva un indicizzatore; per LangChain resta agilev_langchain.py.

Come si adotta: si copia il file, si mette la chiave in AGILEV_KEY, with AgileV() as av: vettori = av.embed(testi) (Python) o $av = new AgileV(); $vettori = $av->embed($testi); (PHP). python agilev_client.py --prova / php agilev_client.php --prova fanno un giro completo (health, embedding, rerank) e stampano i contatori (richieste, ritentate, lotti dimezzati, connessioni). Provati contro il servizio 1.2 e contro un server finto che simula 429, 500, quota e timeout (10 test, stesse scene per Python e PHP). Non registrano nulla: nessun testo nei log.

2026-09-21 — Pagina di stato pubblica (in produzione dal 29/9/2026)

Cosa cambia: nasce stato.html: disponibilità del servizio letta dal browser (/health, con il tempo di risposta), versione del contratto, ora dell'ultimo avvio, e i contatori aggregati su tutti i clienti del mese in corso (richieste e testi per operazione, quanti clienti hanno una chiave attiva, quanti testi sono stati serviti dalla cache). Il file stato.json è rigenerato ogni 5 minuti sul server da un timer che legge i contatori in sola lettura; nessuna modifica all'API, nessun riavvio.

A chi serve: a chi vuole sapere in un colpo d'occhio se AgileV è su prima di aprire un ticket, e a chi deve mostrare a un cliente finale che il servizio esiste ed è usato.

Cosa NON c'è, per scelta: nessun testo, nessun nome di cliente, nessuna chiave, nessun consumo per cliente (ognuno vede solo il proprio con GET /v1/usage), nessun tempo medio del mese (mescolerebbe indicizzazioni a lotti e domande interattive: i tempi per operazione restano nella documentazione).

Come si adotta: niente da fare. Chi vuole un controllo automatico legge /health (JSON, senza chiave) come prima.

21/9/2026Contratto 1.2: pesi lessicali (return_sparse) per la ricerca ibrida. Perché: le citazioni di articolo.

Stato: in produzione dal 29/9/2026 (deploy della 1.2 alle 18:09 di Roma). La pagina risponde: verificato dall'esterno il 29/9, https://agilev.agile.software/stato.html → 200.

Cosa abbiamo misurato (e perché ve lo diciamo)

Cosa cambia

A chi serve

Come si adotta

15/9/2026Fase 2a in produzione: cache e micro-batching. Documentazione pubblica.

Cosa è cambiato

A chi serve

Come si adotta

14/9/2026Contratto 1.1: parità con Voyage, conteggio token vero, vettori compressi

Cosa è cambiato

A chi serve

Come si adotta

11/9/2026Nasce AgileV (contratto 1.0)

Cosa è cambiato

A chi serve

Come si adotta