Release notes

One entry per date, the most recent at the top. Every entry says what changed, who it may be useful to and how to adopt it. If it requires nothing, it says so. The complete contract is in the API documentation.

Contract version in production: 1.2 (since 29/9/2026). A model name never changes its vectors: if the model changes, the name will change.

2026-10-20 — The keys of our own products now renew by themselves: a 90-day contract, 72 hours in which the old and the new one are both valid, and the new secret goes to the vault — never into a chat. The first real rotation is on 10/12, and today none of your requests change

What changes for you today: nothing, and it is measured. The renewal clock runs every night at 03:30 (Europe/Rome), and last night it said what it will say tomorrow too: 5 active in-house keys examined, 0 to renew, 0 within notice. Contract 1.2 unchanged: no new route, no new field, no new error code, no migration, no key to reissue.

What it is, in one sentence. A key of one of our products has a 90-day contract. When it runs out, the clock gives birth to a new one with the same parameters, writes the new secret into the vault inside the very transaction that creates it, and keeps the old one valid for 72 hours: during those hours both keys work, and none of your requests falls in the handover from one to the other.

THE DATES, measured and not promised, read read-only from our database on 20/10 at 03:46Z. The first real rotation is on 10/12/2026 and concerns nis2 and lg231; on 11/12/2026 it is trpg and aiepod; on 6/1/2027, adp. Notice is 30 days, so from 10/11 the first two appear among those «within notice» — a number that changes by itself, with nobody touching anything. Between now and 10/12 the clock will keep finding 0 keys to renew every night: that is the right behaviour of a clock doing its job, not a stopped clock.

What you really need to know is a question, and the time to ask it is now and not on 10/12: does your service re-read its key from the vault when it starts, or does it keep a copy written by hand? What has been in service since 19/10 is the chain up to the vault: the clock rotates, closes the new secret into the regia's drop box inside the same transaction, and the serverino collects it. The last stretch — from the vault to your product's environment file — is a timer of yours, which the regia opens box by box, and that stretch is exactly the reason why the window is 72 hours and not 24: for a product that re-reads its key only on restart, that stretch may arrive in the evening of the same day, that is twenty hours after 03:30, and with a 24-hour grace the margin on a three-piece chain would have been three and a half hours. With 72 it is two and a half days. If instead the key is pasted inside a configuration file, an image or a hand-written variable, that copy has 72 hours: after those it receives 403 KEY_EXPIRED, which is the same code and the same shape as always. The right moment to remove that copy is not 10/12 at 03:30: it is any day between now and November, with both keys still far from their rotation.

What does NOT change, and every line here is measured and not inferred. The new key inherits owner, company (tenant), scopes, per-minute limit, monthly cap, contract and digital person: same four compute routes, same limits, same codes. The vector cache belongs to the company and not to the key — its cache key is (model, tenant, fingerprint of the text) — so after a rotation no computation is paid for twice. The contract and company caps sum the consumption rows, not the keys: a rotation neither gifts you characters nor takes any away. The key's own cap, instead, is per key, and a new key starts the month at zero: on all five in-house keys that cap is unlimited (measured, 5 out of 5), so for none of them does that difference exist.

The minute, during the 72 hours in which there are two keys. The two keys have two separate per-minute windows (300 requests per minute each, for our products), but the contract's one is a single window and it sums across every key under it: 2940 per minute for the group products' contract, 300 for adp's. So for adp the minute does not widen even with two live keys, because the contract's number is the same as the key's; and for the others the sum of the two stays well below the contract's cap. Nobody has to move traffic by hand, and nobody goes faster for having two live keys.

One thing really does change, and we say it before you discover it from a chart. GET /v1/usage answers per key: after the rotation the new key shows its own consumption, and the history of previous months stays on the old key_id. If you keep a monthly count per key, in the month of the rotation you will see two rows instead of one, and the sum of the two is your real consumption. The contract's cap, on the contrary, keeps summing them by itself: there is nothing to recompose there.

Who does NOT rotate by itself, and why — said instead of letting you find out. The clock looks only at the keys of our own products and services, by a list written in the owner's directive. The other 7 active keys stay by hand, and for one of them the reason speaks for itself: automatically rotating the key with which keys are administered means changing the lock while holding it in your hand. The trial key passepartout has an expiry of its own, 29/10, and it is not in-house: no clock renews it, and on that day it stops answering, as has been written since the day it was born. If you are using a trial key and you need a real one, ask whoever follows you.

No text, and no secret in any journal. Of a renewal we keep who, when and the two fingerprints — the first 12 characters of the sha256 of the old key and of the new one — and never the value of either. The fingerprints serve one purpose only: being able to say «the one in the vault is the key of this row» without the secret passing through a chat, a log or a working file. It is the rule it has always been, and it holds here too: a key's plaintext is shown exactly once.

When: live since 19/10 at 21:59Z, and that instant is the one of the lever that was missing, not of an announcement. Verified on the box in service and read-only, not inferred from our repo: the clock's unit declares from that instant the program that carries the new secret to the vault, and the proof is in the journal of the two runs. The run of 19/10 at 01:30:02Z — before the lever — closed with the line «the program that writes the new secret into the vault is not set: the day a key expires this run will stop without rotating anything»; the run of 20/10 at 01:30:03Z — after the lever — did the same work and that line is gone, because there is nothing left to warn about. Between the two, no key was touched: both say «5 active in-house keys examined, 0 within notice», both closed with exit 0, and the service engine has 0 unrequested restarts. The whole chain — rotate, deliver to the vault, collect — had already been tested end to end on the test machine, on the very bytes of the program that is in service.

How to adopt it: nothing to do today, and no key to reissue. The only thing worth looking at is the question above — whether your service's key comes from the vault or is written by hand somewhere — and it is worth looking now, with fifty-one days of margin, not on the morning of the rotation.

2026-10-17 — The three X-RateLimit-* headers now arrive on errors too, the ones raised after authentication: 413 BATCH_TOO_LARGE, 413 TEXT_TOO_LONG, 400 EMPTY_INPUT, 429 QUOTA_EXCEEDED, on all four compute routes. Nothing to do, and no key to reissue

What changes for you: if you throttle yourself by reading those headers, the numbers are now true even when the request was refused. Until today the three headers disappeared on those errors, and the slot in the minute had already been consumed: whoever reads X-RateLimit-Remaining to pace their traffic was holding a number higher than the truth by as many as the errors had been. Contract 1.2 unchanged: no new route, no new field, no new error code, no migration.

The damage in figures, because this is not a matter of form. Seven requests that end in an error consume seven slots in the minute. Before, the client saw zero of them consumed: it believed it had 59 slots left while it had 51, and the brake we sell you — "so your log stops seeing rate errors" — fired late, taking the 429 it was meant to spare you. Now it sees seven out of seven: the count it holds and the service's count agree.

Where the headers stay absent, and it is deliberate: key errors (401, 403), the 422 of an invalid body and GET /v1/usage. This is not an oversight. In those cases the slot in the minute was not consumed — the 422 even arrives before authentication — and checking your own consumption stays free. The API pages say so among the declared exceptions, in both languages.

What does NOT change: the contract stays 1.2, no price, no tighter limit for anyone, no key to reissue. 200 responses already carried the three headers and carry them identically, and 429 RATE_LIMIT keeps carrying its Retry-After. No additional text is read or recorded: of a refusal we keep a label and an instant.

When: live since 17/10 at 00:00Z, with the engine release. Verified on the engine in service, not deduced from our repo: the app.py that sits in /opt/agilev, copied read-only and given as the subject to this fix's own checks, passes all 23 judgements (the engine and the three documents that publish the promise), 0 KO, and the previous source fails 11 of them; the engine process was born at 00:00:03Z, that is AFTER that file was written (00:00:02Z), so it is that code answering you. It is not a GET: these headers live behind authentication, and testing them with a customer's key would leave them a refusal and billable characters for a request they never made. And from the public edge, with a GET and no key, /health declares the engine up since 17/10 at 00:00Z, with contract 1.2 and environment produzione.

How to adopt it: nothing to do. If your client keeps the last headers it read in order to throttle itself — ours does — there is nothing to touch: it receives true numbers in more cases than before.

And from the same day, on the same 422, the second thing: it now carries an error.code, and it is BODY_INVALID. The 422 is the answer to a body we cannot read, and it was the last response that could reach you without an error.code: whoever reads our errors by code kept a «do not know» branch open for that one alone. The count of responses that can reach you without an error.code goes from ONE to NONE. It was the last one open, and this closes it.

And it no longer hands your texts back to you in the error body. Measured from the public edge, with no key, before and after the release: before, the 422 body was 123 bytes and carried the echo of what the probe had sent; after, it is 402 bytes, it carries BODY_INVALID, and of echo it carries ZERO. An error body that hands you back what you sent us is a text of yours crossing the network again, and it can end up in your own log: as of this morning that does not happen any more.

When: live since 17/10 at 03:37Z, with engine release 9fdfaf6 and with 0 unrequested restarts — and that instant is the engine's, not our pages'. The public pages carrying the new count (api and errori-dal-client, in both languages) have been at the edge since 09:42:59Z on the same day, through a site copy with no engine restart: measured right after, all four say zero open shapes.

How to adopt this one too: nothing to do, and no key to reissue. Whoever kept a «do not know» branch in their own error reader for the responses without an error.code can now remove the last case: the code is there on all of them.

2026-10-12 — A third cap on characters per month, the company's: it sums all the keys of the same customer, including those outside any contract. No company has one today, so none of your requests change

What changes for you today: nothing, and it is measured. A company cap exists only if we write one, and right now no company has one: 0 out of 3 (measured read-only on a copy of our database, counts only, no text and no secret). No new code, no new field, no new route, no key to reissue: contract 1.2 unchanged.

What it is, in one sentence. Until today the caps on characters per month were two: the key's and the licence's (the contract), which is consumed together by all the keys born under it. Now there is a third one, the company's, and the difference is which consumption it sums: all the keys of that customer, including those that have no contract and those you have rotated or that we revoked after they had already consumed. When a cap exists and fills up, the answer is the 429 QUOTA_EXCEEDED you already know, with the same Retry-After — the seconds to the first instant of the new month in UTC.

The thing you actually need to know: the message says WHICH of the three caps bit. "quota mensile di caratteri esaurita" is your key, "quota mensile della licenza esaurita" is the contract, "quota mensile del tenant esaurita" is the company. Without that word whoever receives the 429 raises the wrong number and the refusal stays identical: that is the only reason the three sentences are different. The tightest cap is named first, so if you exceed your own you hear about your own.

Where the company's numbers can be read, and where not, said instead of leaving you to find out: the totals of all the keys of a customer are visible to whoever administers that customer, in GET /admin/usage. We do not put them in GET /v1/usage, and the reason is not laziness: that aggregate crosses the digital personas of the same company, and a persona's key reading everyone else's consumption would be exactly the boundary we are closing. Your key keeps seeing only its own consumption and its own contract.

Why we did it, with the real damage and not in the abstract. With two caps a company had no number measuring it whole: two contracts under the same customer are two separate caps, and a key without a contract — which with us is born as a service key — walked past the contract cap by construction, because that cap sums "the keys naming this licence". A cap you get around by issuing one more key is not a cap. The new cap follows the tenant, that is the company label every key carries from birth, and for the same reason we read the sum from the consumption row and not from the key: if we read it from the key, deleting one would zero the characters already consumed.

What does NOT change: the contract stays 1.2, no price, no tighter limit for anyone, no mandatory field, no migration, no key to reissue. The 429 QUOTA_EXCEEDED is the same code as before — a new code would have broken the clients that already handle it — and GET /v1/usage answers with the same fields. No extra text is read or recorded: of a refusal there remain a label and an instant.

When: live since 12/10 at 22:20Z, with the engine release. Verified on the live engine and on our database read-only, not inferred from our repo: the live app.py, given to the cap tests as the subject, passes all 22 of them, and the previous source fails 16 — and the engine process was born AFTER that file was copied, so it is that code answering you. It is not a GET: the caps sit behind authentication, and testing them with a customer's key would leave them a rejection and billed characters for a request they did not ask for. The proof that the new engine is really running was left by it in our database: the company-cap table is there, and the previous code does not create it on a fresh database; read read-only it has zero rows, that is no cap, that is none of your answers changed. And from the public edge, with a key-less GET, /health declares the engine up since this instant, with contract 1.2 and the produzione environment.

How to adopt it: nothing to do. If one day your contract has a company cap, you will know it beforehand from the person who looks after you, not from a 429.

2026-10-12 — The tenant field in the body: on the four compute routes the route now declares and verifies it. Silent body, the tenant is your key's; a body naming yours, it passes; a body naming someone else's, 403 TENANT_UNKNOWN. Optional for everyone, and no key to reissue

What can break you comes before anything else: a response that used to be 200 becomes 403 only for whoever writes in the body a tenant that is not theirs. Whoever does not send the field — that is, every client of today — sees no difference: same vectors, same cache (no computation to pay for again), same invoice, same codes. Whoever sends it with their own name sees exactly one thing change: the field no longer comes back among the ignored_fields, because it is now a declaration we verify instead of a word we throw away. The 403 only reaches whoever names a tenant that is not their key's, and it is counted among your rejections in GET /v1/usage (the rejected field), like all the others.

What changes for you: a declaration the service used to accept and never look at, it now looks at. On the four compute routes (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) the tenant field is optional and behaves like this: silent body, the tenant is your key's and the engine fills it in — character for character what has always happened; a body naming yours, 200 as before; a body naming someone else's, 403 TENANT_UNKNOWN. «Unknown» and not «it is not yours» for one reason only: telling you that another customer exists would already be data of theirs, the same reason why between different customers we answer 404 and not 403. It is additive: contract 1.2 unchanged, no new route, no migration, no key to reissue.

Why we did it, told with the real damage and not in the abstract. A field we accept and do not look at is worse than a field that does not exist. Until yesterday {"tenant":"another-customer"} got a 200, computed and billed in your tenant — the key's, the only one the engine has ever used — and the name written in the body came back to you among the ignored_fields. Whoever wrote that field out of caution, or copied it from our administration routes (where tenant already existed and means something else), was reading an accepted declaration and believed they had a boundary that existed nowhere. An illusion of isolation is the opposite of isolation: now the name in the body and the name on the key must match, and the one that chooses the tenant is still the key — the body never decides it.

