AgileDecide — guida all'API /v1/systemone
Per chi deve chiamare il servizio da un proprio programma. Scritta il 26/10/2026.
Derivata da tre fonti, non da ricordi: il contratto docs/F2-contratto.md, il codice della porta servizio/porta.py, l'elenco dei compiti servizio/registro.json. Gli esempi curl ricalcano le chiamate di servizio/collaudo.py. Dove una cosa non è misurata, è scritto.
1. Cos'è, e da dove si chiama
AgileDecide è un servizio di decisioni a risposta fissa. Gli dai un testo e il nome di un compito; lui sceglie una classe da un elenco chiuso, dice quanto è sicuro, e si astiene quando non lo è.
È una normale porta HTTP. Richieste e risposte sono JSON (application/json, UTF-8). Ogni chiamata porta una chiave nell'intestazione Authorization: Bearer <chiave>.
Il nome pubblico c'è. La base delle chiamate è https://api.agiledecide.agile.software. Davanti al servizio sta un proxy nginx 1.24 della regia, che inoltra al servizio solo il percorso /v1/: ogni altra rotta prende 404 percorso_ignoto dal proxy, e la richiesta non arriva nemmeno a noi. La vetrina statica sta su un nome diverso, https://agiledecide.agile.software. La superficie pubblica è soltanto 80 e 443: tutto il resto è chiuso. Configurazione e certificati sono della regia (infra/agiledecide/nginx-agiledecide.conf, sul serverino), non di questo box: questa guida non promette di gestirli.
Il servizio dietro il proxy non si è aperto di un millimetro, e conviene saperlo perché spiega gli esiti che vedrete:
- Ascolta su due soli indirizzi, cablati nel codice e non configurabili:
127.0.0.1e l'indirizzo della macchina dentro la rete privata WireGuard (ASCOLTI_AMMESSIinservizio/porta.py). Ogni altro indirizzo,0.0.0.0compreso, fa uscire il processo con errore prima di aprire il socket. - I chiamanti ammessi sono due: la macchina stessa e un solo altro nodo della rete privata (
CHIAMANTI_AMMESSI). Chiunque altro prende 403chiamante_non_ammesso, prima che la chiave venga guardata: una chiave valida non apre la porta a un chiamante non ammesso. Il filtro è doppio e indipendente — c'è anche un filtro nel kernel, nell'unità di sistema (servizio/systemone.service): se uno salta, l'altro regge. - Il nome pubblico non è una porta nuova sul servizio: chi inoltra è il proxy della regia, che sta sul serverino, cioè su una macchina che era già fra i chiamanti ammessi. Dal nome pubblico passa
/v1/e nient'altro; aprire altro lo decide chi governa la rete.
La porta TCP di esercizio è la 8711 (servizio/systemone.service), e si raggiunge solo dalla macchina stessa. Negli esempi della §7 la base è quella pubblica, https://api.agiledecide.agile.software: è l'unica che un cliente possa chiamare, e i comandi si copiano così come sono. L'indirizzo di casa http://127.0.0.1:8711 resta dichiarato a parte, in una seconda variabile, perché /healthz non esce dalla macchina e perché è lì che il nostro collaudo esegue gli esempi (strumenti/collaudo_cliente.py, che sostituisce ogni variabile dichiarata con un'istanza usa-e-getta). Fra le due basi il resto del comando non cambia.
Le rotte sono tre, e sono tutte quelle che esistono (ROTTE in servizio/porta.py):
| rotta | metodo | chiave | a cosa serve |
|---|---|---|---|
/v1/compiti | GET | sì | l'elenco dei compiti e cosa il servizio sa fare di ciascuno |
/v1/systemone | POST | sì | una decisione su un testo |
/healthz | GET | no | stato del processo, solo dalla macchina stessa |
/healthz non esce dalla macchina nemmeno verso l'altro chiamante ammesso: da fuori risponde 404, non 403, così non risulta nemmeno che quel percorso esista. Non usatelo come controllo di salute remoto: non lo potete raggiungere. Dal nome pubblico /healthz non esiste affatto: lo ferma il proxy, che inoltra solo /v1/, e la risposta è il suo 404. Le rotte raggiungibili da fuori sono dunque due, /v1/compiti e /v1/systemone.
2. GET /v1/compiti — che cosa il servizio sa fare
Torna l'elenco completo dei compiti, compresi quelli che oggi il servizio non serve — che un compito non sia servito è un'informazione utile, non una cosa da nascondere — e l'ambito della vostra chiave, cioè su quali compiti potete chiamare. Ogni voce è la voce del registro servizio/registro.json con id rinominato in compito:
| campo | significato |
|---|---|
compito | t1, t2, t3, t4 |
nome | la descrizione in italiano del compito |
versione_schema, impronta_schema | versione dello schema servito (es. "2.3") e sua impronta, per accorgersi che è cambiato |
classi | l'elenco chiuso delle classi possibili |
lunghezza_max_caratteri | oltre questa lunghezza il servizio si astiene |
lingue, campi_obbligatori | le lingue previste e i campi che la richiesta deve portare |
livello, soglia, confine | quale motore decide e con quale soglia; null se nessuno. confine è una soglia più alta che vale solo su alcune classi: su quelle il taglio è la più alta fra soglia e confine |
servito | se oggi il servizio decide davvero su questo compito |
perche | in prosa, perché è servito o perché no |
a_valle_astensione | che cosa deve fare il vostro sistema quando arriva ASTIENI |
asimmetria | quale errore costa di più su questo compito |
configurazione | che cosa abbiamo misurato di meglio su questo compito, in parole libere (vocabolario aperto): può nominare anche una configurazione che NON è servibile — per esempio ospitata — perché una misura migliore deve avere un posto dove arrivare; null se non c'è |
vincoli | i vincoli dello schema, con l'esito misurato di ciascuno |
Risposta (estratto reale dalla voce t2 di servizio/registro.json; i campi lunghi in prosa ci sono davvero, qui sono accorciati a …):
{
"versione_risposta": "1",
"ambito": ["t1", "t2", "t3", "t4"],
"compiti": [
{ "compito": "t2",
"nome": "Instradamento verso le specialità di AgileLLM",
"versione_schema": "2.3", "impronta_schema": "6099722f678dfec2",
"classi": ["NORMATIVA", "AIUTO_PRODOTTO", "DI_SERIE"],
"lunghezza_max_caratteri": 2000, "lingue": ["it", "en"],
"campi_obbligatori": ["testo"],
"livello": null, "soglia": null, "confine": null, "servito": false,
"perche": "…", "a_valle_astensione": "…", "asimmetria": "…",
"configurazione": "misurata meglio: nessuna configurazione sola tiene i tre vincoli",
"vincoli": ["…"] }
]
}
3. POST /v1/systemone — una decisione
La richiesta
Content-Type: application/json. Il corpo è un oggetto con questi campi e nessun altro: un campo non previsto è un errore, non viene ignorato.
| campo | tipo | obbligatorio | note |
|---|---|---|---|
compito | stringa | sì | uno fra t1…t4; deve esistere nel registro |
testo | stringa | sì | il testo da classificare; può essere vuoto, ma allora il servizio si astiene |
contesto | oggetto di stringhe | dipende | i campi obbligatori li dice lo schema del compito (campi_obbligatori in /v1/compiti); chiavi e valori devono essere stringhe |
id_chiamante | stringa | no | finisce nei registri del servizio: forma chiusa [A-Za-z0-9._:-], massimo 64 caratteri |
Limiti duri, dal codice (servizio/porta.py): corpo oltre 65 536 byte (64 KiB) rifiutato con 413 senza leggerlo tutto; Content-Length mancante 411; id_chiamante oltre 64 caratteri 422; id_chiamante con caratteri fuori dalla forma [A-Za-z0-9._:-] 400 id_chiamante_non_valido.
id_chiamante non è un campo libero, e non ci mettete segreti. Va a finire nel registro delle decisioni in chiaro, perché serve a ritrovare una chiamata dopo un guasto: è l'unico campo vostro che il servizio scrive così. Dal 26/10/2026 passa da una forma chiusa (lettere, cifre, . _ : -) proprio per non essere un posto dove un segreto possa finire per distrazione — ma la forma non vi impedisce di scriverci qualcosa che non volete sul nostro disco: quello dipende da voi. Misurato in F5g (docs/F5g-referto.md).
Se la chiamata è una prova, il contratto chiede che id_chiamante cominci per prova-: serve a chi legge i registri a distinguere l'esercitazione dal traffico vero (docs/F2-contratto.md §6-bis).
La risposta
Esito 200. Le chiavi sono sempre tutte presenti, sempre queste, mai una in più (CHIAVI_RISPOSTA in servizio/porta.py; tabella §1 di docs/F2-contratto.md):
| chiave | tipo | significato |
|---|---|---|
versione_risposta | str | "1" |
id_decisione | str | d- + 16 esadecimali, unico per chiamata |
compito | str | t1…t4 |
versione_schema | str | versione dello schema servito |
decisione | str | una classe dell'elenco chiuso oppure ASTIENI |
astenuto | bool | true se e solo se decisione == "ASTIENI" |
confidenza | float|null | 0..1 calibrata; null quando il compito non è servito |
soglia | float|null | la soglia applicata a questa decisione |
livello | str|null | L0, L1 o null (nessun livello ammesso) |
motivo_astensione | str|null | vedi §4 qui sotto; null se non astenuto |
a_valle | str | cosa deve fare chi consuma: dal campo a_valle_astensione dello schema quando astenuto, dalla classe altrimenti |
livello2 | obj|null | solo t2: {"pacchetto":…, "confidenza":…, "soglia":…} o null |
ms | float | tempo della decisione dentro il servizio |
impronta_ingresso | str | sha256 del testo, primi 16 esadecimali |
impronta_ingresso serve a ritrovare una decisione nei registri senza che il servizio conservi il vostro testo: il testo d'ingresso non viene mai scritto nei log.
Gli invarianti che il collaudo verifica su ogni risposta
Garanzie su cui potete costruire: servizio/collaudo.py le controlla su ogni risposta.
decisioneè una classe dello schema oppureASTIENI— mai una classe inventata;astenutoè coerente condecisione;confidenza≥sogliaquando non è astenuto (per i livelli che hanno una confidenza);- nessuna chiave in più rispetto alla tabella qui sopra;
- il testo d'ingresso non compare in nessun campo della risposta.
L'invariante 5 è anche una cintura a tempo di esecuzione: se il motore rimettesse il testo dentro la risposta, la porta risponde 500 motore_incoerente invece di consegnarla.
4. L'astensione — la parte da capire bene
decisione: "ASTIENI" non è un guasto, non è un errore, non è un 500. È un esito normale, HTTP 200: il servizio ha guardato e non è abbastanza sicuro per decidere al posto vostro. Trattatelo come un caso previsto, non come un'eccezione.
Quando astenuto è true, motivo_astensione dice perché e a_valle dice in parole che cosa deve succedere adesso: leggete a_valle, è la parte del contratto scritta per voi. I motivi sono un elenco chiuso (§2 del contratto, MOTIVI_ASTENSIONE in servizio/porta.py): non ne arriveranno altri.
motivo_astensione | che cosa è successo | che cosa dovete fare |
|---|---|---|
compito_senza_livello_ammesso | nessun motore disponibile rispetta il vincolo di qualità che lo schema impone a quel compito. Il servizio si astiene sempre su quel compito, e lo dichiara | usate il percorso che usate oggi senza AgileDecide: niente cambia |
sotto_soglia | il motore ha scelto una classe, ma con una confidenza sotto la soglia tarata | mandate il caso alla coda di revisione umana |
nessuna_regola_scattata | il livello a regole non ha trovato niente e il compito non ha una classe di ripiego | revisione umana |
livello_ha_scelto_astieni | il modello ha guardato e ha scelto di non decidere: su quel livello ASTIENI è una classe addestrata come le altre | revisione umana; è diverso da «non ho visto niente», e il registro lo distingue |
troppo_lungo | il testo supera lunghezza_max_caratteri dello schema | accorciate, spezzate, o mandate a revisione umana |
ingresso_vuoto | testo vuoto o fatto di soli spazi | è un vostro difetto di integrazione: sistemate a monte |
contesto_mancante | mancano campi obbligatori dello schema dentro contesto | aggiungete i campi dichiarati in campi_obbligatori |
Due conseguenze pratiche: dimensionate la coda umana (il servizio è fatto per astenersi piuttosto che sbagliare, e oggi — vedi §8 — si astiene sempre), e non riprovate la stessa chiamata: salvo ingresso_vuoto e contesto_mancante, che sono difetti della richiesta, lo stesso testo dà lo stesso esito.
5. Gli errori
Il corpo di un errore è sempre {"errore": "<nome>"}, con un dettaglio opzionale che è un testo fisso: non contiene mai il vostro testo d'ingresso né la vostra chiave. La tabella è l'elenco completo dei casi ErroreRichiesta di servizio/porta.py.
| HTTP | errore | quando succede | che fare |
|---|---|---|---|
| 400 | lunghezza_non_valida | Content-Length non numerico, o negativo | difetto del client HTTP |
| 400 | json_malformato | il corpo non è JSON UTF-8 valido | sistemate la serializzazione |
| 400 | id_chiamante_non_valido | id_chiamante contiene caratteri fuori dalla forma ammessa (vedi §3) | usate solo lettere, cifre e . _ : - |
| 401 | chiave_assente | manca Authorization, non è Bearer, o il segreto è più corto di 32 caratteri | mettete la chiave; la risposta porta WWW-Authenticate: Bearer |
| 401 | chiave_non_valida | la chiave non è nel registro, oppure è stata revocata | chiedete una chiave nuova; non riprovate in ciclo |
| 403 | chiamante_non_ammesso | state chiamando da un indirizzo che non è fra i due ammessi | non è un problema di chiave: non potete raggiungere il servizio da lì |
| 403 | fuori_ambito | chiave valida, ma il compito non è nell'ambito di quella chiave | chiedete un ambito più largo, oppure chiamate solo i compiti del vostro ambito |
| 404 | percorso_ignoto | il percorso non esiste; oppure avete chiesto /healthz da fuori la macchina | controllate la rotta |
| 404 | compito_ignoto | il compito non è nel registro | leggete /v1/compiti |
| 405 | metodo_non_ammesso | metodo sbagliato per quella rotta (es. GET /v1/systemone) | la risposta porta l'intestazione Allow coi metodi giusti |
| 411 | lunghezza_mancante | la richiesta non porta Content-Length | difetto del client HTTP |
| 413 | corpo_troppo_grande | il corpo supera 65 536 byte | spezzate il testo |
| 422 | campo_non_valido | il corpo non è un oggetto; compito o testo mancanti o non stringhe; contesto non è un oggetto di stringhe; id_chiamante non stringa o oltre 64 caratteri; un campo non previsto | il dettaglio dice quale campo |
| 429 | quota_superata | la vostra chiave ha superato il suo tetto di chiamate nella finestra in corso | aspettate i secondi dell'intestazione Retry-After e riprovate; vedi §8 |
| 500 | motore_incoerente | il motore ha risposto fuori dal contratto, o il testo d'ingresso è finito nella risposta | segnalatelo: è un difetto nostro, non vostro |
| 500 | guasto_interno | qualunque altro errore non previsto | riprovabile; se persiste, segnalatelo |
Prima di noi c'è il proxy, e ha esiti suoi. La tabella qui sopra è il servizio. Chiamando il nome pubblico, due di questi esiti arrivano dal proxy della regia senza che la richiesta ci raggiunga, e il corpo ha la stessa forma {"errore": "<nome>"}:
- 404
percorso_ignotosu qualunque percorso che non cominci per/v1/: il proxy inoltra solo quello. Il nome dell'errore è lo stesso del nostro 404, ma lo dice un altro, e da fuori/healthznon esiste. - 429
tetto_indirizzo, corpo{"errore": "tetto_indirizzo"}e intestazioneRetry-After: 60: è il tetto per indirizzo, e sta davanti alla quota per chiave. Sono due misure, entrambe sull'indirizzo di chi chiama: senza l'intestazioneAuthorization: Bearercon un segreto di almeno 32 caratteri, 10 chiamate al minuto con raffica 5; su tutte le chiamate, con chiave o senza, 5 al secondo con raffica 20.
Il tetto per indirizzo è della regia, la quota per chiave è nostra: sono due limiti diversi, contati su cose diverse, e un 429 tetto_indirizzo non consuma la vostra quota (vedi §8).
Quante chiamate passano davvero, misurate. Su un secchio pieno (nessuna chiamata senza chiave nell'ultimo minuto) sono passate 6 chiamate di fila e le altre 7 hanno preso 429: una dentro il ritmo più le 5 della raffica. Non è un numero diverso dalla configurazione, è come la configurazione si vede da fuori; con il secchio già in parte consumato ne passano meno (la regia, dal suo posto, ne ha viste 5). Fonte: F5l, strumenti/collaudo_nome_pubblico.py, 13 chiamate senza Authorization il 26/10/2026.
Due esiti misurati dal nome pubblico che invece sono nostri, perché il percorso /v1/ il proxy lo inoltra: GET /v1/systemone risponde 405 (il verbo giusto è POST), ed è la riga metodo_non_ammesso qui sopra. Sulla lunghezza del corpo i due limiti si sommano nel verso giusto: il proxy taglia oltre 128 KiB, il nostro 413 corpo_troppo_grande scatta prima, a 64 KiB — chi sbaglia la dimensione prende quasi sempre il nostro.
Note sull'ordine, che conta quando più cose sono sbagliate insieme:
- l'indirizzo del chiamante è controllato per primo, prima della chiave e prima della rotta;
- 401 ha la precedenza su 404: se chiedete un compito inesistente senza chiave valida, ricevete 401, non 404;
- 404 viene prima di 403: l'ambito si giudica su un compito che esiste;
- la quota si conta dopo la chiave e dopo l'ambito: un 429 non ruba mai il posto a un 401 o a un 403;
- le risposte 4xx e 5xx chiudono la connessione (
Connection: close).
Ogni chiamata a /v1/systemone finisce nel registro delle decisioni anche quando finisce in errore, con l'esito HTTP. Il registro non contiene mai il vostro testo, né il contesto, né la chiave: solo impronta e lunghezza del testo e il nome della chiave (docs/F2-contratto.md §6).
Due cose che il collaudo di sicurezza di F5g ha cambiato il 26/10/2026, e vi riguardano:
- una chiamata che non si autentica non lascia niente di vostro. Se la chiave manca o è ignota (401), la riga a registro porta solo l'ora, l'esito e
chiave: null:compito,id_chiamante, l'impronta e la lunghezza del testo sono tutti vuoti. Prima no, e un chiamante senza chiave poteva scegliere che cosa restava scritto sul nostro disco. Un 403 fuori ambito invece resta registrato col nome della vostra chiave, apposta (§6 del contratto); - il registro ha un tetto dichiarato di 64 MiB al giorno. Raggiunto il tetto il servizio smette di scrivere righe per quel giorno e conta quante ne ha perse in un file di fianco: le vostre chiamate continuano a essere servite normalmente, ma non le ritrovate nel registro di quella giornata. Il tetto sta più di mille volte sopra il traffico di una giornata vera, quindi non lo vedrete: è lì perché il disco non si riempia.
6. La chiave e il suo ambito
- Una chiave è un segreto opaco di almeno 32 caratteri, presentato come
Authorization: Bearer <chiave>. - Non la conia questo servizio. La genera chi governa l'infrastruttura e la tiene nel proprio caveau; il segreto non passa mai dalla macchina del servizio. Sulla macchina c'è solo un registro di impronte
sha256(servizio/chiavi.json) con nome, ambito e stato di ogni chiamante. Dall'impronta non si risale al segreto: per questo il registro dei chiamanti sta in chiaro nel repository ed è revisionabile come il codice. - Per averla, chiedetela a chi vi ha dato questa guida: servono il nome del chiamante e i compiti su cui volete chiamare.
- Ogni chiave ha un
ambito: l'elenco dei compiti che può chiedere, per esempio["t2","t3"]. Un"*"nell'ambito vale per tutti. Chiamare un compito fuori ambito dà 403fuori_ambito— non 401: la chiave è buona, il permesso no. Il tentativo finisce nel registro col nome della vostra chiave, apposta: un tentativo di uscire dal proprio ambito non deve restare anonimo. GET /v1/compitivi dice l'ambito della chiave che avete usato, nel campoambito.- Il confronto fra impronte è a tempo costante, e il servizio non stampa mai la chiave né un suo pezzo in nessun log.
- Revoca: una chiave si revoca senza fermare il servizio. Da quel momento le sue chiamate prendono
401 chiave_non_valida.
Nel vostro codice la chiave va presa dall'ambiente o da un gestore di segreti, mai scritta nel sorgente. Negli esempi è $AGILEDECIDE_CHIAVE.
7. Esempi curl
export AGILEDECIDE_CHIAVE="…" # dal vostro gestore di segreti, mai nel codice
export AGILEDECIDE_BASE="https://api.agiledecide.agile.software"
export AGILEDECIDE_CASA="http://127.0.0.1:8711" # solo dalla macchina stessa: /healthz
L'elenco dei compiti
curl -sS "$AGILEDECIDE_BASE/v1/compiti" -H "Authorization: Bearer $AGILEDECIDE_CHIAVE"
Una decisione, con contesto (la forma che usa il collaudo in servizio/collaudo.py)
curl -sS -X POST "$AGILEDECIDE_BASE/v1/systemone" \
-H "Authorization: Bearer $AGILEDECIDE_CHIAVE" -H "Content-Type: application/json" \
-d '{"compito":"t3","testo":"misura della porta, testo corto",
"contesto":{"prodotto":"agiletav"},"id_chiamante":"prova-1"}'
Una decisione senza contesto, e la risposta che il servizio dà oggi (vedi §8): esito 200, astensione dichiarata.
curl -sS -X POST "$AGILEDECIDE_BASE/v1/systemone" \
-H "Authorization: Bearer $AGILEDECIDE_CHIAVE" -H "Content-Type: application/json" \
-d '{"compito":"t2","testo":"dove clicco per cambiare la password?"}'
{
"versione_risposta": "1", "id_decisione": "d-0123456789abcdef",
"compito": "t2", "versione_schema": "2.3",
"decisione": "ASTIENI", "astenuto": true,
"confidenza": null, "soglia": null, "livello": null,
"motivo_astensione": "compito_senza_livello_ammesso",
"a_valle": "In entrambi i casi la richiesta prende il percorso di serie …",
"livello2": null, "ms": 0.0142, "impronta_ingresso": "0123456789abcdef"
}
Il corpo di un errore, col codice HTTP; e lo stato del processo, che si legge solo dalla macchina stessa e senza chiave:
curl -sS -w '\nHTTP %{http_code}\n' -X POST "$AGILEDECIDE_BASE/v1/systemone" \
-H "Authorization: Bearer $AGILEDECIDE_CHIAVE" -H "Content-Type: application/json" \
-d '{"compito":"t9","testo":"ciao"}'
# {"errore": "compito_ignoto", "dettaglio": "compito fuori dal registro"} / HTTP 404
curl -sS "$AGILEDECIDE_CASA/healthz"
8. I limiti di oggi, detti chiaramente
Due limiti, non uno. Davanti alla quota per chiave, dal nome pubblico, c'è il tetto per indirizzo del proxy della regia: 10 chiamate al minuto (raffica 5) per chi chiama senza Authorization: Bearer con un segreto di almeno 32 caratteri, e 5 al secondo (raffica 20) su tutte le chiamate; oltre il tetto, 429 tetto_indirizzo con Retry-After: 60 (§5). Quello non è nostro e non lo governiamo: non guarda la vostra chiave, guarda l'indirizzo da cui arrivate, e per questo più vostri processi che escono dallo stesso indirizzo lo consumano insieme. La quota per chiave qui sotto è nostra, ed è cosa diversa: si conta sull'impronta della chiave, e oggi è il solo limite che il servizio tiene da sé.
Quote per chiave. Il meccanismo è nel codice (servizio/porta.py, classe Quote):
- il tetto è per chiave, non globale: il traffico di un altro chiamante non consuma il vostro. Si conta sull'impronta della chiave, mai sul segreto;
- contano entrambe le rotte con chiave,
/v1/systemonee/v1/compiti; - superato il tetto, l'esito è 429
quota_superatae la risposta porta l'intestazioneRetry-Aftercoi secondi da aspettare (sempre almeno 1). Aspettate quei secondi: riprovare subito dà un altro 429; - la finestra è fissa, non scorrevole, e si apre sulla vostra prima chiamata. Questo ha una conseguenza che vi diciamo col numero misurato, perché a leggere «60 chiamate ogni 3600 s» non la indovinereste: nel caso peggiore si possono ottenere fino a 2 × tetto − 1 chiamate servite in un intervallo brevissimo — un pieno a filo dello scatto della finestra e un altro pieno subito dopo. Col tetto di
agile-services(60) fanno 119 chiamate servite dentro un microsecondo, misurate il 26/10/2026 (F5g,docs/F5g-referto.md). Non è un permesso: è il limite del meccanismo, e lo scriviamo perché non vi sorprenda. Per 2 × tetto servirebbe un intervallo di ampiezza esattamente pari alla finestra; - i contatori vivono in memoria, e un riavvio del servizio li azzera: appena riparte, la vostra finestra ricomincia da zero anche se l'avevate appena esaurita. Anche questo ve lo diciamo col numero misurato (26/10/2026, F5h,
strumenti/prova_riavvio_perdite.py): su un gemello con tetto 3 sono state 9 chiamate servite in 0.247 s attraversando 2 riavvii, cioè (riavvii + 1) × tetto. Col tetto 60 diagile-services, due riavvii dentro l'ora fanno fino a 180 chiamate servite nella stessa finestra — e in un riavvio continuo non c'è più nessun tetto, perché ogni ciclo ne regala uno nuovo. Il verso che conta per voi è l'altro: ilRetry-Afterdel 429 (fino a 3600 s, cioè l'intera finestra) non sa del riavvio, quindi vi può chiedere di aspettare un'ora quando il tetto è già libero. Un riavvio del servizio non lo decidete voi e non lo vedete: se un 429 vi blocca, ritentare dopo il buio di un riavvio (~0.5 s di processo, ~2.5 s sotto il supervisore) può bastare, e non è un abuso — è il meccanismo. Renderlo persistente è una riga di lavoro nostra, non ancora fatta; - i contatori non si azzerano quando il servizio rilegge le chiavi (
reload): quello è un caso diverso dal riavvio, e lì il conto resta; - una chiave senza quota dichiarata non ha limite.
I numeri sono per singola chiave e stanno nel registro delle chiavi, campi quota_chiamate e quota_finestra_s: ve li dice chi vi conia la chiave, perché non sono gli stessi per tutti. Oggi nel registro ci sono due voci, e questi sono i loro numeri veri, letti da servizio/chiavi.json il 26/10/2026:
| voce | ambito | quota | stato |
|---|---|---|---|
serverino-regia | t1 t2 t3 t4 | nessuna quota dichiarata → nessun limite | attiva |
agile-services | t1 t2 t3 t4 | 60 chiamate ogni 3600 s (un'ora) | attiva |
La voce agile-services è la chiave di prova, e dal 26/10/2026 è attiva: il segreto l'ha coniato chi tiene il caveau e non è mai passato da questo box, che ha ricevuto solo i 64 esadecimali della sua impronta. La chiave in chiaro la consegna il caveau al cliente, non noi: qui non c'è nessuna copia da farsi dare. Finché una voce resta senza impronta (stato in_attesa_di_impronta, come era questa fino al 26/10) nessuna chiave al mondo autentica su di essa.
Il suo ambito sono tutti e quattro i compiti, e non è una scelta larga: è l'elenco dei compiti che servizio/registro.json dichiara non serviti, cioè quelli in prova. La voce porta per questo il campo classe: prova, e la classe non è un'etichetta: il servizio rifiuta di caricare una voce di classe prova che abbia nel suo ambito un compito passato in esercizio. Il giorno in cui uno dei quattro verrà servito da un livello nostro, questa chiave non lo raggiungerà: il servizio lo dirà invece di lasciarla passare in silenzio. Una voce senza campo classe vale esercizio e non ha questo vincolo.
Perché 60 all'ora: è una dose da cliente che prova. Bastano per provare a mano tutte le rotte e un lotto di una cinquantina di testi in un'ora; e un ciclo impazzito costa al massimo 60 chiamate, contro le migliaia all'ora che la porta regge.
Oggi i quattro compiti rispondono tutti ASTIENI. Non è un guasto, ed è dichiarato: in servizio/registro.json ogni compito ha "servito": false e la risposta porta motivo_astensione: "compito_senza_livello_ammesso". Il motivo è lo stesso per tutti e quattro: lo schema di ciascun compito impone un vincolo di qualità, e nessuna configurazione misurata lo rispetta in modo dimostrato.
| compito | vincolo che non si tiene | come sta oggi (campo perche di servizio/registro.json) |
|---|---|---|
t1 — cancello dei dati personali | richiamo su BLOCCA ≥ 0.98 | tenuto solo da una configurazione ospitata fuori, che per questo compito non si può usare: l'ingresso di t1 è esattamente il testo che non deve uscire. Sui nostri processori il meglio a registro è sicurezza 0.7729 |
t2 — instradamento | precisione su NORMATIVA ≥ 0.90 | gli altri due vincoli sono tenuti, questo no: il punto più alto è 1.000 su 56 decisioni, ma l'intervallo di confidenza si ferma a 0.851 |
t3 — posta in arrivo | richiamo su PRATICA_URGENTE ≥ 0.98, precisione su INDESIDERATA ≥ 0.98, zero errori da NOTIFICA_AUTOMATICA | nessuno dei tre dimostrato; l'ultimo è mancato, non solo non dimostrato |
t4 — prossimo gesto della persona digitale | richiamo su PASSA_A_PERSONA ≥ 0.98 e altri due | nessuno dimostrato; il braccio migliore decide meno dell'8% del traffico |
Cosa vuol dire per voi, in pratica:
- Potete integrare adesso. La forma di richiesta, risposta ed errori è stabile e sotto collaudo; quando un compito si accenderà cambierà il contenuto di
decisione, non la forma. Il ramo che il vostro codice eseguirà oggi è il ramo dell'astensione: il più difficile da scrivere è anche quello che potete provare subito. - Guardate
servitoin/v1/compiti, non questa guida: il registro è il dato vivo. - Prestazioni: su un compito che si astiene sempre, il servizio intero misura p50 1.99 ms (
docs/F2a-referto.md). Per un compito servito non diamo numeri: oggi non ce n'è uno, e un numero non misurato qui non lo scriviamo. - Non c'è un endpoint a lotti: una chiamata, una decisione. E non c'è versione nell'URL oltre a
/v1: il segnale di un cambio di forma sarà il campoversione_risposta.
9. Scostamenti fra contratto e codice
Trovati confrontando ogni ErroreRichiesta di servizio/porta.py con docs/F2-contratto.md. Esiti presenti nel codice che il contratto non nomina (§5 elenca solo 401/401/403/404, §4 il 413, §0 il 403 del chiamante e il 404 di /healthz):
| HTTP | errore | nota |
|---|---|---|
| 400 | lunghezza_non_valida | Content-Length non numerico o negativo |
| 400 | json_malformato | corpo non JSON UTF-8 |
| 404 | percorso_ignoto | il contratto lo nomina solo per /healthz da fuori; nel codice vale per qualunque rotta sconosciuta |
| 405 | metodo_non_ammesso | con intestazione Allow |
| 411 | lunghezza_mancante | il contratto cita il limite di 64 KiB ma non il caso «Content-Length assente» |
| 422 | campo_non_valido | tutta la validazione del corpo; il contratto §4 descrive i campi ma non l'esito |
| 500 | motore_incoerente | cintura su §1: risposta del motore fuori contratto, o testo d'ingresso dentro la risposta |
| 500 | guasto_interno | qualunque eccezione non prevista |
Altri scostamenti, non di esito:
- Il contratto non descrive la forma del corpo d'errore (
{"errore": …, "dettaglio": …}), né le intestazioniWWW-Authenticate: Bearersul 401 eAllowsul 405. Sono nel codice (Gestore._errore,Gestore._smistainservizio/porta.py) e in questa guida. - Chi serve cosa. La tabella §3 del contratto dà
t2servito da L0 a soglia 0.70; il dato vivoservizio/registro.json, più recente, dice"servito": falseper tutti e quattro i compiti. Vale il registro: il contratto stesso dichiara che «il registro dei compiti è un dato, non codice». Questa guida segue il registro. - Motivi di astensione oggi irraggiungibili. Non essendo servito nessun compito, il cancello in
servizio/motore.pyesce subito concompito_senza_livello_ammesso: gli altri sei motivi di §2 restano nel contratto e provati dalle batterie interne, ma non possono arrivare a un chiamante. Sono documentati lo stesso in §4: la vostra integrazione deve reggerli il giorno in cui un compito si accende. - La quota, scostamento CHIUSO (verificato il 26/10/2026, F5h). Questo punto diceva «la quota non è nel contratto: il contratto va aggiornato», e da A847 non è più vero:
docs/F2-contratto.md§5-bis — la QUOTA per chiave nomina il tetto per chiave, il 429quota_superatae l'intestazioneRetry-After(riga 147), e §6-ter mette la quota a contrasto col credito del registro. Resta nel codice solo l'attuazione (servizio/porta.py,Quote) e nei campiquota_chiamate/quota_finestra_sdel registro delle chiavi. Il punto si tiene qui, chiuso e datato, invece di sparire: un elenco di scostamenti da cui le righe svaniscono non si può rileggere.