DEVELOPMENT VERSION: This is an experimental DALICC environment. Features, data and APIs may change or be reset without notice. A stable version of this service, with a potentially different feature set, will be made available soon at dalicc.net.

Backward compatibility


Last changed Markdown source

On this page

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:

  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, 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.

  • 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).

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/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.

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, 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

  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 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 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, 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.