# Backward compatibility

The promise DALICC makes to the clients nobody can enumerate: what is frozen, every
way this release differs from the service that was live on 2026-09-14, what is merely
additive, and how all of it is tested. Written for anybody who calls the API or
changes it.

Part of the [documentation index](../README.md#documentation).

*Contents:* [1. The contract](#1-the-contract) ·
[2. Every observable change](#2-every-observable-change) ·
[3. What is additive](#3-what-is-additive) ·
[4. How the promise is tested](#4-how-the-promise-is-tested) ·
[5. Deprecation policy](#5-deprecation-policy)

The endpoints themselves are described in [API.md](API.md).

---

## 1. The contract

The backward-compatibility surface is ten operations:

1. `GET /licenselibrary/license/{license_id}`
2. `GET /licenselibrary/list`
3. `POST /licenselibrary/facetedsearch`
4. `POST /licenselibrary/composer`
5. `GET /dependencygraph/list`
6. `POST /compatibilitycheck/` (with the trailing slash)
7. `GET /githublicensechecker/dependencies/{owner}/{name}`
8. `GET /githublicensechecker/dependencies?github_url=`
9. `GET /githublicensechecker/check/{owner}/{name}`
10. `GET /githublicensechecker/check?github_url=`

plus the undocumented but publicly reachable `/web/*` fragments, `/static/*`, `/spec`,
`/docs`, `/redoc` and `/openapi.json`.

**The guarantee.** For those operations, paths, parameter names, parameter types,
defaults and response keys do not change. A client that worked against
`https://api.dalicc.net` on 2026-09-14 and sends the same requests receives the same
bodies, with the exceptions listed in [section 2](#2-every-observable-change), each of
which is either a fix to a response that was wrong or an addition that a client only
sees if it opts in.

**Four quirks are frozen deliberately**, because clients parse them today:

* `/list` and `/facetedsearch` return the raw SPARQL-JSON envelope of the triple
  store, not a list, and `title` bindings carry `xml:lang` inconsistently.
* `conflicting_statements.direct` and `.derived` are JSON objects keyed by stringified
  integers, and an empty result is `{}`, not `[]`.
* `/compatibilitycheck/` takes full license IRIs while `/licenselibrary/license/{id}`
  takes bare ids.
* `format=ttl` and `format=rdf-xml` return a JSON-encoded string with
  `Content-Type: application/json`.

### The version strings

Two numbers are called "the version" and they do not move together.

* **The application** is versioned with semantic versioning. `version` in
  `pyproject.toml` and `version` in `sdk/pyproject.toml` hold the same number, `2.0.0`
  once the release is cut; until then the application says `2.0.0.dev0` and the client,
  already on PyPI as a release candidate, `2.0.0rc2`. The major bump is about the
  deployment and the internals; it says nothing about this contract.
* **The API** is `info.version` in the OpenAPI document, `1.1` since this release, up
  from the `1.0` production answered. A client written against `1.0` keeps working.
  `info.title` stays `"DALICC API"`, because clients read the title to identify the
  service. The details are change 47 in
  [section 2](#2-every-observable-change).

* **Version 2 of the API** is `2.0`, under `/v2`, with its own OpenAPI document at
  `/v2/openapi.json` (`info.version` `2.0.0`) and its own Swagger UI at `/v2/docs`. It
  holds the history, release and discovery operations and nothing of version 1: version 1
  keeps its paths, its shapes, its headers and `/openapi.json` with `info.version` `1.1`,
  and no `/v2` path appears in that document. `GET /v2` names both versions, their
  documents and their status ([API.md section 11](API.md#11-version-2-history-releases-and-discovery)).

The OpenAPI document itself is `3.1.0` rather than `3.0.2`, which is the version of the
generator and not of the API (change 30). How and when the two numbers are set is
[section 11 of DEVELOPMENT.md](DEVELOPMENT.md#11-cutting-a-release).

## 2. Every observable change

Numbered once and never renumbered, so that a reference to "change 12" keeps meaning
the same thing. "Impact" describes what a client that was working before sees now.

### `GET /licenselibrary/license/{license_id}`

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 1 | Unknown id | `200` with `{"@context": ..., "@graph": []}` | `404` with `{"detail": "Unknown license id: ..."}` | A bare `except: pass` swallowed the parse error, so "no such license" was indistinguishable from "a license with no statements". Every client had to special-case an empty `@graph` | A client that tested `len(@graph) == 0` now sees a `404`. Both mean the same thing; the status code is the honest one |
| 2 | Malformed id | `500`, or an odd file path touched on disk | `422` with a `detail` | The id flowed unvalidated into a filesystem path and into SPARQL. It is now validated against `^[A-Za-z0-9._-]{1,128}$` | Only malformed requests are affected |
| 3 | Store unreachable, id not on disk | `200` with an empty `@graph` | `503` | A user-composed license lives only in the triple store, so a store outage used to be reported as "this license does not exist" | Retryable failures are now distinguishable from permanent ones |
| 4 | `download=true` | `500` (it wrote into a gitignored `app/temp/`) | The serialisation as an attachment with the correct media type | The path was simply broken in production | A route that returned `500` now works |
| 5 | Composed licenses | `200` with an empty `@graph` | The document, resolved from the `customlicenses` graph | 62 licenses that real users composed were write-only: they were handed a URI that resolved to nothing | Strictly more ids resolve |
| 6 | `Accept: text/turtle`, `application/rdf+xml`, `application/ld+json` | Ignored; the JSON-wrapped default was returned | The raw serialisation with the matching media type. For `application/ld+json` that is JSON-LD compacted with the frozen `@context` and with every object reference kept as `{"@id": ...}`, so it parses to the same triples as the Turtle answer | Additive content negotiation, and the only way to get real Turtle without the frozen JSON wrapper, or JSON-LD whose references are not flattened into strings | Opt-in. Before this change was deployed, the negotiated JSON-LD briefly carried the flattened default shape; it was corrected, and the default without `Accept` is unchanged |
| 7 | `format=rdf-xml` byte order | A particular block order from the old rdflib | A different block order from the current rdflib | Not controllable without pinning an ancient rdflib. The contract test asserts **RDF isomorphism**, so the graph is identical | A client diffing bytes of RDF/XML breaks; a client parsing RDF does not |
| 8 | Id case | `Wtfpl` and `WTFPL` behaved differently (the file on disk is `Wtfpl.ttl`) | Both resolve to the same license | The filename casing was an accident of the data, not a decision | Strictly more ids resolve |
| 60 | An id that resolves to nothing, but was retired on 2023-04-24 or is an SPDX identifier a record declares | `404` | `301` to the successor's document for a retired id with a successor, `410` with a `detail` for one without (six test records), `303` to the declaring record's document for an SPDX id such as `ODbL-1.0`; the query string is kept | 33 IRIs published before 2023-04-24 ended in a `404`, and a client holding an SPDX id had to call `/licenselibrary/spdx/{id}` first. The table is `licensedata/retired-identifiers.yaml` (docs/DATA.md section 8) | Only ids that answered `404` change; every id that resolved is served exactly as before. A client that does not follow redirects sees a `3xx` with a `Location` instead of a `404` |

`Accept: text/html` still redirects to `/web/license/{id}`: a `307` with an absolute
`Location` on the API host, which is what production sends (this page
said `302` until it was checked against production). Any `Accept` that contains
`text/html` redirects, even one that ranks an RDF type higher, as production does.
The default JSON-LD body for
an existing id is byte-identical, which the snapshots for Apache-2.0, MIT and
CC-BY-4.0 prove.

### `GET /licenselibrary/list` and `POST /licenselibrary/facetedsearch`

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 9 | `skip` and `limit` | Declared in the signature, never applied: every call returned all 343 rows | Applied **only when the client actually sends them** in the query string | Applying them unconditionally would have silently truncated every existing client to 10 rows, which is the default | A client that omits them sees no change. A client that sends them gets the paging it always asked for |
| 10 | Non-integer `limit` on `/facetedsearch` | Ignored | `422` | The parameter is typed as a string in the published schema, and it stays a string, but the value must be a number | Only malformed requests |
| 11 | SPARQL injection through `keyword` | A quote in the keyword produced `500` | The value is escaped and matched literally | The input reached the query unescaped on a store that had anonymous SPARQL UPDATE enabled | A keyword containing a quote now returns results instead of an error |
| 11a | Two test-fixture records left the listings | `SampleLicenseSl` and `DeveloperLicense` were listed like any other license | Both are excluded from `/list`, `/facetedsearch`, the site listing, the search, the autocomplete, the mixer dropdown and the homepage count | Neither is a published license. `SampleLicenseSl` is the fixture the composer is tested with: no `cc:legalcode`, no `dct:source`, no legal text to check it against. `DeveloperLicense` is an evaluation license kept as a fixture. The 2026-09-15 content review marked both `dalicc:recordStatus dalicc:testFixture` (decision 13) | **Two rows fewer.** Both documents keep resolving at `GET /licenselibrary/license/{id}` in all three serialisations, and both license pages still work |
| 11b | `ports` on `/list` and `/facetedsearch` | Did not exist | `include` (the default, unchanged), `exclude` (parents only), `only` (ports only); anything else is `422` | 290 records are the same license adapted to another legal system, which the data now says with `dalicc:jurisdictionPortOf`. Clients that want the distinct licenses had no way to ask | Additive. A client that omits the parameter sees exactly what it saw before |

The envelope, the `head`/`results` structure, the binding shape, the `http:` to
`https:` normalisation on `/facetedsearch` and the sort order are unchanged.

### `POST /licenselibrary/composer`

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 12 | The disabled stub became the real operation | `200` with the body `null` | `401` without a token; with a valid Bearer token `201` and `{"id", "uri", "status", "version", "conflicts"}`, `409` with the conflicts, `422` for an unusable body | The token-gated API was documented from the first release ("get in touch to receive your personalized access token") and never implemented; `null` looked like a successful empty response | No client can have depended on `null`, and none could have had a token. The path, tag, `operationId` and summary are unchanged; the published **description** now describes the operation instead of asking the reader to get in touch, and the operation gains a request body and the `publish` parameter |
| 62 | An account that waits for an administrator's approval (self-registration) | Could not exist: accounts came by invitation only | `403` "The account waits for an administrator's approval." for a token of such an account, and nothing is published | An installation that opens sign-up with approval must not publish for an account nobody has looked at | Only a token of an account created on the sign-up form of an installation that asks for approval meets it; the answer is the documented `403` shape |

The description is the one field of a documented operation that changes, and
`DESCRIPTION_EXEMPTIONS` in `tests/contract/test_contract.py` exempts that one field so
that the guard keeps checking every other one.

### `POST /compatibilitycheck/`

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 13 | Malformed license IRI | Forwarded into the ASP program | `422` | The value was written into a logic program unescaped, which was a code-execution path | Only malformed requests |
| 14 | Reasoner failure | `200` with the JSON string `""` | `502` with a `detail` | `""` was indistinguishable from a valid answer, so a broken solver looked like "no conflicts found", which is the dangerous direction for a license-clearance service | A client that treated `""` as "no conflicts" now sees an error, correctly |
| 15 | Reasoner timeout | Hung until the client or nginx gave up (`proxy_read_timeout` was 86,400 seconds) | `504` after `DALICC_REASONER_TIMEOUT_SECONDS` (60) | There was no timeout anywhere in the chain | Bounded latency |
| 16 | The empty verdict | `{"conflicting_statements": {"direct": {}, "derived": {}}}` | Identical | Measured against hexlite 1.4.1: the no-conflict case always produced the empty object; `""` was only ever produced by a failure | None |
| 17 | `X-DALICC-Compat: 2` | Not understood | Forwarded to the reasoner, where it guarantees the empty object in the one residual branch (solver exits 0, prints no answer set) | Opt-in normalisation for clients that want a single shape under all circumstances | Opt-in |
| 18 | Rate limit | None | 60 per minute per client address, then `429` with `Retry-After` | Each request starts a solver process and queries the store per license, unauthenticated | Only clients above the limit |
| 18a | `"dependency_graph"` in the body | Did not exist; every check used the graph the deployment is configured with | An optional identifier or IRI. Omitted, the request and the answer are exactly what they always were. A published **core** graph is accepted from anybody; a graph belonging to an account needs that account's session or API token, and is otherwise `403`. An unusable value is `422` | The dependency graph is the knowledge base behind the verdict, and it is now an owned, versioned object. A caller that reasons with its own graph has to be able to say so | Additive and opt-in. The field is absent from every existing request, so nothing changes for a client that does not send it |
| 18b | `"dependency_graph"` in the response | Did not exist | Present **only** when the caller sent field 18a and the resolved graph differs from the configured default; its value is the named-graph IRI that was used | An answer reasoned with a non-default graph has to say so, or it cannot be reproduced. A caller that chose nothing must keep the byte shape the contract promises | A client that omits `dependency_graph` sees the same keys as before. One that sends it sees one extra top-level key beside `conflicting_statements` |

| 55 | Two licenses that each keep the whole work under themselves (2026-09-24) | No entry: only a permission against a prohibition was weighed, so GPL-2.0-only with GPL-3.0-only, the ODbL with CC-BY-SA-4.0 and BUSL-1.1 with GPL-3.0-only answered with the empty verdict | One direct entry per such pair, both statements `[license, odrl:duty, cc:ShareAlike]`, with the new reason "Direct conflict. Both licenses require the whole work, or every work derived from it, to stay under themselves, and no later-version option or compatibility clause leads from one to the other, so one combined work cannot satisfy both." A pair linked by the same license text, an "or later" option that reaches the other version, or a compatibility clause is not reported ([DATA.md](DATA.md#share-alike-between-two-licenses)) | The empty verdict told a visitor these pairs combine, which is the dangerous direction for a license-clearance service | A client sees one more entry for such a pair; the shape and every existing `reason` string are unchanged. No contract snapshot holds such a pair. The running reasoner needs its image rebuilt |
| 59 | Two such licenses where one names the other with `dalicc:compatibleWith` (2026-09-24) | The change 55 entry: LGPL-3.0-only with GPL-3.0-only, CC-BY-SA-4.0 with GPL-3.0-only and AGPL-3.0-only with GPL-3.0-only were reported as conflicts | One direct entry per stated direction, `statement_1` `[license, dalicc:compatibleWith, other license]`, `statement_2` `[other license, odrl:duty, cc:ShareAlike]`, with the new reason "Direct restriction. Both licenses require the whole work to stay under themselves, and the first names the second as a license a work under it may be released under, so the combined work has to be released under the second." The named record may be one hop from the other license (the same text, or an "or later" option that reaches it) ([DATA.md](DATA.md#share-alike-between-two-licenses)) | The texts grant the compatibility, so calling the pair a conflict was wrong | A client sees a restriction entry where it saw a conflict, only for records that carry the property; the shape and every existing `reason` string are unchanged. No contract snapshot holds such a pair. The running reasoner needs its image rebuilt |

The conflict object, the stringified-integer keys and the
`statement_1`/`statement_2`/`reason` shape are unchanged, and so is every `reason`
string that existed before change 55, which added one, as change 59 did.

### `GET /githublicensechecker/*`

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 19 | No API key configured | The key was hardcoded in the source | `503` with a message naming `DALICC_LIBRARIES_IO_API_KEY` | The committed key was in a public-ish repository and is now revoked | A deployment without a key gets a clear error instead of a burned quota |
| 20 | libraries.io error or unexpected payload | Passed through with `200`, or `500` | `502`; `404` when the repository is unknown; `504` on timeout | An upstream error object returned as `200` is a lie | Error responses are now correct |
| 21 | Non-GitHub `github_url` | Accepted and forwarded into the libraries.io path | `400` | `?github_url=evil.com/a/b` was forwarded verbatim | Only malformed requests |
| 22 | `dalicc_id` | A malformed value from an indexing bug on the response of the DALICC lookup | The full DALICC IRI, or `null` | The field name always promised an identifier, and the compatibility check needs the IRI | The endpoint was very likely failing in production; it now returns what the schema describes |
| 23 | The DALICC lookup | One public HTTPS round trip per dependency back to `https://api.dalicc.net` | In process | N+1 external calls per request, which also meant a developer laptop queried production | Faster and functional; the response shape is unchanged |
| 24 | Rate limit | None | 30 per minute per client address | The endpoints spend a paid quota, unauthenticated | Only clients above the limit |
| 56 | `dalicc_id` of a license filed under another DALICC id (2026-09-24) | `null` for `MPL-2.0`, `ISC`, `LGPL-2.1` and every other SPDX identifier whose record is not filed under it, and those licenses were left out of `compatibilitycheck`; one store lookup per dependency row, and a warning for every unknown license | The IRI of the record the SPDX mapping names, aliases included; the value is tried as a DALICC identifier only when the mapping knows nothing; one lookup per distinct license per request; an unknown license is logged at DEBUG | `null` promised "the library does not know that license", and the library knew these | More dependencies carry a `dalicc_id`, so `compatibilitycheck` can hold more licenses and more entries. The shape is unchanged. Neither contract snapshot changed: `github_check_dalicc_dalicc` records a repository without dependencies and `github_dependencies_psf_requests` is the libraries.io document passed through |
| 64 | Description of `GET /githublicensechecker/check/{owner}/{name}` (2026-09-25, description only) | Its `target` paragraph called the combined check "the question the License Mixer asks" | "the question the License Compatibility Checker asks", the page's name since 2026-09-25 | The page it names was renamed | None on any response: the ten published operations answer byte for byte as before. The OpenAPI snapshot records no description for this operation (the `target` parameter came later), so the contract comparison is unaffected and no snapshot was refreshed |

`/dependencies` still passes the libraries.io document through verbatim.

### Every response

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 25 | CORS | `GET` only, `allow_credentials=True`, a fixed origin list | The configured origins, `GET`, `POST` and `OPTIONS`, `allow_credentials=False` | Browser clients could not call the POST endpoints at all, and credentials were never used | Strictly more permissive for the methods that matter, strictly less for credentials, which nothing sent |
| 58 | CORS for anonymous reads | Only the configured origins, and no header exposed | An anonymous `GET` or `HEAD` of an API path or of `/ns` (no `Authorization` header), and its preflight, answer `Access-Control-Allow-Origin: *`; a `POST` or a request with a token keeps the configured origins. Every answer exposes `X-RateLimit-*`, `Retry-After`, `X-Request-ID`, `Content-Disposition`, `ETag`, `Link`, `Deprecation` and `Sunset` | A third party's browser tool could not read even what anybody may read, and no browser program could read the rate-limit headers | Additive: headers only, on the ten operations too; bodies and status codes are unchanged, and credentials stay off |
| 26 | Security headers | None | `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Frame-Options: SAMEORIGIN`, and for `/web/*` a `Content-Security-Policy: frame-ancestors` list instead of `X-Frame-Options` so the historical iframe embedding keeps working. HSTS and TLS 1.2 or 1.3 at nginx | Baseline hardening | A page that embedded `/web/*` from a host outside `DALICC_FRAME_ANCESTORS` stops rendering. Configure the setting if a third party embeds it |
| 27 | `X-Request-ID` | Absent | On every response, logged by the application and by nginx | Nothing correlated a user report with a log line | Additive |
| 28 | Error bodies | Unhandled exceptions produced a bare `Internal Server Error` text body | `{"detail": ...}` JSON on every API path | The documented shape for `HTTPException` was already `{"detail": ...}`; now it is universal | Uniform |
| 29 | HTML error pages | None | A rendered page, **only** when the request explicitly ranks `text/html` above JSON and RDF, and only on site paths | A browser should not see a JSON blob. `curl`, `*/*` and `Accept`-less requests keep the JSON body the contract tests replay | None for API clients |
| 30 | OpenAPI document | `3.0.2` | `3.1.0` | It is generated by FastAPI, and the current version emits 3.1.0. The API version string is `1.1` since this release (change 47), the title stays "DALICC API", and every operation keeps its `operationId`, path, parameters and description | A generator that cannot read OpenAPI 3.1 needs an upgrade |
| 31 | `GET /` on the API host | `404` | The site home page | Additive: there was no root route | None |
| 32 | `GET /healthz` | Did not exist | Health JSON | Additive | None |
| 47 | `info.version` in the OpenAPI document | `1.0` | `1.1` | The API only gained operations (the composer, the translate endpoint, the dependency-graph endpoints and the account endpoints) and none of the ten operations production published changed, which is a minor bump under semantic versioning. `info.title` stays "DALICC API" | None for a client that calls the API. One that pins the string reads `1.1`; every operation it already used answers exactly as before |

### The `/web/*` fragments

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 33 | `GET /web/` | `200` with zero bytes (`empty.html`) | `302` to `/` | The advertised entry point of the web UI served a blank page | A blank page becomes a real one |
| 34 | `GET /web/compose` | `500` (`compose.html` is not in the repository) | `302` to `/license-composer` | A hard `500` on a public route | A broken route now works |
| 35 | `POST /web/wpcomposer` redirect | `302` to `https://dalicc.net/licenselibrary/<id>`, which by then was a `404` | `303` to `/license-library/<id>`, a page that exists and is served by whatever host handled the request | The composed license was unreachable, which is the whole point of composing it. `303` is the correct status after a POST, and both are followed identically by every client | Users land on their license |
| 36 | Composer validation | Invalid input was written to the store anyway | The form is re-rendered with the reasons at `422`; a consistency conflict re-renders at `409`; nothing is written in either case | Unvalidated triples, including relative IRIs such as `<test>` and `<None>`, went straight into the graph and made the production dump unparseable | A browser sees the errors. The URL, the form field names and the successful path are unchanged |
| 37 | reCAPTCHA | The token was popped from the form and discarded; there was no secret key | Verified server-side when `DALICC_RECAPTCHA_SECRET` is set; a warning is logged and the submission is accepted when it is not | Making it mandatory would break every deployment that has no key yet | None until a secret is configured |
| 38 | Composer rate limit | None | 10 per minute per client address | Unauthenticated writes into the triple store | Only clients above the limit |

Every other `/web/*` URL, its form field names and its rendered structure are
unchanged. Two rendering bugs were fixed along the way: a `"https//dalicc.net/ns#..."`
typo that made warranty disclaimers invisible, and an `if "duty_" in k` test that also
matched `pduty_*`.

### Token requests

Per-account API limits are the one part of the authenticated surface that an existing
client can notice, so it is written down in full.

| # | Change | Before | Now | Justification | Impact |
|---|---|---|---|---|---|
| 44 | `X-RateLimit-Limit`, `-Remaining`, `-Reset` | Absent | On every answer to a request that carries a personal API token, describing the window closest to refusing the next call. `Reset` is a UNIX timestamp in seconds, UTC | A client that is being counted has to be able to see the count, or it can only discover a limit by hitting it | **Additive.** Anonymous requests carry none of them, and neither does a `401`: a rate-limit answer must never confirm that a token exists |
| 45 | `429` for a token client over its account's limits | Only the per-IP buckets could refuse a request, and a token was not counted at all | Four per-account windows apply at once: **5 a second, 60 a minute, 1000 an hour, 10000 a day** by default (`DALICC_RATE_USER_*`), counted per account rather than per token, with `POST /compatibilitycheck/` costing 5, `POST /licenselibrary/consistencycheck` 3 and `POST /licenselibrary/translate` 10. Over a window is `429` with `Retry-After` and a body naming the window, the limit, what is left and when it restarts | An authenticated client is identified by its token, not by its address, so a script rotating through addresses was bounded by nothing that scaled with what it cost | **A client that stays under the defaults sees no difference.** The defaults are far above what the documented operations were ever used at, a refused request consumes nothing, `DALICC_RATE_LIMIT_ENABLED=false` switches the whole thing off, and an administrator can raise the numbers for one account without a restart |
| 46 | A service-wide ceiling on token traffic | None | `DALICC_RATE_GLOBAL_PER_MINUTE` (600), counted in process memory, answering `429` with `window: "global"` and a `detail` that says the service is busy rather than that the caller is over a limit | A stampede brake that must not cost a database write per request | Approximate by design (about `WEB_CONCURRENCY` times the number). nginx `limit_req` remains the hard edge |

None of this touches an anonymous request, a session request or the ten documented
operations called without a token: those keep the per-IP limits of
[API.md](API.md#rate-limits) and carry no rate-limit headers.

### The data

No response **shape** changed here, but the **content** did, and in ways a client
notices.

The 2026-09-15 content review checked every record against the legal text of the
license it models; 621 corrections were applied and 785 proposals were left to the
owner. What that means for a client is listed in
[LICENSE_REVIEW.md](LICENSE_REVIEW.md#observable-behaviour-changes).

| # | Change | Impact on a client |
|---|---|---|
| 39 | **116 records were added**, taking the library from 344 to **460** | `GET /licenselibrary/list` returns 458 rows rather than 342 (the two test fixtures stay out). A keyword search matches more: `?keyword=mit` now also returns `MIT-0` and `MIT-Modern-Variant`. `POST /licenselibrary/facetedsearch` returns more rows on every facet combination the new records satisfy, and `ports=exclude` returns 169 parents rather than 52. No existing identifier moved |
| 40 | **A permissive license permits relicensing again.** `MIT` and 62 other permissive records had gained a `dalicc:ChangeLicense` prohibition, which was a misreading of the notice clause; it was taken off every one of them | `POST /compatibilitycheck/` with `Apache-2.0` and `MIT` reports **no conflict**, which is what production always answered. Any bundle of a permissive license with one that relicenses is affected the same way. 18 published records were versioned for it, so their `dct:hasVersion` moved |
| 41 | **`dalicc:sublicense owl:sameAs odrl:grantUse`** was added to the core dependency graph, in both directions | The graph went to **41 axioms**, not the same 41 it started with: the pair the review removed (`cc:ShareAlike dalicc:contradicts odrl:grantUse`) stayed gone, and change 51 later took the graph to 46. A bundle of a Creative Commons license, which forbids sublicensing, with an MIT-style license, which grants it, reports the conflict it always had; between the two changes the pair came back clean |
| 42 | **The weak-copyleft family was aligned.** `MozillaPublicLicenseVersion20`, `CommonDevelopmentAndDistributionLicense10`, `EPL-2.0`, `EclipsePublicLicense10` and `APSL-2.0` were corrected against the sentences of their own texts and versioned (see their change logs); `CPL-1.0` and `IPL-1.0` were brought onto the same source-duty shape | Those documents carry different statements, so `GET /licenselibrary/license/{id}`, the comparator and any compatibility check involving them answers differently. `EclipsePublicLicense10` lost three statements its text does not support, which can turn a reported conflict into no conflict |
| 43 | **The consistency check was refined**: a compatibility clause and file-level reciprocity are no longer called a conflict | `POST /licenselibrary/consistencycheck` and the composer accept the EUPL, CeCILL, LiLiQ, Mozilla, CDDL, Eclipse and Apple shapes that were refused before |
| 48 | **121 records were added on 2026-09-22**, the licenses GitHub projects declare and the flavours of licenses the library already held, taking it from 460 to **581**. They are 14 or-later and `-only` GNU identifiers with three base texts the library did not hold, 18 SPDX exception combinations, one rider, 22 permissive variants, 16 source-available licenses, 17 model licenses and 24 Creative Commons legal codes of versions 1.0 to 3.0, plus CC-PDDC, four LaTeX Project Public License versions and OFL-1.0 | `GET /licenselibrary/list` returns 579 rows rather than 458, and `ports=exclude` 290 parents rather than 169; `ports` still means jurisdiction ports and every new record is an ordinary row. A keyword search and `POST /licenselibrary/facetedsearch` match more. `GET /licenselibrary/spdx/{spdx_id}` resolves 276 identifiers rather than 179, among them the SPDX **expressions** of the exception records (`GPL-2.0-only WITH Classpath-exception-2.0`, with the space percent-encoded or written as a `+`) and, through the new `aliases` table, the 35 deprecated identifiers and `+` forms that GitHub and the package managers still report (`GPL-2.0`, `GPL-2.0+`, `LGPL-2.1+`, `bzip2-1.0.5` and the rest); 26 of the 35 reach a record and the other nine answer `404` as before. `GET /licenselibrary/spdx-mapping` gains the `aliases` object and the `aliases_resolvable` count. The regex the route validates against widens from `[A-Za-z0-9.+-]{1,128}` to `[A-Za-z0-9.+ -]{1,160}`, so a value that used to answer `422` may now answer `200` or `404`. No existing identifier moved |
| 49 | **`dalicc:variantOf` and `dalicc:exceptedCombination`** were added to the vocabulary (version 2 since change 57), and `dalicc:variantKind` gained the values `version-option`, `exception` and `rider` | Additive. 33 license documents carry `dalicc:variantOf`, naming the record they vary; 18 carry a `dalicc:exceptedCombination` permission. A client that iterates every triple of a record sees the two terms; one that reads known predicates is unaffected. `GET /ns` lists them and `GET /ns/versions` then reported version 6; after change 57 it reported 2, after version 3 of 2026-09-24 3, and after change 63 it reports 2 again |
| 50 | **Seven published records were corrected against their own texts** and versioned: `AGPL-3.0` (the section 13 network trigger, the termination terms and the patent grant), `GPL-2.0-only` and `GPL-3.0-only` and `LGPL-3.0-only` (termination, cure period, patent grant), `GnuFreeDocumentationLicense` (the three duty nodes whose `odrl:action` was `dct:source` are gone, and `cc:SourceCode` beside each of them carries the rule), `LatexProjectPublicLicenseVersion13c` (the unsupported `cc:ShareAlike` prohibition removed, `dalicc:originalVersionOffer` added, the target widened, the padding space stripped, the title tagged `@en`) and `TheZlibLibpngLicense` (the alternative name `Libpng`, which now belongs to a record of its own) | Those documents carry different statements, so `GET /licenselibrary/license/{id}`, the comparator and any compatibility check involving them answers differently, and their `dct:hasVersion` moved. A lookup for the alternative name `Libpng` reaches the `Libpng` record rather than `TheZlibLibpngLicense`. `odrl:action dct:source`, which was never a real action, no longer occurs anywhere in the data |
| 51 | **The core dependency graph was reviewed against the whole vocabulary** and gained a version: eight axioms added, three removed, 46 in force. The one that matters is `cc:CommercialUse owl:sameAs odrl:commercialize`, which puts the term 579 statements of the library use into a graph that had only ever spoken about `odrl:commercialize`; `dalicc:sellCopy includedIn odrl:sell`, `cc:DerivativeWorks includedIn odrl:distribute` and `dalicc:ModifiedWorks includedIn odrl:distribute` came with it. `odrl:extract owl:sameAs odrl:copy`, `odrl:reproduce includedIn odrl:copy` and `odrl:display includedIn odrl:play` went | **Five conflicts are reported that were not.** A bundle of `AFL-3.0`, which permits `dalicc:chargeLicenseFee`, with `CC-BY-NC-4.0`, which prohibits `cc:CommercialUse`, answered no conflict and now reports one; the same holds for `NVIDIA-Open-Model-License`, which permits `dalicc:sellCopy`, against the same license, and for a license permitting `odrl:derive`, `cc:DerivativeWorks` or `dalicc:ModifiedWorks` against one prohibiting `odrl:distribute`, such as `CC-BY-4.0` with `PolyForm-Strict-1.0.0`. **One is no longer reported**: a license permitting `odrl:reproduce` beside one prohibiting `odrl:extract` was called a conflict because the graph made the two names for one act, and extracting a part is not copying the whole. No pair of library records changes its verdict, and the sweep over all 581 still reports the two Ordnance Survey records and nothing else. `GET /dependencygraph/list` returns 46 rows in the shape it always had, `/dependencygraph/versions` listed four versions at the time (three since change 57), and a deployment that reasons with a graph of its own is untouched |
| 52 | **What a license does not say moved from the records into the dependency graph.** The core graph gained a version with one adopted `dalicc:DefaultRule`: a license that is silent about endorsement grants no right to it, worldwide, on the strength of trademark law and the protection of names. 157 records lost the `dalicc:promote` prohibition, which no sentence of their text supported, and were versioned one by one; 362 whose own text bars the use of the licensor's name kept it. Seven further core graphs ship, `dg_eu`, `dg_us`, `dg_cn`, `dg_gb`, `dg_jp`, `dg_in` and `dg_br`, holding the default rules proposed for one market each; every rule in them is a proposal and none of them is the default for any check | **A compatibility check reaches the same answer as before**: the licenses that lost the statement are silent about the action, and the adopted rule supplies the prohibition for every license that is silent, so the pairs that conflicted still conflict and the sweep over all 581 records still reports the two Ordnance Survey licenses and nothing else. What changes is what the documents say: `GET /licenselibrary/license/{id}` no longer carries the prohibition on those 157 records, their `dct:hasVersion` moved, and the comparator shows the row as not applicable rather than prohibited. `POST /licenselibrary/consistencycheck` gained `defaults` and four keys per conflict, `origin_1`, `origin_2`, `rule_1` and `rule_2`, all additive and always present. `POST /compatibilitycheck/` gains the same two things only when a rule fired, so a deployment whose graph carries no rule gets the answer it has always got. `GET /dependencygraph/list` is unchanged and still returns the 46 axioms and nothing else; the rules are served by the new `GET /dependencygraph/rules`. None of it is legal advice |
| 53 | **The seven jurisdiction graphs are named plainly** and went to version 2 on 2026-09-24: "European Union default rules" rather than "European Union default rules (proposal)", and the same for `dg_us`, `dg_cn`, `dg_gb`, `dg_jp`, `dg_in` and `dg_br`, with a description that says what the graph is. The rules did not change, and each still says `dalicc:ruleStatus dalicc:Proposed` | The `title` and `version` of those graphs change in `GET /dependencygraph/graphs` and `/dependencygraph/{id}/versions`, which then listed versions 2 and 1 (3, 2 and 1 after change 61, and 2 and 1 again after change 63); version 1 is served at `/dependencygraph/{id}/versions/1`. A deployment that keeps its accounts database takes the new title and version at the next start. Additive: `POST /licenselibrary/consistencycheck` gains `rule_status_1` and `rule_status_2` on every conflict and `rule_status` on every entry of `defaults` (`adopted`, `proposed` or empty). No contract snapshot holds a graph title, so none was re-recorded |
| 54 | **Every published dependency graph is complete** (2026-09-24). The seven jurisdiction graphs gained a version: each holds the 46 axioms and the adopted rule of the core graph beside its own rules and is generated from the core graph and a difference file; `dalicc:extendsGraph` is deprecated and nothing follows it any more. The core graph gained a version that gave its rule an explanation and changed nothing else. Every rule carries `dalicc:ruleExplanation` | `GET /dependencygraph/list?graph=dg_eu` still answers with 46 axioms, now the graph's own. `GET /dependencygraph/graphs` gains `based_on`, `based_on_version`, `added_rules`, `removed_axioms`, `replaced_rules`, `group` and `summary` (additive). Two keys that stood on the development branch for one day, `own_axioms` and `extends`, were never deployed and are removed; no deployed client can have read them. Additive: `explanation` on every entry of `defaults` of `POST /licenselibrary/consistencycheck` and `POST /compatibilitycheck/`, and the new `GET /dependencygraph/compare`. The Turtle download of a jurisdiction graph is the complete graph, without the comment line that separated the core axioms. No contract snapshot changed |
| 57 | **Every model history was squashed to two versions before the first deployment.** Version 1 is the model as it was published before the 2026 work (or, for a record written in 2026, its first version) and version 2 the model as it is now; 220 records, the eight shipped dependency graphs and the vocabulary had more than two versions, and their intermediate versions were merged into version 2, whose change log lists every merged change with its reason and names the merged numbers in `squashed_from` | `dct:hasVersion` is `"1"` or `"2"` on every record (a record that said `"5"` on a development build now says `"2"`); `/licenselibrary/license/{id}/versions`, `/dependencygraph/versions`, `/dependencygraph/{id}/versions` and `/ns/versions` list two versions at most, and `/versions/{n}` answers 404 for n of 3 and above; the vocabulary says `owl:versionInfo "2.0"` and names version 1 alone in `owl:priorVersion`; a jurisdiction graph says `dalicc:basedOnVersion "2"`. Production never served a version above 2, so no published address disappears. A deployment whose accounts database was written before the squash moves its graph rows down at the next start ([DEPLOYMENT.md](DEPLOYMENT.md#7-upgrading-and-rolling-back)) |
| 61 | **Versions after the squash.** On 2026-09-24 the corrections that followed the squash (the evidence and origin of the statements, the termination of the Creative Commons 4.0 records and the ports, the canonical CC BY 4.0 address on every record, the records corrected against their texts and the default rules corrected against their statutes) each took the records they touched to a new version, and the eight shipped graphs and the vocabulary went to version 3 | Every record is past version 1 and `dct:hasVersion` runs from `"2"` to `"6"` today; `/licenselibrary/license/{id}/versions` lists as many versions as a record has, and `/dependencygraph/versions`, `/dependencygraph/{id}/versions` and `/ns/versions` three; the vocabulary says `owl:versionInfo "3.0"` and a jurisdiction graph `dalicc:basedOnVersion "3"`. Each is an ordinary new version: every earlier address keeps answering |
| 63 | **The histories were squashed again after the corrections of the review.** The first deployment had not happened, so the rule of change 57 applied once more: the 575 records at versions 3 to 6, the eight shipped graphs at version 3 and the vocabulary at version 4 were merged into version 2 on 2026-09-24. Version 1 is untouched; the change log of version 2 lists every change of both squashes with its reason, and its `squashed_from` names the numbers of both | `dct:hasVersion` is `"2"` on all 581 records; `/licenselibrary/license/{id}/versions`, `/dependencygraph/versions`, `/dependencygraph/{id}/versions` and `/ns/versions` list versions 2 and 1, and `/versions/{n}` answers 404 for n of 3 and above; the vocabulary says `owl:versionInfo "2.0"` and names version 1 alone in `owl:priorVersion`, and a jurisdiction graph says `dalicc:basedOnVersion "2"`. Production never served a version above 2, so no published address disappears. A deployment whose accounts database holds a graph at a merged number moves it down at the next start, as after change 57 |

The review also wrote new predicates into every record it applies to:
`dalicc:jurisdictionPortOf`, `dalicc:variantKind` and `dalicc:licenseVersion` on the
ports, `dalicc:reviewStatus` and `dalicc:reviewedOn` everywhere, `dalicc:recordStatus`
on the two fixtures, and, with the model history, `dct:hasVersion`, `dct:modified` and
`dalicc:versionHistory` on all 581. They appear in the license documents, which is
additive: a client that reads known predicates is unaffected, and one that iterates
every triple sees more of them.

**One predicate changed meaning**, and only on records that already carried it:
`dct:hasVersion` used to hold the version of the *license text* on the 287 Creative
Commons ports and the two GNU records (`"3.0"`). That value moved to
`dalicc:licenseVersion`, and `dct:hasVersion` now holds the version of the DALICC
**model** (an integer from `"1"`), which is what it already meant on a license composed with
the License Composer. A client that read `dct:hasVersion` as a license version on a
port should read `dalicc:licenseVersion` instead; no other record is affected. The
model is documented in [DATA.md](DATA.md#4-model-history-and-versioning).

## 3. What is additive

Nothing existing changed for any of these to exist.

**API endpoints**, all read-only unless noted:
`GET /healthz`, `GET /licenselibrary/compare`, `GET /licenselibrary/actions`,
`POST /licenselibrary/consistencycheck`, `GET /licenselibrary/spdx/{spdx_id}`,
`GET /licenselibrary/spdx-mapping`, `GET /licenselibrary/review/{id}`,
`GET /licenselibrary/history`, `GET /licenselibrary/license/{id}/versions`,
`.../versions/{n}` and `.../changelog`, `GET /dependencygraph/versions[/{n}]`,
`GET /dependencygraph/changelog`, `GET /dependency-graph.ttl`,
`GET /license-library/suggest`, `GET /license-library/{id}/license.json`,
`GET /license-library/{id}/badge.svg` and the `/license-compatibility-checker/*` helpers.

**Behind an API token or a session:** `GET /licenselibrary/mine`,
`GET /licenselibrary/mine/{id}/revisions`,
`POST /licenselibrary/mine/{id}/deprecate`, `GET /dependencygraph/graphs`,
`GET /dependencygraph/mine`, `POST /dependencygraph`,
`GET /dependencygraph/{graph_id}/versions`, `.../versions/{n}` and `.../changelog`,
`POST /licenselibrary/translate` and `GET /licenselibrary/translate/jobs/{job_id}`.

**Version 2**, a namespace of its own that changes nothing of version 1:

| Group | Paths |
|---|---|
| discovery | `GET /v2`, `GET /.well-known/api-catalog`, `GET /v2/openapi.json`, `GET /v2/docs` |
| licenses | `GET /v2/licenses`, `/v2/licenses/{id}`, `/v2/licenses/{id}/versions`, `/v2/licenses/{id}/versions/{n}` |
| dependency graphs | `GET /v2/dependency-graphs`, `/v2/dependency-graphs/{id}`, `.../versions`, `.../versions/{n}` (a graph of an account behind its token or session) |
| vocabulary | `GET /v2/vocabulary`, `/v2/vocabulary/versions`, `/v2/vocabulary/versions/{n}` |
| releases | `GET /v2/releases`, `/v2/releases/{id}`, `/v2/changes` |

Errors there are problem details, reads are open to every origin, and every version
carries a content hash, an ETag and `Link` navigation. None of it reaches a version 1
path: the problem format, the cross-origin rule and the new headers apply only under
`/v2` and `/.well-known/api-catalog`.

**Optional parameters on existing operations:** `GET /dependencygraph/list?graph=<id>`
and `POST /licenselibrary/consistencycheck?dependency_graph=` both default to the
configured core graph, so an omitted parameter answers exactly as before.

Two things about the translation endpoints are worth stating, because neither is a
change to anything that existed:

* **`POST /licenselibrary/translate` answers `202`, not only `200`.** A text that
  needs more than one part is *started* rather than awaited, because reading a long
  license takes minutes and no proxy holds a request open that long. The `202` body
  carries `job_id` and `poll_url`, and the job endpoint answers `202` with the
  progress until it answers `200` with the same proposal a short text gets in one
  call. A client therefore has to look at the status code, which is why the endpoint
  ships with a client that does it (`translate.text(..., wait=True)`, the default).
* **It is off unless the deployment configures it.** Without an access key the
  endpoint answers `503` and the page says the assistant is not configured. A
  deployment that does nothing gets exactly the service it had.

**Site paths**, none of which collides with an API prefix or with a path the old
WordPress site used: `/login`, `/logout`, `/invite/{token}`, `/forgot-password`,
`/reset/{token}`, `/change-password`, `/account*`, `/my/licenses*`,
`/my/dependency-graphs*`, `/my/submissions*`, `/my/requests*`, `/requests/new`,
`/inbox*`, `/dependency-graph/{id}`, `/text-to-license*`, `/documentation*`,
`/media/{id}/{filename}`, `/blog/tag/{slug}`, `/blog/feed.xml` and `/admin*`. The
OpenAPI documentation keeps its address at `/docs`; `/documentation` is the
repository's markdown, a different thing at a different path.

`/text-to-license*` was `/license-translator*` until the feature was renamed. The old
path and every path under it answer `301` to the new one with the query string kept, so
a bookmark or a link from elsewhere still arrives. Nothing else moved with the name: the
API path `POST /licenselibrary/translate`, the `DALICC_TRANSLATE_*` settings, the
`translation_jobs` and `translation_runs` tables and the SDK method names are unchanged.

`/licenselibrary/{id}` is registered after the API router, so the fixed paths always
win: a known API segment reached with the wrong method answers `405` with an `Allow`
header, never a Linked-Data redirect. The site pages and the account pages are
excluded from the published OpenAPI document (`include_in_schema=False`), so the
schema a client generates from `/openapi.json` is unchanged apart from the operations
listed in [section 2](#2-every-observable-change).

Two things that did **not** change, although they easily could have:

* **Anonymous composing still works.** `DALICC_ANONYMOUS_COMPOSER` defaults to `true`,
  so `/license-composer` accepts a submission without an account exactly as before and
  produces an ownerless license. Requiring an account is opt-in. `POST /web/wpcomposer`
  no longer takes one: it published a person's name with no consent box, so it answers
  `410` with a message naming `/license-composer`, and `GET` answers `301` to the same
  place. What it could state, the composer states, which
  `tests/unit/test_composer_coverage.py` checks term by term.
* **A published custom license stays reachable at its URI.** Editing one mints a new
  id rather than changing the old document; the old one gains `owl:deprecated`,
  `dct:isReplacedBy` and a banner on its page, and keeps resolving in every
  serialisation.

## 4. How the promise is tested

Responses captured from `https://api.dalicc.net` before the refactor are replayed
against the current application. The snapshots live in
[`tests/contract/snapshots`](../tests/contract/snapshots/README.md), one JSON file per
case, with `manifest.json` describing each case (method, path, query, request body)
and the response that was recorded (status, content type, snapshot file).

```bash
pytest tests/contract -q                                     # against the SPARQL double
make contract-live CONTRACT_BASE_URL=http://127.0.0.1:8002   # against a local stack
make contract-live CONTRACT_BASE_URL=https://api.dalicc.net  # against production
```

22 cases: nine license serialisations, three listings, three faceted searches, the
dependency-graph dump, three compatibility checks, two GitHub checks and the OpenAPI
document. The two `githublicensechecker` cases skip without
`DALICC_LIBRARIES_IO_API_KEY`. Against the double the list endpoints assert a subset
(it holds a dozen licenses, two of them jurisdiction ports and one a test fixture that
must never appear); against a real server they must match exactly.

The OpenAPI case is not a body comparison: it asserts that the current schema is a
**superset** of the live one and that no documented operation changed its path,
method, parameters or description, with the single exemption recorded under change 12.
The `info` block is outside that comparison, which is why the snapshot still records
the `1.0` production answered while the document reports `1.1` (change 47). A unit test
asserts the published title and version instead.

**Snapshots that are knowingly stale in live mode.** A snapshot is evidence of what
the service used to answer, not a fixture to keep green, so the ones below were left
exactly as production recorded them. The default replay runs against the SPARQL double
and is unaffected; a live replay against a reloaded server differs as listed:

| Snapshot | Live replay | Why |
|---|---|---|
| `list_keyword_apache` | `Apache-1.0` matches too | A new record, same keyword |
| `list_keyword_mit_limit5` | `MIT-0`, `MIT-Modern-Variant`, `MIT-CMU` and `MIT-Wu` match too | New records, same keyword |
| `facetedsearch_software_no` | More rows | The new open-data and content records match |
| `compatibilitycheck_statscanada_ukogl` | An empty `direct` object | The review corrected `StatisticsCanadaOpenLicenceAgreement`: it permits adaptations with attribution and a modification notice, so it no longer prohibits `odrl:derive` and `odrl:modify` and the pair stops conflicting |
| `dependencygraph_list` | 46 axioms, re-recorded on 2026-09-23 | Changes 41 and 51. The case is compared by JSON shape, not by content. Change 52 left it alone: the default rules are a different kind of statement and this endpoint does not return them |
| `compatibilitycheck_*` | `defaults`, and four origin keys per conflict | Change 52, and only on a live replay: the app's own route is a pass-through, so the contract test against the double is unaffected |

`compatibilitycheck_apache_mit` is **not** in that list: change 40 restored the empty
`direct` object it records, so it replays correctly again, live and against the
double.

Three cases were re-recorded on 2026-09-22, because a case is compared against the
double by asking that the double's identifiers be a subset of the recorded ones and the
fixture dataset gained `GPL-3.0-or-later`: `list_no_params` (579 identifiers),
`facetedsearch_defaults` (579 rows) and `facetedsearch_commercial_attribution` (409
rows). The manifest records each refresh with its date and its reason.

`make snapshot CONTRACT_BASE_URL=...` re-captures the snapshots. Do that only when a
change is intended, documented in [section 2](#2-every-observable-change) and reviewed
diff by diff. A failing contract test is the system doing its job.

## 5. Deprecation policy

1. **Additive first.** A new capability is a new endpoint, a new optional parameter or
   an opt-in header. Never a changed default.
2. **Nothing in [section 1](#1-the-contract) changes without a major version.** That
   means a new path prefix or a new OpenAPI version, announced in advance.
3. **To deprecate an operation:** document it in [API.md](API.md) and in the OpenAPI
   description, announce it at `tassilo.pellegrini@ustp.at` and `giray.havur@ustp.at`
   (the shared `info@dalicc.net` was retired) and on the site, keep it working for at
   least 12 months, log its use so the impact is known, and only then remove it.
   Version 1 answers carry no `Deprecation` or `Sunset` header, so a client cannot
   detect a deprecation from a response; the announcement and API.md are the signal.
   The version 2 namespace sends RFC 9745 `Deprecation` and RFC 8594 `Sunset` on any
   operation that is deprecated. The API version a client talks to is `info.version`
   of `/openapi.json` (see API.md, "Versions and deprecation").
4. **Every behaviour change is written down** in
   [section 2](#2-every-observable-change), with its justification and its client
   impact, before it is merged.
5. **The snapshots move last.** Code first, review, then the snapshot, in the same
   pull request.
6. **A deprecated operation says so on every answer.** It answers `Deprecation`
   (RFC 9745) with the day it was deprecated and, once a removal date exists, `Sunset`
   (RFC 8594) with that date, and the discovery document `GET /v2` names both for the
   API version they belong to (`deprecated`, `sunset`). No operation is deprecated
   today, so neither header is sent and both are `false` and `null` there. The same
   `Deprecation` header on a superseded or withdrawn version of a license, a graph or
   the vocabulary is about the data, not the API, and never comes with `Sunset`,
   because a version is never removed.
