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.0.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.0.0",
  "spdx_list_version": "e4c1f27",
  "aliases": { "expat": "MIT", "gpl-3.0+": "GPL-3.0-or-later" },
  "never_resolve": ["agpl", "apache", "bsd", "gpl", "lgpl", "public domain"]
}

Keys in aliases are lowercased, so lowercase your input before looking it up. Every value is an id present in index.json. An alias claimed by more than one license is absent rather than resolved, because a confidently wrong answer is worse than none.

never_resolve is not a subset of aliases, it is the complement: text that must not resolve to anything. bsd is 2-clause or 3-clause and the choice changes obligations. Treat these as unresolved rather than inventing a guess. Read both through license_aliases() 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 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.

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, and default_status. categories.json maps a family name to the license ids in it. relationships/<family>.json holds the pair rules.

A relationship file is keyed by source license id, then by target license id, then by linking context:

{
  "Apache-1.0": {
    "0BSD": {
      "static_linking": "compatible",
      "dynamic_linking": "compatible",
      "distribution": "compatible"
    }
  }
}

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.

Budget for the size. relationships/ is currently about 73 MB across ten files, and other.json alone is 59 MB. Stream it or index it; do not plan on holding it in memory next to everything else you are doing.

That size is the thing to know about "format": "sparse", because the label promises more than the data delivers. The intent of the format is that pairs resolving to unknown are omitted. In the shipped data there are no unknown statuses, so nothing is omitted and the store is a full enumeration: all 733 by 733 pairs, 537,289 of them, are present. Treat sparse as a statement about the writer’s rule, not as a promise that the file is small.

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

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, and compatibility_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 their version field is unrelated to this contract.
  • Everything under ospac.pipeline. Dataset-generation machinery. It calls an LLM; the installed tool does not.
  • ospac/defaults/ and ospac/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.0.0"
info.schema_version_info   # (1, 0, 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_version in 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_of is 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 -only or -or-later identifier. Fifteen other deprecated ids carry alias_of: null, including wxWindows, eCos-2.0, Net-SNMP, the two BSD-2-Clause-*BSD spellings and the seven GPL-*-with-*-exception ids, because SPDX replaced them with something other than a single plain identifier. To migrate a stored id, read alias_of and 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:

  1. A minor release that documents the deprecation on this page and in the CHANGELOG, while the field keeps working.
  2. At least one further minor release in which it still works.
  3. A major bump of version that removes it, called out in the changelog under a Breaking heading.

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 still match the dataset validator’s constants, and that all 733 shipped records validate against the normative schema. A silent removal fails CI.

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.