What does NOT change: the contract stays 1.2, and the public edge says so, not us — re-read at 16:53Z, together with the produzione environment. No new route, no price, no tighter limit, no key to reissue, no field mandatory for anyone. The tenant of the administration routes (/admin/*) is not this one and works as before. And no additional text is read or recorded: of a rejection there remain a label and an instant, never the request that took it.

The second act, declared now and not when it arrives: the tenant field mandatory for all keys is something we will tell you beforehand, with the date. Today the field is mandatory for nobody, and it will not become so without that announcement: it is the other half of the same rule as persona, and the two halves arrive in two acts for the same reason — a boundary that is born as a surprise rejection is a break of the contract, not one bit more isolation.

When: live since 12/10 at 18:26Z, with the engine release. Verified on the live engine and from the public edge, not inferred from our repo: the live app.py, read read-only, carries the guard comparing the body's tenant with the key's, and the engine process was born AFTER that file was copied — it is not a GET, because auth() comes before the guard and testing it with a customer's key would leave them a row among their rejections for a request they did not ask for; and from the public edge, with a key-less GET, /health declares the engine up since this instant, with contract 1.2 and the produzione environment.

The measurements, and what we did NOT measure. The contract of this field is the same in the document and in the page that carry it: the tables paired row by row give 10 tables in common, 54 rows compared, 0 diverging — and among those 54 there is the new row of the 403 TENANT_UNKNOWN. From the public edge, with a key-less GET — which consumes neither quota nor per-minute requests —, at 16:53Z the contract reads 1.2. One thing we did not measure from the edge, and we prefer writing it down to counting it among the green ones: the 403 TENANT_UNKNOWN from the public edge would want a customer's key, and a request that customer did not ask for would leave them a row among their rejections — the rejected of GET /v1/usage is theirs, not ours. Your texts we do not read and do not record, not even to measure: the scenes run on fake models and throw-away databases, and only counts came out of production. How to adopt it: nothing to do — and if you do want to write the field, write your own name and nothing else.

2026-10-12 — If your key belongs to a persona, you no longer have to send the persona field: a silent body takes the key's persona. Nobody emits 400 PERSONA_MISSING any more

What changes for you: a response that used to be 400 becomes 200, and nothing that was 200 changes. The 11/10 entry — the one right below, which stays where it is — told you that a key issued for a persona requires the persona field in the body, and that without it the answer is 400 PERSONA_MISSING. That sentence is superseded as of today: if the body is silent, the engine fills the persona in, and it is the one written on the key that opened the door — never another one. On the four compute routes (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) there is nothing to adopt, and that is not a figure of speech: 0 keys out of 18 belong to a persona today (measured read-only, counts only), so none of your requests changes answer.

What has NOT changed comes before anything else, because it is the only thing that can break you. 403 PERSONA_UNKNOWN is still there: a body naming a persona different from the key's stays 403, because who you compute on behalf of is not your choice — the key imposes it. And persona remains the only exception to our promise that «parameters we do not know we ignore»: if your key belongs to no persona and you send the field anyway, the answer is 403, not a 200 with an ignored field. If you added the field to your client after the 11/10 entry, you can remove it: with a persona key a silent body is now enough.

We are NOT deleting the 400 PERSONA_MISSING row from the error table. For one day it was a real code, and someone may have written it into their client: the row says that nobody emits it any more and that it will come back with the second act, when the field becomes mandatory for everyone. Deleting it would erase the question of whoever finds it in their own code.

Why we did it, told with the real damage and not in the abstract. The rule says «persona mandatory and imposed». Applying it to the letter today would have been a break of the public contract: the keys that actually compute would have taken a 400 on their first try, and the largest of them makes more than twelve thousand calls a month. So the boundary arrives in two acts: now the persona is imposed where there is one (and it does not widen by a millimetre — no default falls onto another persona), and the field mandatory for everyone will have a date announced in advance instead of being discovered through a rejection.

The second act, declared now and not when it lands: the persona field mandatory for all keys is something we will tell you about beforehand, with the date. The keys that make several digital personas speak — there is one, and it is the heaviest consumer — stay without a bound persona until that day: binding them to a single persona would be a false persona, the opposite of the isolation we are building.

And one thing from our side, which the contract does not show and we are telling you anyway. The database holding your keys and your counters — never your texts, which we do not store — could be recreated empty by one of our maintenance programs, the one that every five minutes checks whether a licence request needs forwarding. Today it happened: at 10:31:58Z a command from one of our test harnesses deleted that file, and five minutes later that program rebuilt it empty, exiting without an error — no failed unit, no alarm, and an empty database is indistinguishable from a healthy one from the outside. The service was restored from the 10:22:30Z copy: all 18 keys are there and no text of anyone's is involved; what was lost is at most one usage row, the one for the request at 10:31:20Z. If your October counts and ours differ by one request, that is the one. As of today that program, if the database is not there, stops and says so — with the path it was given and the resolved one — instead of manufacturing one: the unit fails, which means somebody notices.

When: live since 12/10 at 15:23Z, with the engine release — and that instant the engine says about itself (motore.da of GET /health), we do not infer it from the time we copied a file; re-read from the public edge at 15:3xZ together with contract 1.2, which has not changed. No new route, no price, no tighter limit.

The measurements, and what we could not measure. Both things in this entry are proved on the deployed engine and not on our repository: the imposed persona with 4 cases out of 4 on a copy of the file in service (silent body 200 with the key's persona, another persona 403, key without a persona 200), and the database that is not rebuilt by actually running the program against a non-existent path — it exits with an error and afterwards there is nothing on disk. One thing we could not measure, and we prefer to say so: the behaviour of the unit with the database missing would require taking the real database out of the way, and that we do not do — we proved the program the unit runs, and read its directives. How to adopt it: nothing to do.

2026-10-12 — Whoever administers one contract cannot read anyone else's not even on a database where one of our migrations did not go through: the tenant filter no longer empties out. There is nothing to adopt, and nobody was exposed

What changes for you: nothing to adopt. The four compute endpoints (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) are untouched: no new field, no new error code, contract 1.2 unchanged, no database migration. One thing that concerns you does change, and it does not go through you: the rule that keeps your licences, your operations log and your invoice unreadable to whoever administers another contract is now written down, and no longer resting on two defaults of ours that cancelled each other out.

Nobody was exposed, and that is not reassurance: it is a measurement. The condition that opened the hole was a database of ours in which the tenant column on the keys table had not been added. In the running service that column is there — read read-only, counts only — and in that state a second default took every administrator to our internal tenant anyway, which means the isolation stood by coincidence between two defaults. No data of any customer was readable by another customer, at any time. We are telling you anyway, because an isolation that holds by coincidence is not an isolation: the day one of the two defaults changes — and they do change, it is our job — the boundary disappears with no error to say so.

The defect, said for what it was. The function that adds «and only of your tenant» to the queries decided whether to do so by looking at the migration flag of another table: the keys one. But the queries it protects run on three different tables — licences, log, usage — and the first two carry the tenant column in their own schema, that is, they have it on *every* database. With that flag down the filter disappears from all three at once. Now the column is declared by whoever runs the query, the only one who knows which table it is running on, and where the column is missing the result is no longer «no filter»: it is empty for anyone who is not our internal tenant. A boundary that closes when it does not know, instead of opening.

What does NOT change: the contract stays 1.2, and it is the public edge that says so, not us — re-read at 09:31Z. No new endpoint, no price, no tighter limit, no key to reissue, no new field. And no extra text is read or stored: this fix touches a WHERE condition, not what you send us.

When: live since 12/10 at 09:22Z, with the engine release — and that instant is the engine speaking about itself (motore.da in GET /health), not deduced from the time we copied a file; the motore.pid answering from the public edge is the same process the box sees running, and the deployed file's sha256 is identical to the released source, so it is that code answering you. The release acceptance: 25 checks, 0 KO, no extra restart, 0 warnings in the engine's journal.

The measurements: 7 new tests (tests/test_filtro_del_tenant_che_si_svuota.py), 6 of them red against the previous sources — the list of licences, the single licence, the revocation of someone else's licence, the log and the invoice: that is the «before» measured, not recounted. The seventh watches the opposite direction, where the column really is missing and our internal administrator must keep seeing, because a matter of ours does not cost you the answer. bench/mutazioni-il-filtro-che-si-svuota.sh: 12 faults out of 12 seen, 0 blind, with the control group at 133 passing before the run — and four of the twelve are the fix that is too wide, that is the filter put where it does not belong, which would take our own view of our own things away without giving you one gram more isolation. Full suite on the released tip: 2863 passed, 0 failed, 3 skipped. We do not read and do not store your texts, not even to measure: the scenes run on throwaway databases and only counters ever left production.

2026-10-11 — A key can belong to a persona, and then every request says on whose behalf it computes: the persona field, 400 PERSONA_MISSING and 403 PERSONA_UNKNOWN. Your keys today need to do nothing

What changes for you: nothing to adopt, and that is not a figure of speech. From today a key can be issued for a digital persona, and then the four compute endpoints (/v1/embeddings, /embed, /v1/rerank, /v1/tokenize) require the persona field in the body: without it, 400 PERSONA_MISSING; with a name other than its own, 403 PERSONA_UNKNOWN. No key that exists today belongs to a persona: the column was born empty at restart and, read read-only on the running service, says 0 keys out of 17 with a persona — none active, none revoked. Your body, your vectors, your cache and your invoice stay exactly as they were yesterday, on all four endpoints.

The only thing that can break for you today is trying the new field, so we say it before anything else. The contract has always promised you that parameters we do not know are ignored: persona is the one exception, and it has to be. If your key belongs to no persona and you send the field anyway, the answer is 403 PERSONA_UNKNOWN, not a 200 with an ignored field — because computing, caching and billing on behalf of a subject nobody declared is precisely what this rule exists to prevent. Until you ask us for a persona key, do not send the field: there is nothing to try, and trying it is the only way to get an error that did not exist before.

Why we did it, told through the real damage and not in the abstract. In the service the persona did not exist: zero columns, zero fields, and in place of a boundary there was a convention written in the code — «a single person, for AgileV, is a tenant with one key». A convention partitions nothing, and the vector cache is the one place where one caller's content meets someone else's: two personas of the same tenant were serving each other's vectors, which is one's state visible to the other. From now on the cache is cut by tenant + persona, and the boundary is carried by issuance and — where it would have vanished unnoticed — by rotation: a rotated key still belongs to the same persona, through both doors we rotate it from.

What does NOT change: the contract stays 1.2, and it is the public edge that says so, not us — re-read at 15:43:26Z. No new endpoint, no price, no tighter limit, no mandatory field for anyone without a persona. And no extra text is read or stored: of the persona there remains a label of 1-32 characters among letters, digits, _, . and - — never a natural person's name, never an e-mail — and not even that leaves the service.

When: live since 11/10 at 15:29Z, with the engine release — and that instant is the engine speaking about itself (motore.da in GET /health), not deduced from the time we copied a file; re-read from the public edge at 15:43:26Z, together with contract 1.2. One thing we cannot show you from the public edge, and we write it down instead of counting it among the green ones: the 400 PERSONA_MISSING, because out there no persona key exists yet to ask it of. We proved that refusal on the deployed file and not on our repo — /opt/agilev/app.py copied read-only and handed to the tests as the subject: 38 tests passed, 0 failed — and that file's sha256 is identical to the released source's, so it is that code answering you.

The measurements: bench/mutazioni-la-persona-che-manca.sh, 16 faults out of 16 seen, 0 blind, with the control group at 155 passing before the run — and the two halves are watched together, because a fix that is too wide (asking the field of people who have no persona) would break all of you while protecting no one. In the suite that runs at every step there are 38 tests (tests/test_persona.py): the missing field, someone else's name, the field sent by a key without a persona, the cache that must not pass a vector along, issuance and rotation that must not lose the boundary, and the half that must not change. The contract is the same in three documents and two languages: the tables paired row by row give 53 rows compared, 0 divergent. Full suite on the tip that carries the code answering you: 2819 passed, 4 skipped — and the one red of that run was not the service but a sheet of ours that was missing the very line with these numbers, fixed before publishing this entry. We do not read and do not store your texts, not even to measure: the scenes use fake models and throwaway databases, and only counters ever left production.

2026-10-11 — A key issued for you is now born yours: without the tenant field, POST /admin/keys and POST /admin/licenze answer 400 TENANT_MISSING instead of creating the thing inside our own tenant

What changes for you: nothing to adopt — and something that concerns you all the same. The two routes that create a key or a licence are not yours: they sit on a private network, outside the public edge, and only whoever administers the service calls them. Until yesterday, if the issuer forgot to write whose the thing being created was, that thing was born inside our internal tenant. A key issued for you and landing there would have been a key whose consumption is not yours: you would not see it in GET /v1/usage, it would not be on your invoice, and your quota would not stop it. From now on that forgotten field is a refusal — 400 TENANT_MISSING — and the thing is not created at all.

Who was exposed, measured before the change and not deduced after it: nobody. The keys carrying the scope that opens those two routes, in service, are zero — neither active nor revoked, out of 17 keys in total; the issuance ledger has 15 rows and none of them arrived from there; and the 2 recorded issuances carry the tenant written out, so with the new code they would have passed unchanged. No customer's key was ever born in the wrong place. We are telling you anyway, because the damage would have been silent: a key that works perfectly and bills the wrong party raises no error for anyone, and isolation between tenants is something we promise you, not a convenience of ours.

If you administer one tenant, your own, nothing changes: the missing field means yours, the only one you could write. Asking you to repeat every time the only admissible answer would break the contract without a gram of extra isolation. The refusal only concerns those who can issue on behalf of others.

What does NOT change: the contract stays 1.2; no public route, no field, no new error code on the edge, no price, no tighter limit. The two routes touched are not on the public edge, and that is not a promise: at 11:38Z a GET from the internet on /admin/keys and on /admin/licenze receives our 404 NOT_FOUND of 263 bytes, the same as a made-up address. The four compute routes — the ones you actually call — do not change by a byte. And no extra text is read or recorded: of the refusal only a label and an instant remain.

When: live since 11/10 at 11:21Z, with the engine release — and that instant is what the engine says about itself (motore.da of GET /health), we do not deduce it from the time we copied a file; re-read from the public edge at 11:38:09Z, together with contract 1.2. One thing we cannot show you from the public edge, and we write it down instead of counting it among the green ones: the 400 itself, because from outside those routes do not exist and there is not yet a single key in the world carrying the scope that opens them. We proved the refusal on the deployed file, not on our repository: the file of the engine in service, copied read-only and given to the tests as their subject — 18 tests passed, 0 failed — and the process in service was born 0.522 seconds after that file, so it is that file it is executing.

The measurements: the mutation bench, 8 faults out of 8 seen, 0 blind, with the control group at 96 green before the run — and one of the eight is the cure that is too wide rather than too narrow, that is, the case where we would have demanded the field even from someone who has a single tenant. The bench that runs at every step holds 18 tests: the refusal on keys, the refusal on licences, the row in the ledger with the right action and inside the tenant of whoever tried, and the half that must not change. The whole bench on the released tip: 2784 passed, 0 failed. We neither read nor record your texts, not even to measure: the scenes use fake models and throwaway databases, and only counters came out of production.

2026-10-10 — GET /v1/usage now tells you when we last answered you and how many times we turned you away: two new fields, last_at and rejected

What changes for you: the only window we give you onto your own usage said how much and never when. In the current month a customer stopped since the 1st and one who called us a minute ago were the same row. From now on GET /v1/usage carries two more fields: last_at, the instant of the key's last successful call on any operation (epoch UTC seconds, 0 = it has never made one), present on every row of usage too, month by month and per operation; and rejected, one row per month, operation and code with how many times we turned you away and when the last one was — the 403s (KEY_REVOKED, KEY_EXPIRED, SCOPE_NOT_ALLOWED, and the contract codes) and the 429s (RATE_LIMIT, QUOTA_EXCEEDED). An empty list is the normal answer. All free like the rest of the route: it consumes no quota, no per-minute slot, and can be read by an automated check. The full contract for the two fields is in the API documentation.

What they are for, told with the real case and not in the abstract: the month's numbers do not say whether an integration has stopped, because a row that does not grow is identical to a customer who had no work that month. And a deployment that starts at night with the wrong key, or with an expired key, sees its 403s at the instant it takes them and then nothing: your client has lost them, and until today we wrote them nowhere — they lived in RAM, aggregated per route, zeroed at every restart of the service (in October the restart of the 5th was the third of the month). Now the two halves of the same question are read together: a last_at older than it should be plus a growing rejected row is your configuration and not our service; an old last_at with rejected standing still means nothing is leaving your side.

Four limits, said before you count on them. (1) The 401s are not there, and never will be: KEY_MISSING and KEY_INVALID come from a key that is not in our archive, so there is no key to charge them to — and the fingerprint of a secret that is not ours, belonging to someone who is not our customer, is not data we want to keep. A key that is wrong by one character leaves no trace here: that one is visible only on your side. (2) The numbers start today and are not retroactive: months already written have last_at at 0 until the first successful call arrives, and refusals from before the release do not exist for anyone. (3) On a revoked or expired key this route answers 403 like all the others, so the key you would want to investigate is precisely the one that no longer opens for you — and refusals are per key: with your own key you can read the 429s and the 403 SCOPE_NOT_ALLOWED, because there the key stays good; for the others, ask us. (4) The lists are cut: last 24 months for usage, 48 most recent rows for rejected, most recent month first. If you rotate keys, the new key starts from last_at at 0 and the old one stays frozen at its last instant: that is the right behaviour, but do not add them up by hand believing you are reading the contract.

What does NOT change: the contract stays 1.2; no route, no required field, no new error code, no price and no tighter limit. The two fields are additive and sit in a response you already read today: whoever does not read them notices nothing, and no request that works today changes by one byte. No extra text is read or recorded: the two new columns carry only labels (month, operation, code) and numbers — of the why of a refusal we do not write the request that took it, and we will not.

When: live since 10/10 at 10:23Z, with the engine release — and that instant is what the engine says about itself (motore.da of GET /health), not something we deduce from the hour we copied a file. Verified from the public edge and not from our repository, at 10:45:19Z: a GET on https://api.agilev.agile.software/v1/usage with one of our monitoring keys answers 200 and the body carries last_at next to rejected, with the list empty — which is the normal answer. And the field is already being filled by real traffic: at 10:45:10Z a usage row carried its own instant, 1,279 seconds after the birth of the process in service, while the rows written before the release stay at 0 — limit (2) measured, not promised. Limit (1) is measured from the same edge too: a fake key takes 401 KEY_INVALID and leaves no rejection row. One thing we cannot show you from outside, and we write it down instead of counting it among the green ones: the last_at on every row of usage, because our monitoring key computes nothing and therefore has no rows — that piece is proven by the bench, which builds the rows itself.

The measurements: twelve judgements on the result — they look for a row that names the key and carries an instant inside the window of the fact, from whichever table it comes — from 2 ok / 10 KO to 12 ok / 0 KO; and two of those twelve test the bench's own instrument, because the other ten all fell together and a blind bench would say the same thing. In the bench that runs at every step there are 19 tests, and the last family keeps the two fields in the contract in all three documents and in both languages, with the names taken from the response and not typed by hand. The mutation bench: 12 faults out of 12 seen, 0 blind, with the control group at 0 KO before the round — and one of the twelve found a defect of the fix that our own judgement had missed: the new table was created without the prudence of the other two migrations, and in a place that refuses writes it would have been every authenticated route at 500 for a table that serves us. And after the release, a bench taken from the public edge: 8 ok / 0 KO out of 8 judgements on the service in operation, plus 1 judgement marked "not measured" — the one about the rows — marked that way on purpose, because a check over an empty list always succeeds, and counting it among the green ones would mean declaring a test that looked at nothing. We do not read your texts and we do not record them, not even to measure: the scenes use fake models and throwaway databases, and only counts came out of production.

2026-10-10 — The query of /v1/rerank was the only field without the ceiling we publish to you, and it is the one we charge once per document: from now on it has the same 32,000 characters as a document

What changes for you: on /v1/rerank the count that ends up in your bill is len(query) × number of documents + sum of the documents. The multiplication is right — the cross-encoder reads the (query, document) pair once per document — but the query was the only field of the contract without the ceiling the Limits table publishes for a text: 32,000 characters, 413 TEXT_TOO_LONG. The field without a ceiling was exactly the one being multiplied, so the maximum a rerank request could cost you did not exist. From now on the query has the same ceiling as a document, and that maximum is 8,192,000 characters: twice the 4,096,000 you compute from the first two rows of the Limits (its own for the documents, and one for the repeated query).

The numbers, measured and not estimated: a query of 200,000 characters with 128 documents passed with 200 and wrote 25,601,280 characters in the usage row, in a request of 202 KB. The largest body the edge accepts (8,000,000 bytes) wrote 1,023,750,400, i.e. 249.94 times the maximum computable from the page. And it cost time to whoever was queueing: the query is tokenized once per document inside the lock that serializes the model, and that request held it for ~404 seconds — six and a half times the minute the per-minute limit is tuned on, which was the only brake left.

None of you ever got there, and we know it from the counters: the heaviest user of rerank sends 14.68 documents per request and a query under one hundred characters, derived from its own columns. This is not a door someone forced: it is the normal case of anyone doing RAG — the «query» of a conversation is often the rewritten question plus the history — and with the 30-50 candidates we ourselves recommend every character of the query is paid 30-50 times, without it being written anywhere. There is nothing to adopt if your query is under 32,000 characters, and no request that works today will stop working.

If instead you exceed it: you get 413 TEXT_TOO_LONG, and the message says which of the two fields went over (query or documents) — on this route that code has two origins, and an error that does not say which leaves you only the option of guessing. The ceiling comes before the bill: a rejected request consumes no quota and leaves no row in your usage.

What does NOT change: the contract stays 1.2; no route, no field, no new code (TEXT_TOO_LONG was already there), no price and no limit tighter than the ones the Limits table already published — the query now respects them, before it was the unwritten exception.

When: live since 10/10 at 06:28Z, with the engine release. Verified on the live engine and from the public edge, not inferred from our repo: the live app.py, read read-only, carries the ceiling on query and the engine process was born AFTER that file was copied — it is not a GET, because auth() comes before the ceiling and testing it with a customer's key would write them an invoice line; and from the public edge, with one of our service keys, a 40,000-character query gets 413 TEXT_TOO_LONG with the query field named in the message.

The measurements: six scenes in a cage, from 14 ok / 7 KO to 22 ok / 0 KO. The bench does not trust its own memory of the limits: it reads them from the service code and the promise about top_n from the paragraph of the documentation, so it demands what we wrote to you and not what it believes. The count is not deduced from a formula: the formula is verified on three measured requests before being used to compute the maximum, and if it does not predict them the bench stops instead of giving a verdict on a count it has not understood. One scene reads our database read-only to count how many keys with rerank had a monthly quota that could stop that request: zero out of nine. One measures the time with the real tokenizer of the reranker. And a control that must stay green before and after: the example published on the API page costs exactly what the formula predicts — a cure that had changed the price of the normal case would have turned it red, and that would have been something to tell you. Plus 9 tests (3 red on the previous engine) and 6 faults out of 6 seen by the mutation bench, 0 blind, with the control group at 0 KO before the round. We do not read your texts and we do not record them, not even to measure: the scenes use fake models and throwaway databases, and only counts came out of production.

2026-10-09 — Three addresses that were never ours — /docs, /openapi.json, /redoc — stop answering in place of the contract: from now on they give our 404

What changes for you: on api.agilev.agile.software three addresses answered 200 without us ever having declared them in any of the five pages where we give you the contract — /docs (930 bytes), /redoc (890) and /openapi.json (32,224). We do not write them: the framework the engine runs on builds them by itself. From now on they get the same answer as a made-up address: our 404 with {"error":{"code":"NOT_FOUND",…}}, 263 bytes, application/json.

Who read them, measured and not estimated: the edge journal, read read-only, carries 16 requests to those three addresses between 11/9 and 9/10, from 4 distinct callers. Three are clients with a key and over the whole period asked for one or two of them, once each (out of 24,578, 904 and 305 requests respectively: nobody is building anything on top of them); the fourth had no key and on 2/10 asked for 6 in nine seconds, together with 85 other made-up paths. If, however, you were generating your client automatically from /openapi.json, write to us: we will give you a schema of our own, maintained and versioned with the contract. Today we do not have one, and in 28 days nobody has asked — which is why we are not publishing it «just in case» before it is of use to anyone.

Why we are closing them, two reasons and neither is «tidying up». The first: those pages are a second source of the contract that we do not maintain. They are machine-generated from the code, they do not say the things that matter to you — the limits, the error codes, what consumes quota — and the day they diverge from the API documentation, two sources that contradict each other are worse than one. The second concerns our own trade: /docs and /redoc sent the browser of whoever is evaluating us to three third-party hosts (cdn.jsdelivr.net, fastapi.tiangolo.com, fonts.googleapis.com) to fetch their JavaScript and their fonts. On a service we sell you as sovereign — open models, data in the EU, your texts that never leave — the first page a client opens must not ask anything of an outside supplier. Our pages name no host that is not ours, and that now holds for the whole edge.

What does NOT change: no route of ours, no field, no limit, no key, no price; the contract stays 1.2. Not a line of the engine changes and the service does not restart: it is one line of our edge file and a caddy reload. The contract you need is where it has always been: the API documentation, the error table and the examples.

When: live since 10/10 at 06:18Z, with the edge, site and examples release. Verified from the outside, not inferred from our repo: a key-less GET on the API edge gets, on /docs, /openapi.json and /redoc, our 404 with NOT_FOUND, 263 bytes each — the same as a made-up address — instead of the 200 with the framework pages.

The measures: bench/misura-la-vetrina-del-bordo.py — seven scenes in a cage on 127.0.0.1 only, with a fake engine declaring the same routes as ours and an edge copied verbatim from the real file: from 3 ok / 4 KO to 7 ok / 0 KO. It does not know by heart which the three framework pages are: it asks the framework itself which addresses it builds, so the day it adds a fourth the scene sees it. The control group is in the same cage: /embed reaches the engine with 200, and a made-up address gets the same 263-byte 404 — that is, the cure closed nothing you need. A seventh scene measures the cost of this entry: none of the five documents with which we give you the contract names the three addresses, so removing them contradicts nothing we promised you.

2026-10-09 — The /v1/rerank turn is now handed over on the requests you actually send, not only on those of 33 documents or more

What changes for you: nothing in the contract — it stays 1.2, no route, no field, no new limit — and one thing in the waiting times of whoever is in line. On 9/10 at 08:49:03Z we put the model turn into service, and on this page we also wrote you how far it went: «the turn is handed over between one pass and the next, and a /v1/rerank up to 16 documents is a single pass». That line was the measure of a defect, not a nuance: whoever uses rerank sends on average 14.68 documents per request, that is below that threshold, and on those requests the turn was never handed over and whoever arrived behind waited for the whole request.

What we did: rerank now has a cap of its own, 4 pairs per pass. It is the largest one that splits the average request into at least three passes, and we do not go down to one because every call to the model has a fixed cost that whoever is alone would pay too. There is nothing to adopt: the route, the request and the response are yesterday's.

When: live since 9/10 at 14:35Z, when the engine came back (14:35:47Z exactly).

The measures, and they are those of the request that exists: the threshold beyond which the turn starts being handed over drops from document 33 to document 5; a /v1/embeddings arriving behind a rerank used to wait 15.45 documents of that rerank (618 ms) and now waits 5.47 (219 ms); and if you close the connection halfway, the work already started that we throw away drops from 14 pairs out of 14.68 to 4. The numbers «from 8.2 to 2.1 passes» you had read on 9/10 were those of a request of 128 documents, the maximum we accept: in October's traffic that request never arrived.

How we know, and we redo it at every fix: a bench measures the turn on the new engine and on the very engine from before (bf93814), because «18 checks ok» on its own would not say that the bench is a gate: on the engine from before the same checks give 11 ok and 7 KO, and those are the seven the fix closed. The average of 14.68 documents comes from our counters (910 documents in 62 requests in October): we do not read your texts and we do not record them, not even to measure.

2026-10-09 — The four 403s that speak about the contract, not about your key, are now written in the error table in Italian and in English, with the real remedy: a new key does not fix them

What changes for you: nothing in the contract, and one thing in what you can read. Since the contract layer exists, a key that belongs to a stopped contract receives one of four 403s on every compute endpoint: LICENSE_SUSPENDED, LICENSE_EXPIRED, LICENSE_REVOKED, LICENSE_NOT_FOUND. None of the four was written in any page of the edge: measured on 9/10 at 13:50Z with a GET without a key, the four error pages — api.html, en/api.html and the two errori-dal-client.html — named zero of them. They were in a document of ours that we do not publish: writing them there is not publishing them. And the API page opens with a promise of ours — «the code is stable: branch on it» — and then gives you the table to branch on: the four you can receive were not in it.

The second piece is about the remedy, and we tested it by executing it: the only row that covers a 403 of this family today is KEY_REVOKED, and it says «ask for a new one». Under a stopped contract that advice does not work, and not by a nuance: asking to rotate the key returns 409 LICENSE_NOT_ACTIVE, and asking for a new key under the same contract returns the same 409. So the client who reads our page asks us for a new key and we cannot even issue one. The real remedy is the opposite and concerns the contract, not the key — which is exactly why that layer exists: the contract is reactivated, and all of its keys resume at the same instant with nothing for you to change.

What you find now: four rows in the error table, after SCOPE_NOT_ALLOWED, in Italian and in English, each saying the three things that matter at that moment — that the code speaks about the contract and not about the key, that every key of the same contract receives it at the same instant, and that a new key under that contract does not work. For LICENSE_NOT_FOUND the remedy is a different one and we say so: it is our fault, your key points to a contract that no longer exists, and there is nothing you can change on your side. In the two client-error pages there is a pointer to the table and not a copy: there is one table only, so it cannot fall behind.

For whoever has a key today nothing changes, and here is the number: in the database in service, read read-only on 9/10 at 13:55Z, there are 17 keys (11 active, 6 revoked), zero contracts, zero keys tied to a contract and zero contract requests. None of today's keys goes through any of the four refusals: it was a door open now that nobody had walked through yet. The day the first contract starts, the first client who gets a 403 LICENSE_SUSPENDED looks for that code in the page we gave them — and now finds it.

What does NOT change: no endpoint, no new code (the engine already sends these four: in the app.py in service there are 18 occurrences, and that file is byte for byte the one in our main — git hash-object gives 6e4e03f — up since 9/10 at 08:49:03Z with zero restarts), no limit, no price, no key, no new field in the responses; the contract stays 1.2. The service does not restart for this entry: these are pages we publish.

When: live since 9/10 at 14:35Z (14:35:46Z exactly, with the release of the site: a file copy, no restart for these rows). The engine already sent the four codes before that; from that instant the error table names them, in Italian and in English. You can redo the proof in one command: a GET without a key on api.html and on en/api.html — it consumes neither quota nor per-minute requests — finds 4 out of 4 in each, measured at 14:58Z. Nothing to adopt: the page you were reading is already the new one.

The measures of this entry: bench/misura-i-codici-del-contratto-che-non-pubblichiamo.py — nine scenes, from 4 ok / 5 KO to 9 ok / 0 KO. Scenes a, b, c, d build the contract with the real admin endpoints, set its state, call /v1/embeddings and read the code from the response, not from a list written inside the bench; r executes the advice we publish and looks at what comes back; k and g are the two control groups (two codes that were already published, and the key without a contract that must keep receiving 200: a cure on the pages cannot move by one character what a client of today receives). bench/mutazioni-i-codici-del-contratto.py — 8 faults out of 8 seen, 0 blind, 1 equivalent confirmed (the same rows moved to the other edge page: the bench demands the edge in two languages, not the name of a file), with the «without the cure» side taken from the pre-cure tip and not deduced. tests/test_codici_del_contratto.py — 3 proofs that nail down what must stay true before and after, among them that the contract codes a client can receive are four: if a fifth arrives the proof turns red, and whoever adds it notices there is a public table to widen. Production was not touched: GET without a key at the edge, read-only reads of the deployed app.py and of the database in mode=ro, no client key used to test.

2026-10-09 — Nine things we had announced are live, and we tell you with the instant and the proof; the responses without error.code still open are one, not four

What changes for you: nothing in the contract, and a lot in what these pages were telling you. The release of 9/10 at 07:31:16Z put our edge file, the site and the examples at the public edge; the live engine is the one from 8/10 at 16:42:27Z. Until this morning nine entries of this page said «written and tested, not yet live» and explained what reached you «until then»: that moment had passed, and the sentence had become false the other way round. Now each entry carries the instant it went live and the proof measured from the outside, not inferred from our repo.

The number you actually needed was a different one: the API page and the client-errors page told you that the responses without error.code still open were four — the 422, the 405, the 413 of the oversized body and the three 50x of the engine that does not answer — and added «three out of four are cured in our edge file and arrive with the next reload». That reload happened. A single one remains open: the 422. It is the number from which you decide how large to make the «don't know» branch of your error reader (rule 5), and a four in place of a one makes you write and maintain code for three cases that no longer exist.

The proofs, one per entry, and you can see where they come from: a key-less GET on the public edge for what a GET can provoke — /esempi/agilev_client.mjs answers 200 with 33,818 bytes where before it gave 404; GET /v1/embeddings answers 405 with {"error":{"code":"METHOD_NOT_ALLOWED",…}}; a path that does not exist on the site gives 404 with 2,283 bytes of HTML and no longer a blank page. For the two forms that provoking would mean breaking production — the 413, which needs an 8 MB body, and the three 50x, which need the engine down — the verdict comes from the live Caddyfile read read-only, and we say so instead of pretending we provoked them: there sit the blocks writing BODY_TOO_LARGE and SERVICE_UNAVAILABLE. For the clear-text guard we downloaded the published client and ran its constructor in a cage: it refuses http://api.agilev.agile.software, accepts http://127.0.0.1:8798 and https:// — three paths, three different outcomes, so the guard is there and it is not blind. For the engine entries the proof is the fingerprint: the live app.py is byte for byte the one in the repo.

And that one thing we had announced as «not yet live» is here now: live since 9/10 at 08:49:03Z, with the engine release. The model's turn is handed over between one pass and the next: before, a request held the model for its whole duration, so whoever arrived behind waited for the whole request and not for one pass. The proof of the instant, from the outside and not inferred from our repo: the live app.py is byte for byte the one in our main (git hash-object gives 6e4e03f) and the engine has been up since that instant. What it was costing you, measured at the edge on real October traffic, not in a cage: 4 /v1/embeddings had their window overlapping a slow /v1/rerank, and 3 of them paid 3.536 s with a 85-byte body, 10.878 s with a 69-byte body, 17.279 s with a 72-byte body — a body that small is not working, it is waiting; the other 21,981 October embeds have a median of 0.010 s. What it costs you now, and we say it instead of rounding: at the edge it is not measurable yet, because since the release nobody has called /v1/rerank or /v1/embeddings — a p95 over zero samples would be made up, and we will measure it again as soon as traffic arrives. In a cage, with the fake model slowed down, the small request behind the big one goes from 8.2 to 2.1 passes — 3.8 times faster — and the big one pays 13% more, and that is the queue. No extra worker, no extra byte of RAM, no contract field changed.

How far it goes, and we write it instead of letting you infer it from the best number: the turn is handed over between one pass and the next, and a /v1/rerank of up to 16 documents is a single pass — every query-document pair is cut at 1,024 tokens, so the number of passes depends on the number of documents and not on their length. On a request of that size whoever arrives behind still waits for the whole request, and the «from 8.2 to 2.1 passes» figures above are those of a 128-document request, the largest we accept. On October's real traffic, measured at the edge on 9/10: the bodies of the served /v1/rerank range from 59 bytes to 14,056, and whoever uses that endpoint sends on average 14.68 documents per request (910 documents in 62 requests) — exactly the size at which the pass coincides with the request.

What does NOT change: no endpoint, no new error code for you, no limit, no price, no key, no new field in the responses; the contract stays 1.2. What changes are the times of whoever sends small requests while someone else sends big ones, and the pages, which now say what the edge does.

How to adopt it: nothing to do. If you had postponed adopting one of those nine entries because it was «not yet live», you now find the instant next to each one.

2026-10-09 — Whoever calls us in Node now has a reference client: esempi/agilev_client.mjs, a single file, zero dependencies, with batching, the brake and Retry-After already inside

What changes: we published three reference clients — Python, PHP and the LangChain adapter — and none of the three is in JavaScript. Of Node, in everything we published, there was one line: new https.Agent({ keepAlive: true }). From now on there is esempi/agilev_client.mjs: an ES module for Node ≥ 18, a single file to copy, zero dependencies (the global fetch and AbortSignal.timeout are in the standard library; no node_modules, no package.json), with the same AgileV class and the same names as its twins — embed(), rerank(), usage(), health(), controllaDim(1024), spezza().

Why we are writing it now, and the number is not our pride but your bill: in the edge journal, read read-only, 99.4% of October's paying calls (5,936 out of 5,970) arrive with User-Agent: node, and none of the clients we published can send that header. The 243 429 refusals of the month are all from there, on 5/10/2026 between 21:41 and 21:43 UTC: in a single minute 300 requests served — that key's ceiling — and 240 refused, while every refusal carried Retry-After: 33. The service had warned first: X-RateLimit-Remaining went 11, 10, 9 … 1, 0 in 238 thousandths. A client that reads Remaining: 0 and waits would have taken no refusal at all; one that honours Retry-After would have taken one. We did not write that code, but we had given it nothing to copy: the gap is ours.

There is also the reason one gets there: those requests carry 1.83 texts each on average, against the 64 per batch we recommend. The same texts in batches of 64 are 159 requests instead of 5,558 — 97% fewer — and the ceiling of 300 per minute is never even seen. The new client does the batching by itself, one at a time and never in parallel.

The rules it carries inside, the same as the API page: connection reuse (with the global fetch keep-alive is already on, and the rule is not to turn it off); batches of 64 (maximum 128) one at a time; limits checked before sending; retry only on 429 RATE_LIMIT, 5xx and dropped connections, with a 1-2-4-8 s wait or exactly Retry-After when present; on a timeout the batch is halved instead of repeated as is; the brake on X-RateLimit-Remaining: 0; no retries on 400/401/403/413 and on QUOTA_EXCEEDED; redirects not followed (a 307 on a POST would resend your texts); the code deduced from the status when the response does not have our shape; and the 422 that tells you which field is wrong without echoing your texts back at you. spezza() produces the same chunks as its Python twin, compared one by one.

In the same work we fixed the LangChain adapter, which declared «contract 1.2» and did not apply those rules even in Python: _Client raised on any response other than 200 — no retry, no Retry-After, no brake — and inside a ContextualCompressionRetriever a single 429 broke the chain. It now brakes and retries like the others, on both branches of the transport (requests if present, urllib if not). AgileVEmbeddings, AgileVSparseEmbeddings, AgileVRerank and AgileVSpezza keep their signatures and the happy path does not change by a byte.

What does NOT change: no endpoint, no limit, no price, no key, no new field in the responses, contract 1.2, and the service does not restart for this entry. We did not raise anyone's rate_per_min: the ceiling did its job, and in batches of 64 that traffic never meets it.

How to adopt it: copy the file into your project and replace your direct fetch calls. import { AgileV, spezza } from "./agilev_client.mjs", then const av = new AgileV() with the key in AGILEV_KEY and await av.embed(texts). If you would rather keep it where you find it, download it: do not make commitments about the address staying identical forever, because a file we publish can move.

When: live since 9/10 at 07:31Z, with the edge, site and examples release. Verified from the outside, not inferred from our repo: a key-less GET on /esempi/agilev_client.mjs answers 200 with 33,818 bytes, where before it answered 404.

The measurements of this entry: bench/misura-il-client-che-non-abbiamo.py — six scenes for four languages in a cage on 127.0.0.1, and the judgement is on what the caller sees and what the server received, never on a reading of the source: from 14 ok / 10 KO to 24 ok / 0 KO (Node from 0 of 6 to 6 of 6, LangChain from 2 of 6 to 6 of 6), with the return after a refusal at 2.01 s against the 2 s asked for; 18 new tests against an in-process fake server, among them the batch halved on a timeout, the detail[].input echo that does not reach the message, and eight requests in a row opening two connections and not eight; and the LangChain adapter's brake tested on both branches of the transport. Production was not touched: reads only, no customer key used for testing.

2026-10-09 — The wrong method on an endpoint that exists is no longer an answer without a name: 405 with METHOD_NOT_ALLOWED, and the message tells you which method that endpoint wants

What changes: if you call an endpoint that exists with the method that is not its own — GET /v1/embeddings, GET /v1/richieste-licenza — you used to receive 405 with 31 bytes of {"detail":"Method Not Allowed"}: no code to branch on and detail as a string. From now on that answer has our shape — {"error":{"code":"METHOD_NOT_ALLOWED","message":"…"}} — and the message states the three things you need at that moment: that the endpoint exists (it is not an address to go looking for), which method to call it with, and that the request stopped before authentication, so it consumed nothing — neither quota nor requests per minute — and can be repeated identically with the right method.

The right method is not hand-written by us, the edge reads it: along with the 405 the framework already sends the standard Allow header with the method of that endpoint, and the edge reads it back and puts it in the message. On /v1/embeddings it says POST, on /v1/models it says GET, and a new endpoint needs nobody to update a list. The Allow header stays in the response: while writing this cure we measured that rewriting the body made it disappear, and a name in the body is not worth paying for with one standard header less.

We had written that this one would stay, and the reason was half right. The API page said: «the 405 comes from the framework, and on an endpoint that exists called with the wrong method "endpoint does not exist" would be false advice, so that one stays». The second half is true and we still honour it — the 405 does not reuse the body of the 404, and it does not send you looking for an address you already had right — but it ruled out one cure, not the cure.

It was not hypothetical, and it was one. On the edge log, read read-only from 11/9 to 8/10, the 405s are 16: 1 to a real customer (on 13/9/2026 at 01:26:32Z, GET /v1/embeddings, from the same source that in those same days made 885 POST /v1/embeddings with 200: they had the right address and were looking for the shape of the call, and four seconds earlier they had tried POST /embeddings), 4 to an audit of ours that arrives from outside, 11 to probes that left the box. One in twenty-eight days is few, and it is not the reason we cure it: the reason is when it happens, that is in the first half hour of an integration, which is the only moment when a new customer decides whether the service is convenient.

What does NOT change: no endpoint, no limit, no price, no key, no new field in the responses; the status stays 405, the Allow header stays the one from before, the contract version stays 1.2, and the service does not restart for this entry — it is all at the edge, a caddy reload is enough. If your client deduces the code from the HTTP status, as rule 5 says, nothing changes for you by a single line: before, you called that 405 BAD_METHOD yourselves, and that was the right behaviour; now you also find a code in the body, and a message you may show because it is ours.

How to adopt it: nothing to do. METHOD_NOT_ALLOWED is the only new code and it means do not retry the same way: change the method, the right one is written in the message and in the Allow.

When: live since 9/10 at 07:31Z, with the edge, site and examples release. Verified from the outside, not inferred from our repo: a key-less GET on /v1/embeddings answers 405 with {"error":{"code":"METHOD_NOT_ALLOWED",…}} and the right method inside the message.

The count of shapes without a code: four remain — the 422, this 405, the 413 of the oversized body and the three 50x of the engine that does not answer — but three out of four are already written in our edge file and arrive with the same caddy reload. The only one we do not close ourselves is the 422: giving it our shape is a change of contract and is assessed separately.

The measurements for this entry: bench/misura-il-405-del-metodo.py — 12 scenes in a cage, from 6 ok / 6 KO to 12 ok / 0 KO, with three control groups; bench/mutazioni-il-405-del-metodo.py — 9 faults out of 9 seen, plus 3 declared and not counted; tests/test_il_405_del_metodo.py — 7 tests, among them that the engine sends Allow on all public endpoints and that five 405s in between do not eat any of your requests per minute. Production was not touched.

2026-10-08 — While the engine restarts you no longer get zero bytes: the edge gives you 502/503/504 with SERVICE_UNAVAILABLE in our shape, and the status stays the real one

What changes: when the engine behind the edge does not answer — it is restarting, or it has too much work at once — the edge stopped you with a 502 (or a 503, or a 504) and zero bytes of body, with no Content-Type. It is the answer anyone gets who sends a request while the engine is restarting, that is at every release of ours: the engine comes back up and its health is awaited for up to 180 seconds. From now on that answer has our shape — {"error":{"code":"SERVICE_UNAVAILABLE","message":"…"}} — and the message says the three things you need at that moment: the request was not executed, it consumed neither quota nor requests per minute, and it can be repeated identically. It is written by the edge, not by the application: your request never reached the engine, so nobody counted it.

The status is not squashed into one: 502, 503 and 504 stay the ones that arrive. This is not elegance: a 503 can also come from the application (MODEL_UNAVAILABLE, model not present on this instance), and the code is the only thing telling you which of the two answered you — whether the engine was absent, or present without that model.

It was not hypothetical, and it was already written on our own pages: on 15/9/2026 at 12:43:55Z a customer received a 502 from the edge two seconds after a restart of ours. They did not report it: we read it ourselves in the edge's journal, and from their side it was an error with no name. That line stayed for 23 days on the client errors page as an example of what we do not tell you, and the answer stayed mute. Now it has a name.

It holds for both public names, and the second one is not symmetry: https://agilev.agile.software/health is the route this very page points you to for an automatic check («JSON, no key»). It was the one that, at the very instant the check matters — the service down — answered you zero bytes and made your json() trip. From now on its error reads too.

No Retry-After on this answer, and why: the edge does not know how long the stop will last — a restart is seconds, a release rolling back is minutes — and an invented number would make you all retry together at the wrong instant. The 429 carries it because there we know the number: the new minute, or the new month. Here we do not, and the message says «in a few seconds».

And a wait that does not change, but that you should know about: before answering by itself the edge holds your request for up to 30 seconds (lb_try_duration), retrying the engine. If the engine comes back within that time the request goes through and you see nothing; if it does not, you waited those 30 seconds. It was like this before too: the difference is that after the wait there is now an answer you can read.

What does NOT change: no route, no limit, no price, no key, no new field in the responses; the contract version stays 1.2, and the service does not restart for this entry — it is all at the edge, a caddy reload is enough. If your client derives the code from the HTTP status, as rule 5 of the client errors page says, nothing changes for you: before, you called that 5xx UPSTREAM_ERROR yourselves, and that was the right behaviour; now you also find a code in the body.

How to adopt it: nothing to do. If you branch on error.code, SERVICE_UNAVAILABLE is the only new code and it means retry: the request was not executed and you did not pay for it.

When: live since 9/10 at 07:31Z, with the edge, site and examples release. Verified from the outside, not inferred from our repo: the live Caddyfile, read read-only, carries the handle_errors 502 503 504 block writing SERVICE_UNAVAILABLE; we do not provoke it live, because that would mean stopping the engine for customers.

A correction to the count we gave you: the client errors page and the API page said that the responses without error.code still open were three (the 422, the 405 and the 413 of an oversized body). They were four: this was the fourth, and we had not counted it. The pages now say four.

The measurements behind this entry: bench/misura-il-502-del-bordo.py — 9 scenes in a cage (high ports on 127.0.0.1 only, a fake engine that switches off and back on, a Caddy of its own with the two real blocks copied from our file), from 6 ok / 3 KO to 9 ok / 0 KO, with two control groups (with the engine up everything answers; the site's 404 page does not break); bench/mutazioni-il-502-del-bordo.py — 10 faults out of 10 seen, each with the red it must produce and with a run on healthy copies first, plus three declared and not counted because they do not change one byte of what you receive; tests/test_il_502_del_bordo.py — 13 tests on what the cage cannot see, among them that the two names say the same thing and that the three statuses are the same ones our edge guard reads. Production was not touched: no request to the public names, no port 80 or 443 opened.

2026-10-08 — An oversized body no longer answers 413 with nothing in it: the edge gives you BODY_TOO_LARGE in our shape; and the two published limits are not reachable together with Chinese, Russian or Greek text

What changes: when your request body exceeds 8,000,000 bytes, our edge used to stop you with a 413 and zero bytes of body — no Content-Type, Connection: close. It is the same status with which the contract promises you {"error":{"code":"BATCH_TOO_LARGE",…}} and {"error":{"code":"TEXT_TOO_LONG",…}}, so a response.json() blew up and a reader that branches on error.code stayed mute. From now on that 413 has our shape, with a new code: BODY_TOO_LARGE. It is written by the edge, not by the application: it arrives before authentication and before the engine, it consumes neither quota nor requests per minute, it carries no X-RateLimit-* headers, and the message is fixed — it repeats nothing of the body you sent, so no fragment of your texts comes back into your logs.

Why it was not a laboratory case, and this is the part that is on us: the two limits we publish — 128 texts per request and 32,000 characters per text — do not fit together under that ceiling if the text is multibyte. In UTF-8 a Chinese, Russian or Greek character weighs 2 or 3 bytes: 128 texts of 32,000 CJK characters are 24,576,545 bytes, i.e. 3.07 times the ceiling, and 42 texts out of 128 are enough to exceed it. Anyone indexing in one of those languages was doing exactly what we had written down and got a 413 that told them nothing — and our 413 BATCH_TOO_LARGE, the one with the right shape, they never saw, because the request never reached the application. We found it ourselves on 5/10 measuring in a cage; nobody reported it.

And a third thing, small and poisonous: «8 MB» is not 8 MiB. Measured to the byte: 8,000,000 pass, 8,000,001 do not. Anyone sizing batches at 8,388,608 bytes was hitting the ceiling by 388,608 bytes with no line telling them so. The limits pages now state the ceiling in bytes, and the calculation to do before sending is in bytes (len(json.dumps(body).encode("utf-8"))), not in characters.

What does NOT change: the ceiling. It stays 8,000,000 bytes: how much a request may weigh is a load decision and we are not taking it inside this entry. No route, no limit, no price, no key, no new field in the responses; the contract version stays 1.2 and the service does not restart — this one is entirely at the edge, a caddy reload is enough. If your client deduces the code from the HTTP status, as rule 5 says, nothing changes for you and there is nothing to copy: that 413 used to come out as NO_CODE with status 413, which was the right behaviour, and now you find the code already in the body.

How to adopt it: nothing to do. If you would rather avoid the 413 than receive it, the calculation is in bytes, and our reference client sends batches of 64 — with which you do not get there even in Chinese.

A correction to what we had written: the errors page told you that the responses without error.code «were four; since 5/10 two remain». There were three: this 413 was the third and we had not counted it. The pages said three, and from 8/10 they say four: the entry above counts the fourth, the 502/503/504 of the restarting engine, found the same day. The count will come down when the cures are really live, one by one.

When: live since 9/10 at 07:31Z, with the edge, site and examples release. Verified from the outside, not inferred from our repo: the live Caddyfile carries the handle_errors 413 block writing BODY_TOO_LARGE; we do not provoke it live, because that would mean sending production an 8 MB body.

2026-10-08 — The three clients we publish refuse http:// towards a public address, and the LangChain adapter no longer follows the redirect: behaviour changes, and we are telling you first

What changes, and it is a change of behaviour: the three files this page tells you to copy into your project — agilev_client.py, agilev_client.php, agilev_langchain.py — used to accept AGILEV_URL=http://api.agilev.agile.software without a word. From now on an http:// address towards a public name or address stops immediately, when the client is constructed, before opening the connection: you get an error that names the address and says what to do. If your AGILEV_URL starts with http:// and points at us, it works today and will not tomorrow: this is the line to read.

Why: the API name also answers on port 80, and there it answers 308 towards https. A redirect arrives afterwards: by the time our edge writes it, your key and your texts have already crossed the Internet in the clear. That our edge does not read that body changes nothing, because the damage is on the wire and not in our logs. And the API page told you, about the trailing-slash 307, that «following the redirect is safe»: true of that 307, which the app writes inside the encrypted session, not true of the port-80 308 — which none of our documents named. Now all of them do.

It has never happened, and we say it with the number: in our edge's journal, from 11/9 to today, requests that arrived in the clear on the API name are 309, from 45 distinct callers, all ending in the 308; 13 had a body, and zero of those 13 were headed for a route of ours. They are scanners, not customers. No text of any customer has gone out in the clear: the exposure was open, not consumed.

http towards home stays allowed, and not for convenience: if you point AgileV at 127.0.0.1, at a private network of yours (10.…, 192.168.…) or at localhost, the client says nothing and works as before — that wire does not go through the Internet. The guard is on the address and not on the scheme, deliberately: an «https only» rule would break every private front end, ours included. For a name of yours inside a VPN we resolve nothing (a DNS lookup inside a constructor is a surprise): there is the explicit opt-out in_chiaro_ammesso=True in Python and $inChiaroAmmesso = true in PHP, to be written in your code.

And the LangChain adapter no longer follows redirects (allow_redirects=False on GET and POST). It went through requests, which follows redirects by itself: on a port-80 308 the body — that is, your texts — was sent again, and you received 200. Not a trace was left, not even in your own program. The other two clients have never followed them. If your code relied on that behaviour, on a 3xx you now get AgileVErrore with code REDIRECT and the fix in the message.

What does NOT change: nothing of the service. No route, no error code, no limit, no price, no key, no new field in the responses; the contract version stays 1.2 and the service is not restarted. The edge gains the three security headers the website name already had (Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy) — but those speak to browsers, and for a program the protection is the guard above, not them.

How to adopt it: check AGILEV_URL. If it starts with https://, there is nothing to do. If it starts with http:// and does not point at your own home, change it to https:// — and this is worth doing even if you do not copy our clients, because that key and those texts are travelling in the clear right now. If you do copy our clients, take the new copy from /esempi/.

When: live since 9/10 at 07:31Z, with the edge, site and examples release. Verified from the outside, not inferred from our repo: the PUBLISHED client, downloaded from the edge and run in a cage with no network, refuses http://api.agilev.agile.software with a ValueError and accepts http://127.0.0.1:8798 and https://: three addresses, three different outcomes, so the guard is there and is not blind.

2026-10-08 — A wrong address on the website is no longer a blank page: you get a page of ours, with status 404 and in your language; and /esempi/ now has an index

What changes: on this website a path that does not exist used to answer with our edge's fallback 404 — zero bytes of body and no Content-Type, that is, a blank page in the browser, with nothing saying the site is there and nothing to go back to. Now you get a page of ours with links to the pages you were looking for, in English or in Italian depending on where you were (/en/… stays in English), and with status 404 and not 200: a program of yours that looks at the status does not believe it found what it was looking for.

And /esempi/ now has an index: we published that address ourselves on 2/10, on this page, so you could copy the example clients — and it was a directory without an index, that is, exactly one of those blank pages. It now lists the seven files with one line on what each one is: the two reference clients (Python, PHP), the three hybrid-search examples (without a database, in PHP, on Qdrant), the LangChain adapter and the on-site test script. We are not withdrawing and not changing the address: you have read it, it stays.

Why we had not noticed, and we are saying so: of the two parts of our edge, the API one kept its log and the website one did not — in Caddy it is turned on per site. Out of 48,805 lines of edge log, the requests served under the website name were zero: every broken link a visitor hit was invisible forever, blank pages included. Now the website block has its own log, and the visitor's address is deleted from that log: the path alone is enough for us to know that a page of ours sends you somewhere that is not there.

What does NOT change: nothing of the API. No route, no error code, no limit, no price, no key; the contract version stays 1.2 and the API's 404 — {"error":{"code":"NOT_FOUND",…}}, 263 bytes — is the one from 29/9 and has not been touched. The service is not restarted: this entry is a copy of pages and a reload of the edge configuration.

How to adopt it: nothing to do. If you have an automated check that asks for a website page and expected an empty body on the 404, the body is now there (HTML, text/html); the status is still 404.

When: live since 9/10 at 07:31Z, with the edge, site and examples release. Verified from the outside, not inferred from our repo: a GET on a path that does not exist answers 404 with 2,283 bytes of HTML and Content-Type: text/html, and /esempi/ answers 200 with its index.

2026-10-07 — The cache is separate per customer, and every response tells you how many of your texts did not go through the model (usage.cached_texts, new field)

What changes, and why we are the ones telling you: since 15/9 a text seen recently is served from an in-RAM cache without recomputation. Today, in production, that cache serves 98.7% of the texts that reach us — 19,866 out of 20,128 since the last restart, you can read it too on the status page — and no field of the response told you whether your text was one of them. Now usage.cached_texts says how many texts of that request we already had, and GET /v1/usage says it by month, next to texts. It is there for a concrete reason: our largest customer sent us the same text forty-five times in October and had no way of noticing — one line in their code, not one in ours.

Texts served from the cache are billed like all the others, and this note does not change that: quota and price are measured on the characters you send us, usage.chars does not change, and the status page now says so on the very row that publishes the reuse figure, where before there was a number and no explanation. If after reading cached_texts you want to send less, the saving is yours and it is made in your client: keep the vectors of the texts that do not change.

THE CACHE IS SEPARATE PER CUSTOMER, and this is the part that concerns confidentiality. Until today the fingerprint of a cached text was (model, sha256(text)): it did not contain who had had that vector computed, so one customer's vector was served to another — and it showed in the timing, because a text served from the cache comes back in a fraction of the inference time (measured in a sandbox: 12 ms against 315). From the outside that timing is an answer to a question nobody should be able to ask you: "has somebody else already sent this exact text?". Now the customer is part of the cache key: "already seen" means seen from you, on every path that writes it — /v1/embeddings, /embed, return_sparse and the batching of requests that arrive together. Keys belonging to the same customer, rotated ones included, keep sharing the cache: the cut is between customers, not between keys.

What the separation costs you, measured: in October the cache hits across different customers were at most 42 out of 19,866 — 0.2% of the reuse — because today's reuse is each customer on their own texts. None of you will see slower responses.

How to adopt it: nothing to do. If you validate the response schema, add cached_texts to the fields of usage (an integer, always present) and to those of the /v1/usage rows. No request signature has changed, the contract version stays 1.2, and the /embed response — frozen contract — does not change at all: for that endpoint the number is in GET /v1/usage.

When: live since 8/10 at 16:42Z, with the engine release. Verified from the outside, not inferred from our repo: measured on 9/10 at 08:15Z, the live app.py carried the sha256 of this entry's commit, and /health said the engine had been up since that release.

2026-10-07 — A zero or negative top_n on /v1/rerank no longer cuts from the tail: you receive every document and the response says so in ignored_values (new field, present only when it happens)

What changes: top_n is applied only when it is positive. A negative top_n ended up as a slice from the tail of the list — out of 4 documents, top_n: -1 returned the 3 best minus the worst, and top_n: -4 returned zero, both with 200 and with all four documents billed. A top_n: 0 cut nothing and returned all of them, while the sentence we had published to you («results has min(top_n, documents) elements») said zero. Now zero and negative mean the same thing, no limit: you receive every document re-ordered and the response carries "ignored_values": ["top_n"], present only when a value was there and could not be applied. A well-formed request sees no new field. A document missing from results does not look like an error: with top_n: -1 the list arrived sorted, plausible and incomplete, and whoever stitched it back to their own texts by position indexed the wrong text. Our two published clients computed the expected count with a bare min and blamed us («attesi -1 elementi in results»): now they stop a negative top_n themselves, before spending. Live since 8/10 at 16:42Z, with the engine release: measured on 9/10 at 08:15Z, the live app.py carried the sha256 of this entry's commit, so a negative top_n no longer cuts from the tail.

2026-10-07 — If your contract is stopped, GET /v1/usage answers 403 too; and if you do have a contract, contract carries its numbers (new field, null for every key today)

What changes: GET /v1/usage was the only route of the public contract that did not go through our front door — its checks were written a second time, inside the route — and they had stopped in September: they looked at the key (revoked, expired) and not at the contract above it. The result: a key under a suspended, expired, revoked or vanished contract received 403 from every compute route and 200 with its usage from this one. Now the route goes through the same door as the others: on the same stopped contract it answers with the same code — LICENSE_SUSPENDED, LICENSE_EXPIRED, LICENSE_REVOKED — you get from a computation in the same instant.

The new field, and what it is for: under a contract the monthly quota and the per-minute limit belong to the contract and its keys consume them together, so your key's own numbers are not enough to predict the 429 that arrives. contract carries the contract's numbers next to the key's, with the same names: {"license_id":"lic_…","quota_chars_month":5000000,"rate_per_min":600,"chars":3120044,"month":"2026-10"}. Between the two ceilings the tighter one always wins, and chars is the sum over the contract's keys for the month. Before, a key read «10 of 90» while the contract was already at «70 of 100», and the refusal arrived with nothing in the response having announced it.

What does NOT change, and it is the part that concerns all of you: whoever has no contract — that is all of you today, our records hold no active contract — reads exactly the previous response, field by field, with contract set to null. And the route stays free: it does not consume one of your requests per minute, it carries no X-RateLimit-* headers, and it works even with the minute used up. That promise was the risk in this change — the front door does take a slot in the minute — and there is a test written for it: minute exhausted with two compute requests, five usage reads in a row, all 200.

A case that concerns very few, and we say it: a key with scope prova and no end date (which our command line will not create: it is a row written by hand in our records) used to get 403 KEY_EXPIRED on computation and 200 here. Now it gets 403 here too. It was the same half key as before, seen from the other side.

Who it is for: anyone with a dashboard that reads this route to know the state of their account — that reading changes, because a 403 on /v1/usage now means «call sales», not «call support». For NIS2, LG231, trpg, aiepod, core and adp nothing changes today: none of you has a contract, and your response is identical. No signature has changed, the contract version stays 1.2.

How to adopt it: nothing to do. If you validate the response schema, add contract to the expected fields (null or an object); if you read usage from an automated check, treat a 403 LICENSE_* as «contract stopped», not as «key to be reissued».

When: live since 8/10 at 16:42Z, with the engine release. Verified from the outside, not inferred from our repo: measured on 9/10 at 08:15Z, the live app.py carried the sha256 of this entry's commit.

The measurements behind this entry: bench/misura-i-consumi-che-ignorano-il-contratto.py — 4 ok / 6 KO before, 10 ok / 0 KO after, over ten scenes built with our real administration routes (two of them control groups that must stay green: the key without a contract and the key under an active contract); tests/test_consumi_del_contratto.py, 12 tests, 8 of them red on the previous engine and 3 declared control groups; bench/mutazioni-i-consumi-del-contratto.py — 7 faults out of 7 seen by the bench.

2026-10-06 — A /v1/rerank you close on a timeout is no longer counted against you, and what the recipe we recommend costs in time (no signature changes; one line to put in your client)

What changes: if you close the connection while we are computing a rerank, that request does not enter your usage. It already worked that way when the computation was made of several passes — between one and the next we check whether you are still connected — but a rerank of up to 16 documents is a single pass, and there is no next pass: there we checked nothing and the request was counted. Now, before writing the usage row, we check once. The check is one and sits outside the loop, so it no longer depends on how many passes the computation had.

Why we say it in this tone: because the API page already promised you that a request closed by the client is «neither computed nor counted», and on /v1/rerank that sentence was not true for small batches. This is not an improvement we invented: it is a promise we were not keeping, and one of our customers in the current month had 24 reranks counted and never delivered — all closed by their client at 30 s, not one of which reached them. You will find the number for the current month in the month-end close, under its own name: a request closed before the response is abandoned, it is counted, it is recorded and it is not charged.

What a rerank costs in time, because that is the cause upstream: rerank costs per character of the pair (query + document), not per document — measured from our counters on 6/10, ~2.1 ms per character. The recipe we have always published («30-50 candidates from the index, then /v1/rerank») with documents of 662 characters costs ~43 s: inside the 120 s we recommend for batches, and outside a 30 s timeout. The paragraph about timeouts was about embedding batches, not rerank, and whoever read the recipe and set 30 s ended up in the worst case. The two levers, in order: shorten the candidates (the same 30 cut to 200 characters cost ~13 s, and the reranker reads the first ~1,024 tokens of each one anyway) and raise the timeout to 120 s. The numbers are now next to the recipe, on the API page.

Who it is for: anyone using /v1/rerank with few candidates, that is anyone following our recipe. No signature has changed, no field is new, the contract version stays 1.2, and no response that is 200 today changes: the only difference is that a 499 you used to pay for, you no longer pay for.

How to adopt it: nothing to do in order not to be counted any more. If you want to stop getting 499s, raise the timeout to 120 s or shorten the documents — the reference client we publish already does the first one.

When: live since 8/10 at 16:42Z, with the engine release. Verified from the outside, not inferred from our repo: measured on 9/10 at 08:15Z, the live app.py carried the sha256 of this entry's commit.

2026-10-06 — A field you get the name wrong on was not vanishing silently by design: now the response lists it in ignored_fields (no well-formed request changes by a byte)

What changes: if you send a field the route does not declare — dimensions instead of nothing, top_k instead of top_n, truncate instead of truncation, or simply a mistyped name — the field is not applied (as before) but now the response says so: "ignored_fields": ["dimensions"], present only when there is at least one. The status stays what it was: nothing that receives 200 today will receive anything different, and a well-formed request sees no new field. Before it was silence: measured on 10 routes out of 10, a made-up field was accepted with the same status as a healthy request and with nothing naming it. A required field with the wrong name we have always reported (422): the silence was only about the optional ones.

Where it actually matters: dimensions is OpenAI's parameter on the route we call OpenAI-compatible, and here the dimensions are 1024 and are not chosen — someone arriving from text-embedding-3-small (1536) or voyage-3-lite (512) wrote it and received 200 with a 1024-value vector, that is exactly the size that did not fit their index; top_k is Cohere's name and with the wrong name you received all the documents instead of the top k; truncate here is truncation. The table is in the API documentation next to the fields.

On the administration routes the refusal is blunt, and there the hand is ours: 400 FIELD_WRONG_NAME on the three pairs of names the two sibling routes use for the same concepts (rate_per_min/limite_al_minuto, quota_chars_month/quota_mensile, tenant_id/tenant), with the right name in the message. A key requested with limite_al_minuto: 300 was born at 120, and one requested with tenant_id was born in our tenant, that is with its usage outside the customer's invoice. For you nothing changes: /admin/* is not reachable from a customer's position.

Who needs this: anyone writing the client in house, anyone arriving from another provider and anyone using an adapter written for another one. No signature has changed, no field is new in the requests, the contract version stays 1.2.

How to adopt it: nothing to do. If you want one more check, read ignored_fields and raise your voice when it is there: it is the signal that somewhere in your code a name we do not know is being written.

What we are NOT doing: an unknown field on the /v1/* routes is not refused. Voyage and Cohere return a 400; we do not, not today: it would be a contract change towards clients we cannot see — we do not log request bodies and we will not — and a new 400 would stop people who work today. If we do it, you will read it here first, with a date.

2026-10-06 — Which clock your month is cut on: at 00:00Z, and the 429 QUOTA_EXCEEDED tells you how many seconds are left (no signature changes; the «When» of this entry carries the instant it went live)

What is already true today, and we had never written it down: your monthly quota restarts when the YYYY-MM label of the UTC clock changes, that is at the first instant of the new month: 00:00Z on day 1, which in Rome is 01:00 (02:00 with daylight saving), not your midnight. The same clock cuts the invoice and the labels that GET /v1/usage returns to you. It has always been so and nothing changes — it holds for October's invoice as for the earlier ones — but until yesterday it was written in none of the pages where we promise you a «monthly quota»: ten promises, zero clocks. If you have jobs that consume quota at the start of the month, that is the instant to wait for.

Why we are telling you now: because that label was not decided by us. We asked the local clock of the process, that is a configuration file of the operating system (/etc/localtime) that is in no deployment list and that no test named. Our box is on UTC, so the result was right — and it is right now too, byte for byte: not one line of your invoice moves. But it was right by configuration and not by construction, and the difference is measurable: if that file changed, for Europe/Rome it would be 19 hours a year attributed to the wrong month, 52 for New York, 96 for Shanghai, 144 for Auckland — and no total could notice, because the two sums would stay consistent with each other and wrong with respect to your calendar. Now the month is cut on UTC by construction, and a test that runs the same code under four time zones keeps it there.

What changes for you: the 429 QUOTA_EXCEEDED carries Retry-After. Until the release of this entry it did not, because of a choice we had written ourselves — «the quota restarts with the month, it is not a retry» — and the result is that a customer at quota has no number to wait for: they find out by retrying. The number we send is the seconds left until the first instant of the new month in UTC, from 1 to 2,678,400, and it is worth saying what it is not: it is not tonight's midnight (someone who hits quota on the 3rd and waited for midnight would take another 429, and a number that makes you wait for nothing is worse than no number) and it is not a recommended wait. It is the distance from when your quota restarts. If you need to work again sooner, do not wait: ask for a quota increase with the landing page form (POST /v1/richieste-licenza, public and without a key).

Two Retry-After, one name, two meanings — and this is the line to put in your client: on the 429 RATE_LIMIT it is the seconds to the new minute (from 1 to 60, the window is the clock minute); on the 429 QUOTA_EXCEEDED it is the seconds to the new month. You tell them apart from the code in the body, not from the header. Our three reference clients retry only on RATE_LIMIT, and on QUOTA_EXCEEDED they do not even read that number: if you copy ours there is nothing to do. If the client is yours and you honour Retry-After on any 429, look at the code first: a client that honours two and a half million seconds stays idle for a month.

Who needs this: anyone with a monthly quota, that is everyone. No signature has changed, no field is new, the contract version stays 1.2, and no response that is 200 today changes by a byte: the only visible difference is one more header on a 429 you already receive.

When: live since 9/10 at 08:49Z, with the engine and the API pages release. Verified from the outside, not inferred from our repo: a key-less GET on /api.html and /en/api.html carries the line «the Retry-After gives the seconds to the new month (00:00Z on day 1)» in both languages, and the contract tests re-run on the LIVE app.py (same sha256 as the repo, process up since 08:49:03Z) find the header on the 429 QUOTA_EXCEEDED.

2026-10-05 — A licence can be requested by yourself: POST /v1/richieste-licenza is live, and the landing page states the plans and the comparison with the numbers (online since 5/10, 08:40 Rome time; for existing customers nothing changes)

What changes: two things that were written and were not reachable. The first is POST /v1/richieste-licenza, our only public and keyless endpoint — until yesterday it answered 404 because it lived only in our code and not in the code in execution. Now it answers: four required fields (chi, contatto, piano, uso), 201 with {"n": <number>} if you call it in JSON, a page with the request number if you send it a <form>, 5 requests an hour per address and then 429 RATE_LIMIT. The contract in full, with the six named errors, is on the API page. The second is the landing page, in Italian and in English: the three plans (prova, standard, enterprise) with their numbers — monthly quota, requests per minute, duration — the form for requesting a licence, and the comparison with Voyage and OpenAI.

The comparison, and the rule that counts more than the table: in our column there are only numbers measured by us, and each one has the bench that produced it; in their column, only what anyone can check for themselves — vector sizes, where the model runs, how you are billed. There is not one measurement of theirs, and there will not be: to measure a provider you have to send it texts, and here texts never leave. So the retrieval-quality row states our own numbers (R@1 0.825 and nDCG@10 0.907 with dense; 0.925 and 0.968 with the hybrid, on 26 passages of public legislation and 20 queries) and for the others it says to take a trial licence and measure yourself on your own corpus, which is the only one you will actually work on.

Who it is for: whoever will arrive from the store, and whoever is evaluating us right now. For NIS2, LG231, trpg, aiepod and core nothing changes, and this time it has to be said in full: the contract version stays 1.2, no signature has changed, no field is new on the endpoints you use, and today's keys have not been put under a licence — the quota, the per-minute limit and the expiry of your keys are yesterday's, byte for byte. Moving them under a contract is a decision we do not take quietly inside a deploy: when it happens, you read it here first.

What you still cannot do by yourselves, and we write it instead of letting you find out: administering your own licence. The ten /admin/licenze/* endpoints exist and are tested, but they sit behind our tunnel and from a customer's position none of them arrives (measured: 0 out of 16 administrative endpoints from outside, and 16 out of 16 from inside). Until there is a console of your own, we issue keys and licences on your request — and that is what the form does.

The measurements for this entry, taken from the public edge and not on our own files (33 reads, no POST, no key, nothing consumed): the items of the service that are in a customer's hands go from 45 out of 58 to 57 out of 58; GET /v1/richieste-licenza answers 405 — «the endpoint is there, the method is another» — where yesterday it answered 404; the trap that worried us most is closed in both languages. That trap was this: the landing page is static and is published by copying files, while the form's endpoint requires a restart, and those are two different deploy chains. Publishing only the page would have given somebody a form that answers 404 after they wrote their name, their contact details and what they need. The two halves arrived together, and now a bench refuses to let a page be published whose call the service does not serve yet.

The one item a customer does not have today, and we are not hiding it: stato.json, the counters file behind the status page, is no longer rewritten every five minutes as we promised you. We are not telling you how many minutes old it is, because that number grows by itself while you read and we would be handing you a stale measure all over again. And we are not telling you it is frozen for good, because it is not true, and this line corrects one of ours a few hours older that said so: the last write we measured is Monday 5/10/2026, 14:18:27 UTC, and the generato field inside the file may well be more recent than this line. The reason is that the little program succeeds now and then, by accident: it runs every five minutes shut inside a sandbox that does not let it open the database, and it only gets to the end when something else, in that very same instant, is already holding that database open: in this write mode (WAL) even a read needs a service file next to the database, and the sandbox will not let it be created. That something else is not you, and this line corrects ours from this morning, which said «your traffic decides»: over the real runs on 5/10 between midnight and 21:10 UTC — 249 runs, 7 of them successful — in front of each of those 7 there were zero requests of yours of the kind that open the database, against 3.3 on average in front of the failed ones (bench/misura-quando-lo-stato-riesce.py --giorno 2026-10-05 --fino 21:10:00). Between midnight and 21:10 UTC on 5/10 it made it 7 times out of 249. Calling us more often does not refresh the page, and a stale page says nothing about the service. You read the age yourself, and it is the only way to have it right: the generato field says when the file was written, and from today the status page compares it with your own clock. It is not a fault of the service and it does not concern your calls. Cause found, fix written and tested, queued for the next deployment; until then, if you need the status page, look at /health, which is live and does not go through that file.

2026-10-01 — The trailing slash in the address: what that 307 is, and why it is not an error (nothing changes in the API; one new line for whoever writes the client)

What changes: nothing in the service. What changes is what is written down, and for a response that has always been there and that we had never named. If you call a real endpoint with a trailing slash — POST /v1/embeddings/ instead of POST /v1/embeddings — you do not receive 200 and you do not receive an error of ours: you receive 307, with an empty body and a Location: pointing at the same endpoint without the slash. It is written by the framework beneath us (redirect_slashes), not by our code, and the 307 preserves method and body: if your client follows redirects, you will not even notice. If it does not — and many disable them on POSTs, which is a prudent choice, because a redirect on a POST re-sends the body — you see a status that is not 200 and has no error.code.

Why we do not call it «a third shape without a code»: because it is not an error. The two shapes without a code remain the 422 and the 405, and those are responses that say «you got it wrong». The 307 says «it is the same answer, at the right address». But it is a status you can receive without error.code, and our rule 5 — deduce the code from the HTTP status — listed 404, 405, 5xx and «everything else»: the 3xx fell into «everything else», that is, into the branch that does not know what to do. Now it has a branch of its own, on the API page and inside the three reference clients, which return REDIRECT and the remedy instead of an «I do not know» code.

What you need to know, all of it measured (with a test service and a test edge on high ports; live, read-only calls only, with no key):

Who it is for: whoever writes the client in house and whoever will arrive from the store. For NIS2, LG231, trpg, aiepod and core nothing changes and there is nothing to copy again: our three clients cannot add that slash by themselves, because they strip the trailing slash from the base address — the slash comes from an address written by hand, from a curl, from a third-party client.

How to adopt it: do not write the trailing slash. If you use a client of your own, add the 3xx branch: drop the slash, or follow the Location. If you copy ours, the branch is already there. The contract version stays 1.2, no signature has changed, no field is new.

What we do not claim, and the decision that is not yours: we have fixed nothing. That redirect can be switched off, in two ways — removing the slash at the edge (a caddy reload) or in the app (one line and a restart in the window) — and the second would make the response uniform across all eight endpoints. It has a price that has to be stated, though: whoever works today without noticing anything (requests, curl -L) would receive a 404. The choice is a decision of ours to be taken in a window, not something to be done on the quiet, and until then this is the behaviour and now it is written down.

2026-10-01 — The 404 you get when you are groping for the endpoint now has a code (online since 1/10, 02:00 Rome time; nothing to copy again)

What changes: a path that does not exist under /v1/ — POST /v1/embed (between /embed and /v1/embeddings), /v1/embedding, /v1/reranking, /v1/keys, /v1/ — no longer answers {"detail":"Not Found"} without a code. It answers {"error":{"code":"NOT_FOUND","message":"rotta inesistente: le rotte pubbliche sono …"}}, the same shape as all our other errors and the very same body you already received for a wrong base URL. And that message now lists all eight public endpoints: before it named six, and the two mute ones were POST /v1/tokenize and POST /v1/richieste-licenza, that is, the two newest — the very ones somebody is looking for when they read that message.

Where it comes from: from the entry of 29/9 below, reread with the service in hand instead of on paper. That entry promises «when you knock where there is nothing — wrong base URL, a path outside the API, an endpoint groped for — the edge no longer answers not found in plain text». Measured from outside on 1/10, as soon as the edge went online: true for 13 paths out of 13 outside /v1/, false for 9 out of 9 inside. The reason is a correct choice nobody had finished following through: the edge lets /v1/* through whole, and it must, otherwise it would stop a new endpoint under /v1/ instead of letting it reach the service — but precisely for that reason everything starting with /v1/ reached FastAPI, which writes its bare 404 for an unknown path. The table row «endpoint that does not exist → {"detail":"Not Found"}» was on the API page as well, three rows above the edge's one: the two rows together said the right thing, the paragraph above them did not.

The decision, and why that one: it is rewritten at the edge, not in the app. Caddy looks at the status of the response coming back from the engine and, if it is a 404, puts our body in. It costs a caddy reload — not a line of the service's code, no restart, no night window — whereas changing FastAPI's 404 inside the app would have been a deploy with a restart for a response that computes nothing. Looking at the status and not at the body is one piece of information less, and it holds for a reason we nailed down in a test instead of remembering it: no public endpoint answers 404 on purpose (our 404 KEY_NOT_FOUND and 404 LICENSE_NOT_FOUND all sit under /admin/*, which does not even pass on the public name), so a 404 arriving from there is always the framework's. If one day a public endpoint wanted a 404 of its own, that test fails before the deploy. The 405 is not touched: the endpoint exists and the method does not, and telling somebody who nearly guessed right «endpoint does not exist» would be false advice — it stays the shape without a code, declared.

Who it is for: whoever writes the client in house, whoever will arrive from the store, and anyone integrating right now — because «groping» is not a figure of speech: /v1/embed is the mistake you make having read that /embed and /v1/embeddings both exist. For NIS2, LG231, trpg, aiepod and core nothing changes: your addresses are right and you do not receive this response. If you deduce the code from the HTTP status as rule 5 of our clients says, the two shapes already behaved the same way — and that is why that rule is written the way it is.

How to adopt it: nothing to do, nothing to copy again, no signature changed, no new field. The contract version stays 1.2. The reference clients already gave NOT_FOUND for a 404 and they still do.

The measurements (bench/misura-bordo-non-trovato.py: a test edge on high ports, the same 20 requests against the earlier Caddyfile and the current one, no request to the real service): responses with an error.code 13 out of 20 → 20 out of 20, of which 0 out of 7 → 7 out of 7 among the groping ones under /v1/; public endpoints named by the message 6 out of 8 → 8 out of 8; distinct bodies across all the points 2 → 1 (there must be one: if /metrics or /admin/* answered differently from an invented path, the response would say they exist — and now you cannot even tell a path that reached the app from one stopped earlier). Unchanged, and these are the counter-proofs: the 405 for the wrong method still 2 out of 2 a 405, the real endpoints still 2 out of 2 served by the engine, echoes of the path in the response 0 out of 3. Bench: 8 new tests, among them the two guards that hold the premise in place (the rewrite only on the public name and only on the 404; no public endpoint with a 404 of its own) and the one that compares the message's list with the decorators of server/app.py, so that it does not go stale at the next endpoint.

What we do not claim: that the bench had already measured it. It said 13 out of 13 and zero failures on 29/9, and that green was on a smaller question than the one written at the top of the file, because of two defects of its own: the fake engine answered 200 to any path — so the scene under /v1/ did not even produce a 404 to look at — and the count excluded the one /v1/* row there was, with a note saying «it goes through to the app and is not the edge's». Now the fake engine fails like FastAPI and reads the real endpoints from the app's decorators, and those requests enter the count: that is why the bench's number went from 13 points to 20, and the two are not compared.

2026-09-30 — Rotating an already expired key handed out a key that was born dead (nothing for you to do; no change to the public API)

What changes: nothing you call. What changes is the tool we administer keys with (/admin, on our private network, outside the public contract): asking for the rotation of an already expired key now receives an explicit refusal — 400 KEY_EXPIRED, «an expired key is not rotated: the new one would be born already expired» — instead of a new key. And the revocation of an expired key now says was: "scaduta" instead of was: "active", with the same word our command-line tool uses.

Where it comes from: from the same defect as yesterday evening and this morning, the third time we have met it, and this time not in a published number but in an operation that hands out a secret. status in AgileV takes two values only, active and revoked: expired is written by nobody, because the expiry is another column (expires_at) and the service looks at it on every request. So an expired key stays active forever, and the rotation check — if status != "active" — let it through. What followed did two kinds of damage in a single response: the old key's grace window was set to min(now + 24 hours, expiry), that is, to an instant already past; and the new key inherited the same expiry, that is, it was born expired. The response was a 201 with "status": "active" and the key's plaintext inside — which is shown once only — and that key took 403 KEY_EXPIRED on its first request. Whoever was administering was left holding a burnt secret, a key that does not work, and no way of having it reissued.

The decision, and why that one: it is refused, not accepted by issuing a live key. Rotation promises one thing only — no customer is left without a key halfway through the change — and on an already expired key that promise is empty: the customer has been without a key since before anyone rotated anything. Accepting would mean choosing, on the administrator's behalf, an expiry nobody asked for (+24 hours? none? the old one carried forward?), that is, extending an expired access inside an operation called «rotation». If a time-limited key has to live again, the decision belongs to whoever issues it and is written where it is seen: with the issuing. A readable refusal costs one line of documentation; a key born dead costs a delivery, a stalled customer and a wasted secret.

Who it is for: none of you in practice — /admin is not reachable from the Internet and is not part of the contract — and we say it anyway for this morning's reason, which holds for the third time: if you watch your keys from a panel of your own, status and expiry are two different things, and an expired key stays active in the status field. If you want to know whether a key of yours is still good, the answer is in expires_at (GET /v1/usage tells you) or, more simply, in the 403 KEY_EXPIRED you receive. For NIS2, LG231, trpg, aiepod and core nothing changes: your keys have no expiry (expires_at: 0), so none of your rotations ever went through there.

How to adopt it: nothing to do. No change to the public API, no signature changed, no new field in your responses, no file to copy again. The contract version stays 1.2.

The measurements (bench/misura-la-rotazione-di-una-scaduta.py, six scenes on throwaway databases, our production database is not even opened, zero requests to the service): 29 ok 9 failures → 38 ok 0 failures. Three of the six scenes are the control group — rotating a live key with no expiry, rotating a live key with a future expiry, revoking a live key — and they were green before and stay green: the refusal has not touched everyday rotation. Bench: 1,185 tests collected (they were 1,167), 18 new, of which 10 fail on the earlier code; 11 mutations out of 11 caught. Revocation does not change its behaviour by a single character: an expired key is revoked all the same and is idempotent as before — only the report changes, and a report is not a permission.

What we do not claim: that it has already happened. Today in production no key has an expiry, so this defect has not yet handed out a dead key: it is an armed error, and what arms it is exactly what we are about to put online — trial keys, which have an expiry by construction, and rotation's grace, which writes an expiry. The second rotation of the same key, after the first grace has passed, would have fallen into it by itself.

2026-09-30 — And the monthly requests on our status page were counting our own keys too (nothing for you to do; no change to the API)

What changes: on the status page, under «This month», the two columns Requests and Texts processed now count customers only. Before, they also included the keys we use for testing and monitoring, and our control room's trial key. Below the table there is a new line saying how many requests the service worked in all and where the difference comes from, so the arithmetic can be redone: no figure disappears, what changes is which question the one in the table answers. On our box, for September 2026, the embedding requests go from 1,139 to 1,128 and the texts from 3,867 to 3,848; on reranking, which has few requests, from 10 to 8 and from 21 to 16 — a fifth of the requests were not customers'.

Where it comes from: from this morning's same question, asked of the two numbers next to the one we had corrected. We had redone the customer count in the early hours of 30/9 (the entry below: from 7 to 5, because among the «customers» there were our own keys too); requests and texts were in the same file, three lines above, and they were computed by a line that did not join the usage to the key that had produced it. Without that join a usage row has no owner, so the number was «the work the service did for anybody», while the page — and the entry of 21/9 below — promised it as «aggregated over all customers». The same file, in the same instant, excluded three keys from the customer count and published their usage as if it belonged to the five.

Who it is for: none of you in practice, and we say it for the same reason as this morning: those numbers are public and you read them too. If you have quoted the September 2026 requests, the customer values are 1,128 (embedding) and 8 (reranking). In your usage nothing changes: GET /v1/usage has always told you only your own, and the invoice already excluded our keys — that is where we took the rule from, instead of writing a second one.

How to adopt it: nothing to do. No change to the API, no restart, no new field in your responses.

The measurements (bench/misura-consumi-pubblicati.py, scenes on throwaway databases, our real database read-only, zero requests to the service): 8 ok 37 failures → 45 ok 0 failures, with a balancing check on every published number (total = customers + ours + control room + unattributable). Bench: 1,117 tests collected (they were 1,092), 25 new — three of them actually run the page's code, not just the file it reads — 18 mutations out of 18 caught. The page still publishes no customer, company or key name, and no character volumes: numbers only.

What we do not claim: that the difference was large. On embedding it is 1 %; on reranking it is a fifth, and it grows with our own testing, that is, with something that has nothing to do with you. And as this morning: the number on the page stays the earlier one until the next site alignment, because it is written by a program that runs on the installed copy.

2026-09-30 — Our status page said «7 customers» and the customers were 5 (nothing for you to do; no change to the API)

What changes: on the status page the customers line said «customers with an active key» and the number followed that sentence instead of reality. Now it says «customers who can use the service right now», and alongside you can read what stays out: how many customers have an expired key and how many keys are not customers' (ours, and the control room's trial key). On our box the number goes from 7 to 5. In the API nothing changes: no signature, no error, no new field in your responses.

Where it comes from: from a simple question asked of a number we publish — which question does it answer? That number was COUNT(DISTINCT owner) over the keys with status='active' and the word embed or rerank among the scopes, and it was wrong in four ways we had already learned elsewhere. (1) status knows nothing about expiry dates: an expired key stays active forever, and we counted it among the customers while the service answers it 403 KEY_EXPIRED — the same defect we had fixed yesterday evening in the invoice. (2) The key's name (owner) is a label chosen by whoever asks for it: two different companies both calling their key core were counted as one. (3) Our own service keys ended up among the customers: 2 of the 7. (4) The scope was checked as a substring, so a scope called embed-lab would have passed and the prova scope — which does grant embed and rerank — was not seen.

Who it is for: none of you in practice, and we say it anyway for two reasons. The first is that that number is public and you read it too: if you quoted it one day, the right value for September 2026 is 5. The second is that the first of the four defects concerns you as well, if you watch your keys from a panel of your own: status and expiry are two different things, and an expired key stays active in the status field. If you want to know whether a key of yours is still good, the answer is in expires_at (GET /v1/usage tells you) or, more simply, in the 403 KEY_EXPIRED you receive. For NIS2, LG231, trpg, aiepod and core nothing changes: your keys have no expiry (expires_at: 0).

How to adopt it: nothing to do. No change to the API, no restart, no file to copy again.

The measurements (bench/misura-clienti-attivi.py, scenes on throwaway databases, our real database read-only, zero requests to the service): 9 ok 14 failures → 23 ok 0 failures; on the box clienti_attivi 7 → 5, keys that are not customers' 3, customers with an expired key 0. Bench: 1,069 tests collected (they were 1,037), 32 new, 22 mutations out of 22 caught. The page still publishes no customer, company or key name: numbers only.

What we do not claim: that the expiry defect has already made us publish a wrong number for that reason. Today we have no expired key, so the 7 was inflated by our own two keys and not by expiry dates: that part was an armed error, with a date already written in the calendar. And the number on the page stays 7 until the next site alignment, because it is written by a program that runs on the installed copy.

2026-09-29 — The clients we hand out: the address no longer disappears in silence (no change to the API; copy the files again only if you need to)

What changes: in the six examples we publish (agilev_client.py, agilev_client.php, agilev_langchain.py, ricerca_ibrida.py, ricerca_ibrida.php, qdrant_ibrido.py) the key has always stopped when it was missing; the address has not. If AGILEV_URL was configured and did not arrive, the client went silently to the public address https://api.agilev.agile.software, and every command-line test stayed green. Now four rules apply, the same in all six: (1) the environment is read from all the sources the room may have — in PHP getenv(), $_SERVER and $_ENV, with getenv() winning because it is the process environment and not something that arrives with the request; (2) a value that is present but empty is an error that names the variable, never a fallback; (3) the client says where it took the address from (sorgente_indirizzo / sorgenteIndirizzo: argomento|getenv|server|env|ambiente|predefinito) and for the key it says the source and never the value; (4) there is an optional guard, indirizzo_obbligatorio=True / new AgileV(..., indirizzoObbligatorio: true), which stops if the address ends up on the public default.

Where it comes from: from the fourth limit we published this morning, measured by trpg in their own house — $_SERVER['LLM_API_URL'] is null under Apache while getenv() and $_ENV carry the value. We had written it as a warning for your code and had never turned it against our own. Having surveyed the six files: the key stopped 6 times out of 6, the address never.

Who it is for, and how much: whoever points AgileV at a non-public address — a private front end, the second box, the router, a WireGuard. For you, an address that disappears is not an error: it is your texts leaving onto the Internet instead of staying on the private network, and nobody tells you. If you use the public address and nothing else, nothing changes for you: whoever configures nothing has exactly the behaviour of before, and that is a test, not a promise. If your stack is PHP inside a web server (Apache with mod_php, or php-fpm, which with clear_env = yes — the default value — empties the process environment and leaves only $_SERVER), this entry concerns you even if you have never seen the problem: it is the shape that does not show.

How to adopt it: nothing to do on the API, no signature changed, no call different. If you copied our examples, copy them again from https://agilev.agile.software/esempi/; if you wrote the client in house, the useful rule in one line is: a configuration value that is present but empty is an error, not a fallback, and in PHP read it from all three sources. Diagnosis in one command when «it works from the command line and not under the web server»: php ricerca_ibrida.php --dove, or python agilev_client.py --prova, which now print the address and its source (never the key).

The measurements (bench/misura-indirizzo-che-sparisce.py, control group taken from git, no request to the service): silent cells that ended up on the public address 4 → 0; clients that with an empty key sent the text to public production with an empty Bearer 2 → 0; unobservable cells 4 → 0 (in ricerca_ibrida.php the address was decided inside embed(), so it was not checkable without making a request, not even by us). Bench: 889 tests collected (they were 845), 44 new, 22 mutations out of 22 caught.

What we do not claim: we did not measure the php-fpm room ourselves — php-fpm is not installed on our box, and the clear_env line is documentation, not a measurement of ours. The Apache one is trpg's. Ours is the command line, where $_ENV is empty with the default variables_order. Three rooms, three different results: that is why all three sources are read instead of choosing one.

2026-09-29 — The fourth of those four closes: the edge's 404 takes the contract's shape (online since 1/10, 02:00 Rome time; there is nothing for you to do)

What changes: when you knock where there is nothing — a wrong base URL, a path outside the API, an endpoint groped for — the edge no longer answers not found in plain text. It answers {"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"}} with Content-Type: application/json, that is, the same shape as all our other errors. The message is fixed: it does not repeat the path you asked for, so nothing of what you send comes back into your logs.

Where it comes from: from the entry below. Of the four responses without error.code we had surveyed this morning, three are written by FastAPI inside the app and can only be touched with a deploy; the fourth is written by the edge, and changing it only takes a caddy reload. It was also the worst of the four, because it is the one that catches whoever is still looking for the address: in the measurement on the September log, 7 of the 20 code-less responses that reached a real customer were of this kind.

Who it is for: whoever writes the client in house and anyone who will arrive from the store. For NIS2, LG231, trpg, aiepod and core nothing changes: if you deduce the code from the HTTP status as rule 5 says, the two shapes already behave the same way — and that is exactly why that rule is written the way it is. Whoever read the body to understand what had happened now finds a code here too.

How to adopt it: nothing to do, nothing to copy again, no signature changed. The reference clients already gave NOT_FOUND for the edge's 404 and they still do.

The measurement (a test edge on high ports, the same 13 requests against the earlier Caddyfile and the current one, no request to the real service): edge responses with an error.code 0 out of 13 → 13 out of 13, all NOT_FOUND, all application/json; distinct bodies 1 before and 1 after — /metrics and /admin/* answer 404 on purpose so as not to admit they exist, and they remain indistinguishable from an invented path; 0 out of 3 «talking» paths came back in the response; 2 out of 2 real endpoints still served (a prettier 404 that ate the good endpoints would be a fault). The scene of 13/9 redone identically — five requests in four seconds from somebody looking for the endpoint — goes from 0 out of 4 responses with a code to 4 out of 4; the fifth was the app's 405, which stays.

Status: online since 1/10/2026, at 02:00 Rome time (the window's caddy reload; no restart of the service, no interruption). Verified from outside immediately afterwards: https://api.agilev.agile.software/ and twelve other paths outside the API answer 404 with code: NOT_FOUND and Content-Type: application/json. The app's 422 and 405 stay as they are; the app's 404 does not, the edge closed it on 1/10 — see the entry at the top of this page.

2026-09-29 — The responses AgileV does not write: four of them, and now they are surveyed (it already holds on the 1.1 that is online)

What changes: nothing in the service. What changes is what we tell you and what the reference clients do. Until yesterday the API page promised that the 422 was the only response without {"error":{"code":…}}. That was not true: there are four, and two have already happened to real customers.

WhenWhat you receiveWho writes it
invalid request body422 with {"detail":[{…}]}FastAPI
wrong HTTP method (GET on /v1/embeddings)405 with {"detail":"Method Not Allowed"}FastAPI
endpoint that does not exist404 with {"detail":"Not Found"}FastAPI
wrong base URL, or a path outside the API404 with not found in plain text, not even JSONthe edge (Caddy)

This is the survey of 29/9. Of the four, the two 404 rows have closed: the fourth that same day (online on 1/10 at 02:00), the third on 1/10 — see the entry at the top of the page. Today the 422 and the 405 remain.

Where it comes from: from checking the consumers in the field. Reading our edge log for September read-only, we found that on 13/9 at 01:26:32Z a customer called /v1/embeddings with GET and received a 405, and that on 15/9 at 12:43:55Z another received a 502 because two seconds earlier we had restarted the service. Neither of them reported it to us, and not out of carelessness: from their side it was an error without a name. In all these responses detail is a string (or the body is not JSON), so they all fall into the same branch of a client's error reader — the one that does not know what to do — and there the clients, ours included, put ?.

Who it is for: anyone who writes the client in house, that is, all six consumers. It is rule 5 of «If you write the client yourself» and of the standalone page the errors seen from your client: no response may leave your client with an «I do not know» code. You put the code in yourselves, deducing it from the HTTP status and never from the body — those bodies are not ours and nobody promised what would be put inside them. 404 → wrong path, do not retry; 405 → wrong method, do not retry; 5xx without shape → retry; everything else → a code that says «without a code» and reports the status. That is four different behaviours, and with ? they were one. Corollary: do not put the body of those responses into the message — an error page from a proxy in the middle can contain anything and would end up in your logs labelled as an AgileV error.

How to adopt it: if you use our clients (agilev_client.py, .php, agilev_langchain.py), copy them again: no signature has changed and the four cases come out as NOT_FOUND, BAD_METHOD, UPSTREAM_ERROR and NO_CODE — codes put there by the client, do not look for them in our responses. It holds right away, with the 1.1 that is online today: the measurement was redone by capturing the error bodies from the code in production and from 1.2, with the same result, so there is no deploy to wait for.

The measurement, in two parts. In a cage (bench/misura-errori-client.py, 15 scenes × 3 clients): rows in which the caller does not see a code 12 out of 45 → 0 out of 45, rows with the echo of your texts 0, silently misaligned vectors 0. Live, on our edge log for September read-only (no request to the service, no address in the output): out of 4,310 requests served, 20 responses without a contract code reached somebody who actually uses the service — 13 on a real endpoint and 7 while looking for the endpoint. Inside those seven is the scene that gave birth to the rule: on 13/9 between 01:26:28 and 01:26:32Z a single source tried POST /embeddings, GET /embeddings, GET /api/embeddings, GET /api/v1/embeddings and finally GET /v1/embeddings — four 404s and a 405, not one of the five with a code. Somebody was groping for our address and we told them nothing useful even when they found it.

Status: done, and it does not require the 1.2 deploy. Of the four, two have closed and the edge closed them: the 404 outside the API on 29/9 (online since 1/10 at 02:00) and the app's 404 on 1/10. The 422 and the 405 remain: giving the 422 our shape is a change of contract and will be assessed separately, announced beforehand; the 405 comes from the framework and on an endpoint that exists called with the wrong method «endpoint does not exist» would be false advice, so it stays.

2026-09-29 — Trial keys with an expiry, and a new error: 403 KEY_EXPIRED (in production since 29/9/2026)

What changes: a key can have an end date. GET /v1/usage states it in the new field expires_at (epoch seconds UTC; 0 = never expires), and once the date has passed every request answers 403 KEY_EXPIRED instead of working. A new scope is born too, prova: it covers /v1/embeddings, /embed, /v1/rerank and /v1/tokenize — that is, the service — and it does not cover /metrics nor key administration. A key with the prova scope must have an expiry: without one, it is not accepted.

Where it comes from: from the owner's mandate of 29/9 on sovereign services. Every service in the suite must be testable with a time-limited master key, the same one for every service, without opening a contract and without anybody having to remember to switch it off by hand.

Who it is for, and who has to do nothing: whoever wants to let people try AgileV (the store, third-party customers, internal tests). For NIS2, LG231, trpg, aiepod and core nothing changes: your keys have no expiry (expires_at: 0) and they keep working exactly as before — it is measured, and it is the first check we wrote.

How to adopt it: nothing to do. If one day you receive a time-limited key, the only useful thing is to read expires_at from GET /v1/usage (which does not consume per-minute requests) and raise the alarm before it expires, instead of finding out from a 403. On KEY_EXPIRED retrying is useless: you need a new key.

Status: in production since 29/9/2026 (the 1.2 deploy at 18:09 Rome time; GET /health answers contract: 1.2).

2026-09-29 — An accounting write of ours no longer costs you the answer (and usage is under, never over)

What changes: if our usage store has a fault — full disk, file locked by another process — your request is served anyway. Before, it was not: the computation had already been done, and the answer was thrown away to give you a 500. Nothing to change on your side, no new field, no different response when all is well.

Where it comes from: from a question we asked ourselves while looking at our own code before the deploy, and from a lesson trpg passed on to us («two repairs both correct which, badly put in sequence, open the hole: if you have something similar in house, look at it before the next deploy»). The invoice write sat after the computation and before the answer, unprotected. Measured by building the fault for real — a genuinely full disk and a genuinely locked file, not a pretend one — the count was: 4 answers lost out of 4, and in half the cases the work had already been done and paid for in CPU. With the file locked you also waited up to 10 seconds longer before receiving the error.

The price, stated in full, because it is yours: the usage row really is lost, and we do not recover it. So the numbers in /v1/usage and the invoice that comes out of them can be lower than the truth; they will never be higher. For the same reason, during such a fault a key with a monthly quota can overshoot: we would rather serve you and lose the count than deny you an answer over a number that matters to us. On our side a counter always says how many rows were lost, so that we know when the month's figures are under and by how much.

How long the wait lasts, and who pays it: at the first fault we stop trying to write for 30 seconds, so that no request pays the wait for the locked file. When the 30 seconds are up, one single request tries again, to see whether the store is back: the others pass straight through. Without this rule, when the time was up they all retried together — measured on 29/9 with a genuinely locked file: 8 requests out of 8 stalled for 10.1 s, once every 30 seconds for the whole duration of the fault; now 1 out of 8. The other side of it, which is yours: the request acting as the probe and the ones passing alongside it (a window of 46 milliseconds) lose their usage row even if the store had come back in the meantime. It is always the right direction for the error — under, never over.

Who can notice: whoever has a fallback on 500 and whoever keeps count of their own requests (trpg, core). If in your records you have isolated 500s with error.code: "INTERNAL" in the past, some of them may have been this — and the number of characters you saw billed in those months may be slightly lower than what you sent.

Measurement, bench in a cage with temporary databases and production never queried: answers lost 4 out of 4 → 0 out of 4; a burst of 3 identical requests during the fault went from 30.1 s and none served to 10.0 s and all 3 served. 545 fast tests green, 23 on the real model, 14 mutations out of 14 caught.

2026-09-29 — The work queue can be read: coda in /health, and ok stays true even when it is full

What changes: GET /health carries one more field, coda, which says how busy the work queue is. Additive: whoever does not read it sees nothing change, no compute response changes, and ok stays true even with the queue full — more on that below, because that is the part that counts.

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

Four names: libera (free, below 80 % of the seats), carica (loaded — above that, but a seat is left), satura (full: no free seat, or somebody is already waiting, so a newcomer waits), indecidibile (undecidable: we could not read the counter — which means we do not know, not everything is fine). Next to the name there are the numbers, so that you can redo the arithmetic yourself.

Where it comes from, and it is the reverse of the entry below. Yesterday we wrote that from 1.2 /health answers under load too, and that «the queue is read in the timings, not in the health». The first half is true and measured; the second was a giving-up. trpg turned it back on us in the right way, on 29/9, starting from a defect found in their own house: four of their services wrote {"status":"ok"} and nothing else, so their four databases could all be dead and every light stayed green. Their question to us was: now that your /health always answers, can it also say «alive, but queueing»? Or can the field the product publishes only say yes and no? It could only say yes and no. Measured before fixing it, with 48 requests in flight for 28 seconds: 0 readings out of 104 named the queue.

The part that does not show and that concerns you: the third name stays outside the field that binary checks look at. ok stays true and the status stays 200 even with the queue full, by design. If you have monitoring, an orchestrator or a grep deciding «alive/dead» from ok or from the HTTP status, keep reading those: a queueing service is alive, and the answer to «dead» is a restart — that is, the worst thing to do while it is working. We learned this from trpg too, who paid for it: by putting the third name inside the field Docker looks at, their «honest health» declared a living service dead every time the queue was full. Here the guard that prevents it is called FILA_PIENA and it is a counter-proof that demands that nothing changes colour: it runs over seven scenes, including the one where we cannot answer.

How to adopt it: nothing to change. If you want to use it, it is there to let you read your own timings — with the queue full, the time you measure is waiting, not computing — and to decide to slow down by yourselves. The real brake is still the 429 with Retry-After and the X-RateLimit-* headers: the queue does not turn you away, it makes you wait. Our agilev-prova-dal-posto.sh now prints it as an informative line, and the status page writes it next to the light without changing its colour.

Three limits, stated before you rely on it. It is a snapshot, not an average: it says how things were in the instant of the reading. A missing field is not libera: a service older than this contract does not send it at all, and then your check must say «I do not know» — the empty model_unknown list teaches the same thing. And occupati counts the seats inside the service: it does not count /health (which does not take one) nor what is still held at the edge.

Measurement, 4 GB cage, temporary databases, test port, production never queried: with 48 simultaneous requests, 95 readings out of 95 carry the field, occupati 40, in_attesa 8 (the 48 in flight counted one by one), ok true in every reading, 0 probes lost, median 9 ms; on an idle service the control group says libera with 0 taken. Before: 0 out of 104.

2026-09-29 — model_unknown: the list that tells you whether your configuration is sending a name we do not know

What changes: GET /v1/usage carries one more field, model_unknown — how many times your key has sent us a model field we do not recognise, by month and by endpoint, with the time of the last occurrence. Additive: whoever does not read it sees nothing change, and no compute response changes.

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

Where it comes from, and it is not an idea of ours. trpg asked us for it on 28/9, in a single sentence: we send the model name from an environment variable and we want to notice before our own customers do. Today a name we do not know is served anyway and nothing happens — it is a choice of continuity, explained in The model field — so a wrong configuration makes no noise anywhere. The day 400 MODEL_UNKNOWN arrives, instead, they would be errors. This list is the way of noticing now, months in advance.

How to adopt it: read /v1/usage from an automated check and raise the alarm if model_unknown is not empty. Looking at it does not consume quota or per-minute requests (measured), so it can sit in a periodic check without weighing on the invoice. The valid names are those of /v1/models.

Four limits, stated before you rely on it. An empty list does not mean the configuration is right: "", null and the missing field count as the default and are not counted — if your environment variable is empty, it does not show up here, and the only way of noticing remains the model of the response. And /embed (the historical format) does not look at the field at all: whoever uses it will always have an empty list. Third limit, since 29/9, and trpg told us after measuring it in house: the list is per key. If one day your installations have a key of their own, the names they send do not appear under yours. And the first limit in reverse, for anyone with a fallback in their code: if your empty variable triggers a ?: 'another-provider-name', that name is counted — so for you the list is more useful, not less. Fourth limit, since 29/9, measured by trpg in their own house: if your code reads the configuration from $_SERVER instead of getenv(), under a web server that value can be absent — in their container $_SERVER['LLM_API_URL'] is null under Apache while getenv() and $_ENV carry the value. There there is no fallback to count: there is a configuration that disappears inside the web server and works in every command-line test. From here it looks like a missing name, or like a key that does not appear at all. The shape to recognise: the same key alternating the right name and no row at all, without anybody having touched anything.

What we record, and what we do not. Numbers only: key, month, endpoint, how many times, when the last one was. The name you sent us is not written anywhere — it arrives from an environment variable of yours and can contain anything, and our database is copied every night. You know what you put in it; for us it is enough to know that we do not recognise it. /metrics remains an aggregate without customer identifiers, as has always been written: the attribution lives where the invoice already lives, and only the key's owner sees it.

Why we did it now. Because the condition we had set ourselves in public for the 400 MODEL_UNKNOWN — «we would know that somebody is getting it wrong but not whom to warn» — was stuck, and would not unstick itself. Read in production on 29/9 at 03:2x: 4 of the 7 successful rerank requests since the restart of 27/9 carried an unrecognised name, and there was no way of saying whose they were. There was a second thing that did not add up: the counter lives in memory and restarted from zero at every restart, so the first condition — «it stays steadily at zero» — was satisfied by restarting the service. Measured in a cage on three fake customers, two of them with the wrong configuration: on the 1.1 code online the verdict is I do not know, and after a restart it becomes nobody is getting it wrong. Now it is these two, before and after the restart.

2026-09-29 — Forty simultaneous requests: /health stopped answering, and a 500 that was ours

What changes: two defects of ours, found by the same measurement and fixed, and one more line in the limits. Neither of them shows with everyday traffic; both show when many requests arrive in the same instant — that is, on the night of an announced deploy, when your benches all start together as soon as you read the notice.

The first: /health went silent. Requests in flight occupy the seats of a queue that has forty, and up to contract 1.1 /health sat in that queue like every other endpoint. With 48 compute requests at once, therefore, health did not answer at all: measured on 29/9 with 10 s probes every 0.25 s, all timed out, 20 s of silence in a row in the worst case, in 5 runs out of 5. Anyone calling /health to know whether the service was alive read «dead» on a service that was working — and the answer to a dead service is a restart, which is the worst thing to do at that moment. From 1.2 /health no longer goes through that queue and is therefore a valid liveness check under load too: on the same scene, 0 probes lost out of 883. If /health tells you ok:true while your requests are slow, it is not a fault: it is the queue, and the queue is read in the timings — since 29/9 the queue is read in the health, field coda: see the entry at the top of this page.

The second: a 500 on a healthy service. In the same measurement, 33 responses out of 48 came back 500 with the service in perfect health. Cause, from our journal: RuntimeError: Already borrowed. bge-m3's tokenizer is an object that has to be configured on every call, and here it was used by two different paths with no common lock — the token count (the one that decides your quota and the 413) and the model tokenising on its own account. Two threads in the same instant, and the object refuses. Fixed: the count now has a copy of its own. Measurement of before and after on the same scene, five runs per arm: non-200s in 5 runs out of 5 → 0 runs out of 5, that is, 0 out of 480 requests. The defect is in production today (contract 1.1) and has never triggered: in September the maximum number of requests in flight together was 3 out of 1,084 requests, and it takes a coincidence of microseconds between two paths. With 8 simultaneous requests, measured, it does not show even once: that is why it stayed invisible.

How to adopt it: nothing to change in the client, and one thing to know. In the limits there is a new line: simultaneous requests are not limited, the per-minute ceiling does not govern them — a key of 120 per minute can have forty in flight without taking a 429. From 1.2 doing so is harmless, but it remains useless: computation is one at a time, so forty simultaneous requests finish in the same time as forty in a row, and in the meantime your timeouts are running. For indexing, what we have always told you holds — one batch at a time, up to 128 texts, 120 s timeout — and now there is also the number we say it for. If you have monitoring watching /health with a timeout of a few seconds, from 1.2 you can raise your confidence; before the deploy, no.

2026-09-29 — The first seconds after a restart: a 500 that now has a code, and a red you must not believe

What changes: two things, and the first concerns your bench, not our service. The service, after a restart (a deploy included), opens the port at once and loads the models afterwards: /health answers 200 with "loaded":false and the models arrive in 8 seconds with a warm cache, ~40 cold. In that window compute requests are not refused: they wait and then answer correctly. But a check that reads loaded and shows red paints a healthy service red: our own on-site test bench, launched in that window, came back with 3 failures while the cosine was 1.000000, that is, with the check that counts green. Fixed: agilev-prova-dal-posto.sh now waits (--attesa-avvio, default 90 s) and says how long it waited; it only fails if the models do not arrive, and then it stops without spending requests. If you rewrote the bench in house — as trpg did — this is the line to add: before judging, wait for /health to say loaded:true. A red in that window is not the fault the test exists for.

The second is a defect of ours, found and fixed on the night of 29/9, and it is worth knowing because it changes a response you might receive. In that same window /v1/rerank (and /v1/embeddings) could answer 500 Internal Server Error in plain text, without {"error":{"code":…}}: it was the second error without our shape after the 422, and whoever branches on error.code saw ? there. Cause measured: the models load in a separate thread, and a request coming in meanwhile made a second one be built in parallel; the model is built without a murmur and breaks at the first use. In the worst case, measured, the process went on serving 500s for ever — Restart=always does not bring it back, because it does not die: it answers wrongly. Frequency measured over 8 cold starts with 4 requests fired at the first instant: most of the runs. In production it has never triggered, and we know why: September's three restarts (19, 23 and 27/9) are all between 06:02 and 06:37 UTC, and in all three there were zero requests in the first 60 seconds. The 1.2 deploy is the opposite: we announce it, and you run your bench as soon as you read it.

How to adopt it: nothing to change in your client. Two things to know. (1) From contract 1.2 an unforeseen fault of ours comes out as 500 INTERNAL with our shape and with the exception's type: {"error":{"code":"INTERNAL","message":"guasto interno (ExceptionName): …","type":"ExceptionName"}}. Never the exception's message — a library message can drag along a piece of your body, and your texts do not leave. Retrying once makes sense; if it repeats, send us the status and the type: the traceback and the agilev_imprevisto_totale{tipo=…} counter are ours, you do not have to record anything else (and in particular not the body). (2) If your client has a low timeout, know that the first request after a restart can take the loading seconds: it is not slow computation, it is the model arriving.

2026-09-28 — A vector that is already in your index: which provider does it come from? It can be derived, and now it is measured

What changes: in the service, nothing. What changes is a sentence of ours that was wrong. In Rule 4, countermeasure (d), we had written: write the model next to the chunk, «it costs nothing today, it is impossible tomorrow». The first half is right, the second is not — and we had written it without measuring it, repeating the conclusion of trpg, who on the evening of 28/9 had written to us: «our index has 442 chunks from one provider and 29 from yours, all at 1024 values, the table does not record the model anywhere, we will never know whether it is healthy». If the text is still next to the vector, the provenance can be derived: you re-embed the text and look at the cosine against the stored vector. There is now a countermeasure (e), and it is the second one that holds on the index you already have.

Measured on 28/9 with the real model in a memory cage on 64 chunks of ours, zero requests to production (13 scenes, 0 failures): the same text and our model give cosine 1, with a maximum deviation of 2.2·10-12 — and of 3.9·10-12 even across our 1.1 → 1.2 deploy, so the verdict is the same before and after. Another space of 1024 values gives |cosine| ≤ 0.10. In between there is nothing: between the worst of ours (0.7537, one-bit storage) and the best of the strangers (0.1011) there is a factor of 7. It holds with compressed storage too — at half precision (float16, 0.99999998) it even holds the exact verdict, so halving the space costs nothing of this test; with int8 (0.9998) and with one bit (0.7537) the verdict on the space remains, and so it does with a text edited after it was written (0.9755). In place of an external provider — which we do not use and whose vectors we do not have — the «other space» is an isometry of ours: same dimension, same norm, same internal geometry, different basis; a real model trained on its own can only be less aligned with us than that.

Who it is for: whoever has an index written in several goes or moved from one provider to another without (d) — trpg for the 442 chunks they considered lost, and anyone of the six who is building their index right now. (1) If you store float32 the verdict is exact: either the cosine is 1 to twelve digits, or that vector is not ours. (2) You do not need the whole index to know whether it is mixed: 29 chunks sampled are enough to notice a 10 % contamination with 95 % confidence (5 if it is 50 %, 299 if it is 1 %). Labelling a whole index of 471 chunks costs two minutes of service, once only.

How to adopt it: nothing to change in the client. The six-step protocol is written to be rewritten in your own language, because on the Agile boxes the harness forbids running code downloaded from outside: the provenance of vectors (copy: artefacts), thresholds in the API too. The trap, the only one that throws the whole index off at once: if the indexer prepares the text before sending it (title in front, cut, a prefix like query: , lower-casing), the stored vector is of that text. Measured: cosine 0.7548 in the worst case, so it does not make you say «stranger» (the verdict on the space stays «ours»), but the exact 1e−6 threshold no longer holds on any chunk and whoever uses only that condemns a healthy index entirely. Re-embed through the same function as the indexer. And a sanity check on your own bench before believing it: a chunk you know is ours must give 1, a vector of normalised random numbers must give something around zero; if the second is not small you are comparing them wrongly (unnormalised vectors, or bytes read with the wrong endianness).

2026-09-28 — Does a deploy of ours move the vectors in your index? Measured: only the large requests, and by 4·10-12 of cosine

What changes: in the service, nothing. What changes is that a promise we made you now has a number underneath it. We asked you to re-measure the same texts after the 1.2 deploy and to stop if the cosine against your frozen reference drops below 0.9999. That threshold was a promise of ours and nobody had verified it — and there was a reason to doubt it: 1.2 splits large requests into several passes through the model (batches on a token budget, so as not to run out of memory), while 1.1 sends them in one go. Grouping the texts differently means different padding, and in floating point the last bits can change.

Measured on the real model by putting the two paths side by side on the same model object, so that the only variable was the grouping: a request that fits in a single pass (up to 16 texts, or short texts) gives bit-for-bit identical vectors, cosine 1.00000000; a request that exceeds the budget and is split — 10 texts of 1,822 tokens, 2 passes — moves 2 vectors out of 10, worst cosine 0.9999999999960902 and maximum deviation on a component 3.06·10-7. On reranking the order does not change, neither at 10 nor at 40 documents (scores within 4·10-11).

Who it is for: all six products that have to re-measure after the deploy. The threshold allows 10-4 and the deploy consumes four hundred-millionths of it: the rule therefore becomes stronger, not weaker — if your «after» measurement drops below 0.9999, it is not the change of grouping: it is a real fault, and it is ours. Stop and write to us, as agreed. Without this measurement, a customer who saw 0.9999999999 instead of 1.00000000 would have written to us about a non-existent fault — the same kind of false alarm as the comparison of names, below.

How to adopt it: nothing to change. One piece of advice that makes your measurement more sensitive: freeze the reference on short sentences, few per request, so that the request always fits in a single pass, the before/after comparison comes back exact bit for bit and any movement you see is real. It is what trpg did, and it is why their line says 1.00000000 and not «about 1». The detail with the table is in the API and on the protocol page. The cost was measured too: budgeted batches cost no time (65.2 s → 64.4 s on the long texts), they keep memory down at the same speed and with the same vectors.

2026-09-28 — Rule 4 had a stumble: (a) is compared on the dimension, never on the name (and the dimension is counted on the floats)

What changes: in the service nothing, today and with the 1.2 deploy (measured on both versions, identical outcome). What changes is Rule 4, which as of this morning advised «at start-up a GET /health and compare with your index» without saying on which value. trpg put it into operation that same evening and sent the stumble back to us: in /health the model field is the underlying model (bge-m3), while /v1/models lists the catalogue name (agilev-m3), which is also the one that comes back from /v1/embeddings. They are two vocabularies: whoever reads (a) as «compare model and dim» and compares the name finds a mismatch at every start-up, with the service perfectly healthy. The counter-proof we measured after their report is sharper still: /v1/embeddings says agilev-m3, /embed says bge-m3, and the two vectors are identical bit for bit. The name is not a health signal; the dimension is, because it is measured. And there is a second false alarm, which nobody had reported to us yet and which we found by measuring: len(vector) == dim holds only on the floats — with output_dtype binary or ubinary a 1024 vector arrives as 128 values (1024 bits packed) and with encoding_format: base64 as a string: 7 combinations out of 10 would trip the guard wrongly. And a guard that discards wrongly, in silence, is exactly the damage Rule 4 is about.

How to adopt it: if you use our three clients, copy them again (agilev_client.py, agilev_client.php, agilev_langchain.py): (a) and (c) are now inside, in a single line at start-up — av.controlla_dim(1024), $av->controllaDim(1024), emb.controlla_dim(1024). It reads dim from /health (no key, no consumption), stops with DIM_MISMATCH if it is not your index's dimension and does not look at the name; from that moment a vector of the wrong size is a BAD_RESPONSE («attesi vettori da 1024 valori, ricevuti da 512») instead of entering the index. The rule now also has a (d), and it is trpg's: write the model next to the chunk at the moment you build the index — theirs has 442 from one provider and 29 from ours, all at 1024 values and no field saying which, and the information needed to notice was never declared. It costs nothing today; deriving it tomorrow is possible, at one call per chunk — it is countermeasure (e), measured in the entry at the top of this page, and it corrects the «impossible» we had written here.

2026-09-28 — Our clients made our errors mute: fixed, and three rules for whoever writes the client themselves

What changes: in the service, nothing. What changes is the three reference clients (agilev_client.py, agilev_client.php, agilev_langchain.py): copy them again, no signature has changed and on the happy path nothing changes. Two defects, both found by trpg on their own client on 28/9 and then measured on ours. (1) error and detail are structures, not strings: stuffing them into a message produces the word Array in PHP and the repr of a dictionary in Python. At their end every error of ours became «Voyage API error: Array» — and not in a log, in the response of their API to a user: KEY_MISSING, KEY_INVALID and RATE_LIMIT never reached the reader. In our clients the same thing happened on the 422, the error a new client meets first: code ? and a message full of your texts, because detail[].input is the echo of what you sent. Now the 422 becomes BAD_BODY with corpo della richiesta non valido - body.input: Field required — only loc, msg, type, never the echo. (2) A 200 of unexpected shape (a mutilated batch, a proxy in the middle) passed as a success with fewer vectors than texts: at trpg it came back as NULL in silence, in our clients it came back as a shorter list. Now it is a BAD_RESPONSE error with attesi 2 elementi in data, ricevuti 1. BAD_BODY and BAD_RESPONSE are put there by the client, not by the service: do not look for them in our responses.

Who it is for: everybody, but above all whoever writes the client in house — which is nearly everybody, because the harness of the Agile boxes forbids running code downloaded from outside. The four rules, measured and not advised by eye, are in the API documentation and on a standalone page to copy: the errors seen from your client. The fourth they found themselves by pulling the thread of a warning of ours, and it says that «an empty list is not an answer»: an index built with one model and queried with another does not give an error, it gives zero results. The third is the one that dirties an index without making any noise: count that data has as many elements as the texts you sent and that the index values are 0…n-1; if fewer arrive and you pair them with a zip, you index the wrong text with the wrong vector and notice months later, as search quality getting worse.

How to adopt it: copy the three files again from the examples; or, if the client is yours, go through the three points one by one. The one-minute test: make a call fail for real (a wrong key, and a body with a missing field) and look with your own eyes at the message that reaches the reader. If it contains «Array», the repr of a dictionary, a ? or a piece of your texts, you have found the defect. The measurement on ours: 12 scenes × 3 clients — rows without a code of ours from 15 to 3, rows with the echo of your texts from 9 to 0, silently misaligned vectors from 3 to 0; 17 new tests and 11 mutations that make them fail.

2026-09-28 — The model field is not validated today: no name is rejected, and the four conditions before it becomes a 400

What changes: in the service nothing, today and with the 1.2 deploy. What changes is that it is now written down, and measured: a bench tries 4 endpoints × 9 names on the 1.1 code in production and on 1.2 and the outcome is identical on both — zero 400. No model name is rejected: voyage-3, modello-che-non-esiste and even "" or null receive a 200, served and billed like all the others. The model of an embedding request sent to the rerank works, and vice versa. /embed does not look at the field at all. The complete table is in the API documentation.

Who it is for: whoever takes the model name from an environment variable or from a configuration file — that is, nearly everybody. Two practical consequences. The first: a wrong name today gives no error, so a typo in the configuration stays invisible until you look for it. The second, worse: "" and null are not even counted as unknown, so an empty environment variable leaves no trace anywhere. There is only one check that holds, and it is free: every response declares the model used (/v1/rerank → "model":"agilev-rerank", /v1/embeddings → "model":"agilev-m3", /embed → "model":"bge-m3"). Compare that, not what you sent. Asked for by trpg on 28/9, after they measured it from their own box: «we want to notice before our customers do».

How to adopt it: nothing to do today. When the validation arrives — 400 MODEL_UNKNOWN — it will not arrive as a surprise: first all four of these things will happen, in this order. (1) The unknown-name counter stays at zero steadily: today it is nothing of the kind — read in production on 28/9, 3 of the 6 successful rerank requests since the restart of 27/9 carried an unrecognised name. (2) The counter becomes attributable: today it is a total per operation, with no key; we would know that somebody is getting it wrong but not whom to warn, and until we know whom to write to, nothing changes. (3) An entry here and a direct message to every key holder, with at least 30 days between the notice and the change. (4) The change ships in a deploy on its own, never together with anything else, so that if it breaks something it is clear what to roll back.

2026-09-28 — The bodies AgileV does not accept: /embed wants texts, /v1/rerank wants documents (and the 422 has no code)

What changes: in the service nothing; what changes is the page, which until now said nothing about two real errors. The first on-site test to come back from a customer (trpg, 28/9 17:53Z: 22 checks, 0 failures, cosine 1.00000000 on three sentences, median 33 ms from their box) marked the historical format as «broken»: it was not broken, it was the body. POST /embed accepts only {"texts":[…]}; with the OpenAI body {"model":…,"input":[…]} it answers 422. Symmetrically, {"texts":[…]} on /v1/embeddings gives 422. On the rerank the opposite of what it seems holds: the model name makes nothing fail — {"model":"agilev-m3", …} on /v1/rerank works and answers "model":"agilev-rerank" — while documents is required and input does not exist there. Measured today on the 1.1 code in production and on 1.2: it does not change with the deploy.

The 422 does not have our shape {"error":{"code":…}} (here we had written that it was the only one: that was false, and on 29/9 we counted them — there are four, see the entry of 29/9): it is FastAPI's body (detail[], no code), it arrives before authentication (wrong body and missing key = 422, not 401), it consumes neither quota nor per-minute requests, it carries no X-RateLimit-* headers — and it sends back what you sent: a batch of 64 texts (89,600 characters) comes back as 89,909 bytes of error. If your client logs error bodies in full, those texts end up in your log.

Who it is for: whoever is bringing in a new client or migrating from an OpenAI SDK (trpg's case), and anyone writing error handling: today, branching only on error.code leaves the 422 uncovered, and it must be recognised from the HTTP status. Whoever is already working need do nothing: no valid body changes behaviour.

How to adopt it: nothing to change. For a new client use /v1/embeddings with the OpenAI body; /embed stays frozen for whoever integrated AgileVoyage. In your error log record the status and the error.code, not the whole body. Giving the 422 the shape of the other errors (with a stable code, without sending your texts back) is a change of contract: it will be assessed after the 1.2 deploy, announced here before it is done. Updated table in the API documentation.

2026-09-28 — A test to run on your own box: agilev-prova-dal-posto.sh

What changes: a script is born for you to run on your own box, not on ours: 17 checks in one command, only bash, curl and python3, no libraries. It checks the network and the certificate as seen by you, /health and /v1/models, that the errors are the documented ones (401 KEY_MISSING, 401 KEY_INVALID), who your key is and which scopes it has, the historical /embed format and the rerank, the timings on your side and that your usage counters move by exactly what you spent. The check that really counts is another one: the cosine against a frozen vector. The fixed sentence «Il contratto scade il 30 settembre» has its vector frozen inside the script; if the cosine stays ~1, the vectors you receive today are compatible with the ones you already have in Qdrant, in pgvector or in MySQL. If it drops, your index is no longer comparable with the new queries: it is the most silent fault AgileV can have and it is only visible from there.

Who it is for: all those who hold a key (NIS2, LG231, trpg, taxai, aiepod, the core when it moves over). From our box we know that the service answers and how long it takes, but we do not know whether your network, your key and your client are well: until now the only answer was «nobody has complained».

How to adopt it: the page artefatti.agile.software/agilev-prova-dal-posto.html carries the script inside itself and one line re-extracts it byte for byte (the expected sha256 fingerprint is written on the page); after the next site alignment the same file will also be here, on prova-dal-posto.html and in esempi/agilev-prova-dal-posto.sh. Send us back only the last line, the one that starts with RIGA:; never the key. It does not read your data: it sends three fixed sentences written in the file, never prints the key and never lets it appear in ps. In full mode it costs 7 requests of a few dozen characters, counted in your usage; with --niente-consumo it only does the checks that do not touch the invoice. When to redo it: after every deploy announced here — the next one is the move to contract 1.2 — and before any large reindexing.

Update of the evening of 28/9: if your box cannot run code downloaded from outside (it is the rule of trpg-01's harness, and it is a good rule), you do not need our file: the same test is now described as a protocol — checks, expected results, rules of conduct and the format of the RIGA: line — on protocollo-prova-dal-posto.html, to be rewritten in your own language as trpg did. With one advantage: freeze the cosine reference yourselves, in your own repository, now that the version you indexed with is still online. What we need is the line, not our script.

2026-09-22 — spezza() in the reference clients: long documents are split before being sent

What changes: the reference clients (esempi/agilev_client.py and .php) have one more function, spezza(testo, massimo=1500, sovrapposizione=150, intestazione="") (in PHP AgileV::spezza): chunks of at most 1,500 characters cut where the text cuts itself (newline, end of sentence, then between words, mid-word only if a word is longer than the chunk), with the last whole sentences of the previous chunk repeated at the head of the next (up to 150 characters) and the heading you give it («Art. 25-septies Rubrica») repeated in every chunk. Python and PHP produce the same chunks (a test demands it). The service does not change: it is code that runs at your end.

Who it is for: whoever indexes whole documents (NIS2 and LG231 with articles of law, trpg with data sheets): an article of 55,928 characters today is a 413 TEXT_TOO_LONG; with spezza() it is 44 chunks that fit in one request (measured: 40 s, 66 chunks a minute), each with the article number at the head, which is what makes citations findable by number. On the 153 articles of the legislative corpus: 468 chunks, none beyond 1,500 characters or 400 tokens, every sentence found whole in at least one chunk.

How to adopt it: copy the client file into your project and call av.embed(spezza(testo, intestazione=titolo)); the 1,500-character size is the one from our tests and on 22/9 we compared it with 1,000 and 2,000 on the 153 articles: the hybrid sits between 0.754 and 0.779 nDCG@10, one or two queries out of 69, no clear winner, so the default does not change; 2,000/200 is the best and cheapest size on long legislation (362 vectors instead of 460, same computation time), so whoever indexes articles of law can pass massimo=2000, sovrapposizione=200: they are parameters. With LangChain: AgileVSpezza(intestazione_da="titolo").split_documents(docs) in esempi/agilev_langchain.py does the same thing in place of RecursiveCharacterTextSplitter (which does not repeat the heading), with the metadata inherited plus frammento/frammenti. It already works with 1.1: it does not depend on the deploy. The updated files are in the repository and reach the site at the next alignment (no restart). Details in API, «Performance».

2026-09-22 — The 429 says when to retry: Retry-After and X-RateLimit-* (in production since 29/9/2026)

What changes: on a 429 RATE_LIMIT the service sends Retry-After with the seconds left to the new minute (from 1 to 60: the window is the minute, the number is exact), and on every authenticated response three informative headers: X-RateLimit-Limit (your key's requests per minute), X-RateLimit-Remaining (how many are left in this minute), X-RateLimit-Reset (seconds to the new minute). The 429 QUOTA_EXCEEDED has no Retry-After: the quota restarts with the month. Error bodies and codes unchanged; key errors (401, 403) carry no rate headers.

Who it is for: whoever indexes in batches (aiepod, which makes 84 % of the requests, and trpg): instead of guessing with 1-2-4-8 s you wait for the right number and start again at the new minute, neither earlier (another 429) nor later (wasted time). X-RateLimit-Remaining lets you slow down before the 429.

How to adopt it: nothing to do. Whoever uses the reference client (esempi/agilev_client.py / .php, updated today) already has it: it waits exactly Retry-After when it is there (up to 60 s) and uses the growing wait (capped at 30 s) only when it is not; a reused connection that the server closed in the meantime (expired keep-alive) is redone at once without counting as a retry. Since 22/9 (same 1.2) the two clients also brake before the 429: when a response says X-RateLimit-Remaining: 0 the next request waits X-RateLimit-Reset seconds before leaving, so your log no longer sees rate errors (measured on 27/9: 5 requests with a key limited to 2 per minute, 429s received 2 → 0, and the same total time, because the window is the clock minute: the brake does not slow you down, it removes the pointless requests). Details in API, «Errors». Status: ready and tested (220 fast tests and 10 on the real model, rerun on 27/9; tests against the 1.2 development service), in production since 29/9/2026 with the 1.2 deploy: GET /health answers contract: 1.2 and the headers are there.

2026-09-21 — Python and PHP reference clients: the integration rules already written into the code

What changes: two files to copy into your project, esempi/agilev_client.py (standard library only) and esempi/agilev_client.php (only ext-curl/ext-json), with the same AgileV class: embed(testi), rerank(domanda, candidati), usage(), health(). Inside are the rules we learned by measuring September's traffic, so that you do not have to rediscover them: the TLS connection is reused (the handshake is paid once), batches leave one at a time (64 texts, maximum 128) with a 120 s timeout, the limits (128 texts, 32,000 characters) are checked before sending, retries happen only on 429 RATE_LIMIT, 5xx and dropped connections with a growing wait (or Retry-After), never on 400/401/403/413 and on an exhausted quota, and on a timeout the batch is halved instead of being repeated as is (the worst case of 12/9: 47 identical batches put back in the queue by a timeout at 31 s). The rerank reads the first 800 characters of each candidate, the value measured on 21/9. No change to the API, no restart: they already work with 1.1 (the sparse part and abandoned requests arrive with 1.2).

Who it is for: trpg (PHP: the short timeout and the retries of 12/9 can no longer happen), aiepod, NIS2, LG231 and anyone writing an indexer; for LangChain there is still agilev_langchain.py.

How to adopt it: copy the file, put the key in AGILEV_KEY, with AgileV() as av: vettori = av.embed(testi) (Python) or $av = new AgileV(); $vettori = $av->embed($testi); (PHP). python agilev_client.py --prova / php agilev_client.php --prova do a complete round (health, embedding, rerank) and print the counters (requests, retries, halved batches, connections). Tested against service 1.2 and against a fake server that simulates 429, 500, quota and timeouts (10 tests, the same scenes for Python and PHP). They record nothing: no text in the logs.

2026-09-21 — A public status page (in production since 29/9/2026)

What changes: the status page is born: the service's availability read from the browser (/health, with the response time), the contract version, the time of the last start-up, and the counters aggregated over all customers for the current month (requests and texts per operation, how many customers have an active key, how many texts were served from cache). The stato.json file is regenerated every 5 minutes on the server by a timer that reads the counters read-only; no change to the API, no restart.

Who it is for: whoever wants to know at a glance whether AgileV is up before opening a ticket, and whoever has to show an end customer that the service exists and is used.

What is NOT there, by choice: no texts, no customer names, no keys, no per-customer usage (each of you sees only your own with GET /v1/usage), no monthly average time (it would mix batch indexing with interactive questions: the per-operation timings stay in the documentation).

How to adopt it: nothing to do. Whoever wants an automated check reads /health (JSON, no key) as before.

21/9/2026Contract 1.2: lexical weights (return_sparse) for hybrid search. Why: article citations.

Status: in production since 29/9/2026 (the 1.2 deploy at 18:09 Rome time). The page answers: verified from outside on 29/9, https://agilev.agile.software/stato.html → 200.

What we measured (and why we are telling you)

What changes

Who it is for

How to adopt it

15/9/2026Phase 2a in production: cache and micro-batching. Public documentation.

What changed

Who it is for

How to adopt it

14/9/2026Contract 1.1: parity with Voyage, real token counting, compressed vectors

What changed

Who it is for

How to adopt it

11/9/2026AgileV is born (contract 1.0)

What changed

Who it is for

How to adopt it