Backward compatibility
On this page
- 1. The contract
- The version strings
- 2. Every observable change
- GET /licenselibrary/license/{license_id}
- GET /licenselibrary/list and POST /licenselibrary/facetedsearch
- POST /licenselibrary/composer
- POST /compatibilitycheck/
- GET /githublicensechecker/*
- Every response
- The /web/* fragments
- Token requests
- The data
- 3. What is additive
- 4. How the promise is tested
- 5. Deprecation policy
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.
Contents: 1. The contract · 2. Every observable change · 3. What is additive · 4. How the promise is tested · 5. Deprecation policy
The endpoints themselves are described in API.md.
1. The contract
The backward-compatibility surface is ten operations:
GET /licenselibrary/license/{license_id}GET /licenselibrary/listPOST /licenselibrary/facetedsearchPOST /licenselibrary/composerGET /dependencygraph/listPOST /compatibilitycheck/(with the trailing slash)GET /githublicensechecker/dependencies/{owner}/{name}GET /githublicensechecker/dependencies?github_url=GET /githublicensechecker/check/{owner}/{name}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, 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:
/listand/facetedsearchreturn the raw SPARQL-JSON envelope of the triple store, not a list, andtitlebindings carryxml:langinconsistently.conflicting_statements.directand.derivedare 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=ttlandformat=rdf-xmlreturn a JSON-encoded string withContent-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.
versioninpyproject.tomlandversioninsdk/pyproject.tomlhold the same number,2.0.0once the release is cut; until then the application says2.0.0.dev0and 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.versionin the OpenAPI document,1.1since this release, up from the1.0production answered. A client written against1.0keeps working.info.titlestays"DALICC API", because clients read the title to identify the service. The details are change 47 in section 2. -
Version 2 of the API is
2.0, under/v2, with its own OpenAPI document at/v2/openapi.json(info.version2.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.jsonwithinfo.version1.1, and no/v2path appears in that document.GET /v2names both versions, their documents and their status (API.md section 11).
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.
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) | 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) | 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 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.
| # | 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) |
| 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.
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/translateanswers202, not only200. 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. The202body carriesjob_idandpoll_url, and the job endpoint answers202with the progress until it answers200with 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
503and 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.
Two things that did not change, although they easily could have:
- Anonymous composing still works.
DALICC_ANONYMOUS_COMPOSERdefaults totrue, so/license-composeraccepts a submission without an account exactly as before and produces an ownerless license. Requiring an account is opt-in.POST /web/wpcomposerno longer takes one: it published a person's name with no consent box, so it answers410with a message naming/license-composer, andGETanswers301to the same place. What it could state, the composer states, whichtests/unit/test_composer_coverage.pychecks 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:isReplacedByand 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, 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).
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 and reviewed
diff by diff. A failing contract test is the system doing its job.
5. Deprecation policy
- Additive first. A new capability is a new endpoint, a new optional parameter or an opt-in header. Never a changed default.
- Nothing in section 1 changes without a major version. That means a new path prefix or a new OpenAPI version, announced in advance.
- To deprecate an operation: document it in API.md and in the OpenAPI
description, announce it at
tassilo.pellegrini@ustp.atandgiray.havur@ustp.at(the sharedinfo@dalicc.netwas 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 noDeprecationorSunsetheader, so a client cannot detect a deprecation from a response; the announcement and API.md are the signal. The version 2 namespace sends RFC 9745Deprecationand RFC 8594Sunseton any operation that is deprecated. The API version a client talks to isinfo.versionof/openapi.json(see API.md, "Versions and deprecation"). - Every behaviour change is written down in section 2, with its justification and its client impact, before it is merged.
- The snapshots move last. Code first, review, then the snapshot, in the same pull request.
- 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 documentGET /v2names both for the API version they belong to (deprecated,sunset). No operation is deprecated today, so neither header is sent and both arefalseandnullthere. The sameDeprecationheader on a superseded or withdrawn version of a license, a graph or the vocabulary is about the data, not the API, and never comes withSunset, because a version is never removed.