# 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.1` e
  l'indirizzo della macchina dentro la rete privata WireGuard (`ASCOLTI_AMMESSI` in
  `servizio/porta.py`). Ogni altro indirizzo, `0.0.0.0` compreso, 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 **403 `chiamante_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 `…`):

```json
{
  "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.

1. `decisione` è una classe dello schema **oppure** `ASTIENI` — mai una classe inventata;
2. `astenuto` è coerente con `decisione`;
3. `confidenza` ≥ `soglia` quando non è astenuto (per i livelli che hanno una confidenza);
4. nessuna chiave in più rispetto alla tabella qui sopra;
5. 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_ignoto`** su 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
  `/healthz` **non esiste**.
- **429 `tetto_indirizzo`**, corpo `{"errore": "tetto_indirizzo"}` e intestazione
  `Retry-After: 60`: è il **tetto per indirizzo**, e sta **davanti** alla quota per chiave.
  Sono due misure, entrambe sull'indirizzo di chi chiama: senza l'intestazione
  `Authorization: Bearer` con 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à
  **403 `fuori_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/compiti` vi dice l'ambito della chiave che avete usato, nel campo `ambito`.
- 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`

```bash
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**

```bash
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`)

```bash
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.

```bash
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?"}'
```

```json
{
  "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:

```bash
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/systemone` e `/v1/compiti`;
- superato il tetto, l'esito è **429 `quota_superata`** e la risposta porta l'intestazione
  **`Retry-After`** coi 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 di `agile-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: il
  `Retry-After` del 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 `servito` in `/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 campo `versione_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:**

1. Il contratto non descrive la **forma del corpo d'errore** (`{"errore": …, "dettaglio": …}`),
   né le intestazioni `WWW-Authenticate: Bearer` sul 401 e `Allow` sul 405. Sono nel codice
   (`Gestore._errore`, `Gestore._smista` in `servizio/porta.py`) e in questa guida.
2. **Chi serve cosa.** La tabella §3 del contratto dà `t2` servito da L0 a soglia 0.70; il
   dato vivo `servizio/registro.json`, più recente, dice `"servito": false` per **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.
3. **Motivi di astensione oggi irraggiungibili.** Non essendo servito nessun compito, il
   cancello in `servizio/motore.py` esce subito con `compito_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.
4. **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
   **429** `quota_superata` e l'intestazione `Retry-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 campi `quota_chiamate` / `quota_finestra_s` del
   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.
