Data contract
The dataset describes how the license data is shaped and
where it comes from. This page is narrower and more boring: it is the promise. If you are
reading ospac/data/ from another tool, these are the files and fields you may build on,
and this is what happens when one of them changes.
The reason to write it down is that the alternative is inference. index.json has carried
a version field since the first release, with no stated meaning, so a consumer had to
guess whether it tracked the schema or the data. Three consumers guessing produce three
different answers, which is the same failure that moved the alias table into the dataset in
the first place. Guessing is cheap to prevent now and expensive to unwind later.
The published surface
Four things are published. Everything else in the package is an implementation detail that may move or vanish in any release.
| Path | What it is |
|---|---|
ospac/data/index.json |
Lookup index over every license: id, name, family, file path, deprecation flag, obligation count |
ospac/data/aliases.json |
Flattened alias map and the never-resolve list |
ospac/data/licenses/json/<id>.json |
One record per license, the full analysis |
ospac/data/compatibility/ |
metadata.json, categories.json, and relationships/*.json |
index.json
{
"version": "1.1.0",
"generated": "2026-08-12T09:45:09.769222",
"spdx_list_version": "e4c1f27",
"total_licenses": 733,
"licenses": {
"MIT": {
"name": "MIT License",
"category": "permissive",
"file": "licenses/json/MIT.json",
"is_deprecated": false,
"obligations_count": 2
}
}
}
Five top-level keys, five keys per record, and no others. total_licenses always equals
the size of licenses. Every file path is relative to ospac/data/ and always resolves.
Worth knowing: ospac barely reads this file. Evaluation reads the per-license records,
aliases.json, and compatibility/. The only internal reader is data_version(), which
touches the four metadata keys and never opens the licenses map. So the whole per-record
half of the index exists purely for consumers, and breaking it would fail no code path of
ours. That is exactly why it is pinned by test rather than left to be noticed.
The record field is called category here and type in the license record. They hold the
same value from the same domain. The two names are historical, both are published, and
neither will be renamed inside a major version.
aliases.json
{
"version": "1.1.0",
"spdx_list_version": "e4c1f27",
"aliases": { "expat": "MIT", "gpl-3.0+": "GPL-3.0-or-later" },
"ambiguous": {
"gnu general public license v2.0": ["GPL-2.0-only", "GPL-2.0-or-later"],
"gplv3": ["GPL-3.0-only", "GPL-3.0-or-later"]
},
"never_resolve": ["agpl", "apache", "bsd", "gpl", "lgpl", "public domain"]
}
Keys in all three tables are lowercased, so lowercase your input before looking it up. The three do not overlap: a string gets an id, a choice, or nothing, and never two of those.
Every value in aliases is an id present in index.json. An alias claimed by more than one
license is not resolved, because a confidently wrong answer is worse than none.
ambiguous maps text that identifies a license but not which id, to the ids it could mean.
Every value has at least two of them, each present in index.json. “GNU General Public
License v2.0” is the SPDX name of both GPL-2.0-only and GPL-2.0-or-later: only versus
or-later is the copyright holder’s grant and the license’s own name does not carry it, so a
document writing that name has not said which one it means. The same holds one axis over:
“Eclipse Public License” is the name of both EPL-1.0 and EPL-2.0 and states no version.
Report the choice rather than making it. Which strings are ambiguous is license data that moves with SPDX, which is why it
is here rather than curated per consumer.
A version is carried in both spellings a registry uses. SPDX writes “GNU Affero General
Public License v3.0” and every iText POM on Maven Central declares “GNU Affero General
Public License v3”, so both keys are present and give the same answer. The shortened form
is only offered where it names every version it could be taken for: php license v3 is a
choice between PHP-3.0 and PHP-3.01, and latex project public license v1 a choice
between five, so those are in ambiguous rather than aliases. Where it would name only
some of them it is absent entirely, which is why there is no
gnu lesser general public license v2: LGPL-2.0 ships under the name SPDX gave it,
“Library”, so offering the two LGPL-2.1 readings would narrow a choice the string never
made.
never_resolve is not a subset of aliases, it is the complement: text that must not
resolve to anything and offers no candidates either. bsd is 2-clause or 3-clause and the
choice changes obligations. Treat these as unresolved rather than inventing a guess. Read all
three through
license_aliases(), license_ambiguous() and license_never_resolve()
rather than parsing the file, if you are in Python.
licenses/json/<id>.json
The per-license record. Its shape is defined normatively by
schemas/license_schema.json,
and the field-by-field meanings are in
The dataset.
Yes, normative. That schema used to be an unused internal artifact that had drifted so far
out of date it rejected all 733 records it claimed to describe: five of the ten license
families were absent from its type enum, three of the five real contamination_effect
values were absent while three values the generator never emits were listed, and it was
closed against five fields the records had carried for releases. It now matches the shipped
data, its value domains and its required-key sets are pinned by test to the same constants
the dataset validator uses, and every shipped record is validated against it in CI. You can
point a validator at it and trust the result.
The value domains were pinned and the required-key sets were not, so the two drifted: the
schema required requirements.include_notice and compatibility.notes and the validator
did not, which meant validate_data.py passed a record the schema then rejected. Both sets
are pinned now, and the generator checks a record against the same rules before writing it,
so a licence whose analysis came back incomplete is skipped with the field named rather than
written with the gap.
One deliberate looseness: the schema does not forbid unknown fields. The compatibility promise below permits new fields in a minor release, so a schema that rejected them would break every consumer on an additive change. Absence of a field is what the schema catches; addition of one is caught by our own test, not by yours.
The schema lives in the repository and the source distribution, not in the wheel, because validating records is a consumer’s CI activity rather than something the installed tool does. Vendor a copy at the major version you support rather than fetching it at runtime; ospac makes no network calls and neither should a compliance check.
compatibility/
metadata.json publishes version, generated, total_licenses, format,
default_status, and statuses. categories.json maps a family name to the license ids
in it. relationships/<family>.json holds the pair rules.
statuses lists the distinct status values the pairs take, once:
// compatibility/metadata.json
{
"format": "interned",
"default_status": "unknown",
"statuses": [
{ "static_linking": "compatible", "dynamic_linking": "compatible",
"distribution": "compatible" },
{ "static_linking": "review_required", "dynamic_linking": "review_required",
"distribution": "review_required" }
]
}
A relationship file is keyed by source license id, then by target license id, and the value is an index into that list:
// compatibility/relationships/mit.json
{
"MIT": { "Apache-2.0": 0, "GPL-3.0-only": 0, "CC-BY-NC-4.0": 1 }
}
A pair is an index into statuses in metadata.json, not a status object. All 537,289
pairs take four distinct values between them, so the four are written once and each pair
costs an integer. Written out in full they cost 76 MB, of which other.json alone was
59 MB; as indexes the same information is under 10 MB. Resolve a pair by reading
metadata.json once and indexing into statuses.
"format" says interned for that reason. It said sparse before, which described the
writer’s one compaction rule, that a pair resolving to unknown is omitted. No pair ever
resolves to unknown, so nothing was ever omitted and the store was a complete
enumeration wearing a label that promised otherwise.
The family in the filename groups the source license only. Targets are every license, so
apache.json contains rows for the three Apache sources against all 733 targets. Note that
the pair rules carry a third context, distribution, which the per-license record’s
compatibility block does not have.
"default_status": "unknown" is still the rule you must implement for a pair you cannot
find: unknown, not compatible. Absence of a recorded conflict is not evidence of its
absence. A consumer that treats a missing pair as approval will ship a violation and blame
the dataset. Today no pair is missing, but that is a property of the current data and not a
promise, so code the default anyway. Interning changed what a pair looks like on disk and
nothing about which pairs exist, so this rule is unaffected: the 88% of pairs that are
compatible are still stored, not assumed.
Note also that the family names in categories.json (gpl, bsd, cc, apache and so
on) are a different taxonomy from the type/category field on a license (permissive,
copyleft_strong and so on). They are not interchangeable and one does not map onto the
other.
What is not published
These exist, and reading them is not a supported thing to do:
obligation_database.json,ospac_license_database.json, andcompatibility_matrix.json. Generation-time intermediates. They are deleted by the generator’s own cleanup step, so they are not in the wheel at all, and theirversionfield is unrelated to this contract.- Everything under
ospac.pipeline. Dataset-generation machinery. It calls an LLM; the installed tool does not. ospac/defaults/andospac/data/LICENSE. The bundled default policy is documented as behaviour, not as a data format, and it is opinionated on purpose. Copy it, do not parse it.- Any file added under
ospac/data/that this page does not name. If you find something useful there and want to depend on it, open an issue and it can be published deliberately.
What version means
version is the version of the data layout, not of the ospac package and not of the
license data. It is a three-part string, MAJOR.MINOR.PATCH, and it is the same value in
index.json, aliases.json, and compatibility/metadata.json.
| Change | Bump |
|---|---|
| A published field is removed or renamed, a type changes, or a documented meaning changes | MAJOR |
| A published field is added, or a new file joins the published surface | MINOR |
| The contract text is corrected without the shape changing | PATCH |
| A monthly SPDX refresh: new licenses, changed classifications, updated deprecation flags | none |
That last row is the important one. A dataset refresh changes hundreds of values and does
not touch version, because the shape did not change. If you want to know whether your copy
of the data is stale, version is the wrong field.
Three parts rather than two, and a string rather than a number, for one reason: 1.10 sorts
below 1.9 under both float and lexicographic comparison. Compare on integers.
version, generated, spdx_list_version
Three fields, three questions, no overlap:
| Field | Answers | Use it for |
|---|---|---|
version |
Is the shape one I understand? | Compatibility gates |
spdx_list_version |
Which upstream SPDX revision is this? | Provenance, reproducing a decision |
generated |
When did the build that produced this run? | Staleness |
For a staleness check, use generated. The dataset is rebuilt monthly, so data older than
about six weeks means releases stopped arriving. spdx_list_version also moves on a refresh
but it is a commit hash, so it tells you which upstream you have and not how old it is.
Checking compatibility from Python
Rather than opening index.json yourself:
import ospac
info = ospac.data_version()
info.schema_version # "1.1.0"
info.schema_version_info # (1, 1, 0)
info.spdx_list_version # "e4c1f27"
info.generated # "2026-08-12T09:45:09.769222"
info.total_licenses # 733
Assert once, at import, so an incompatible dataset fails loudly instead of producing quietly wrong normalization:
import ospac
major, _, _ = ospac.data_version().schema_version_info
if major != 1:
raise RuntimeError(f"ospac data schema v{major} is not supported by this consumer")
ospac.DATA_SCHEMA_VERSION is the same value as a module constant, for a build-time pin.
The compatibility promise
Within a major version:
- No published field is removed, renamed, or retyped.
- No published field’s meaning changes. If the meaning has to change, that is a new field and the old one is deprecated, not a redefinition of the existing one.
- Fields may be added. Tolerate unknown keys.
- Values may change freely. A license may be reclassified, gain obligations, or flip its
deprecation flag on any monthly refresh. The contract is about shape, never about
verdicts. If a reclassification would change your output, pin
spdx_list_versionin whatever you store, so you can explain a decision later. -
License ids may appear and disappear, following SPDX. A deprecated id is kept, with
is_deprecated: true, so a removal from upstream does not orphan an id you already recorded.alias_ofis not guaranteed on a deprecated record. It is populated for the GPL, LGPL, AGPL and GFDL spellings, where the deprecated form maps onto an unambiguous-onlyor-or-lateridentifier. Fifteen other deprecated ids carryalias_of: null, includingwxWindows,eCos-2.0,Net-SNMP, the twoBSD-2-Clause-*BSDspellings and the sevenGPL-*-with-*-exceptionids, because SPDX replaced them with something other than a single plain identifier. To migrate a stored id, readalias_ofand fall back to the alias map rather than assuming a non-null value.
How a removal is announced
Nothing in the published surface is removed without all of:
- A minor release that documents the deprecation on this page and in the CHANGELOG, while the field keeps working.
- At least one further minor release in which it still works.
- A major bump of
versionthat removes it, called out in the changelog under aBreakingheading.
There is no in-band notice mechanism, no deprecation flag inside the JSON. The changelog and this page are the channel. If you consume the dataset in automation, watch releases.
How this is enforced
tests/test_data_contract.py asserts every field named on this page, that the schema’s
value domains and required-key sets still match the dataset validator’s constants, and that
all 733 shipped records validate against the normative schema. A silent removal fails CI.
The required-key comparison follows the schema’s $ref rather than stopping at it. The
linking contexts state their required keys through one, so a test reading the node directly
would find no required, compare an empty set, and pass while proving nothing.
The field lists in that test are typed out by hand rather than read from the data, on purpose. A test that derives its expectations from the file it is checking asserts only that the data equals itself, which is how a contract test passes through the exact change it was written to catch.
The test does not pin values, only shape. It has to stay green across a monthly refresh that reclassifies licenses, and a test that pinned verdicts would fail on correct data and get weakened until it stopped meaning anything.