SBOM: selectable spec version defaulting to 1.6, honest coverage, element type, purl scope encoding - #190
Merged
villelaitila merged 5 commits intoAug 25, 2026
Conversation
The generator declared 1.7 and offered no way to say otherwise. CycloneDX 1.7
was published in October 2025, so a consumer built against an earlier library
rejects the document on the version string alone, before reading any of its
content. A reporting instance runs cyclonedx-core-java 9.0.5, released in 2024:
every document it receives is unusable, not degraded.
Nothing in the output needs 1.7. Measured rather than assumed: 912 documents
generated from 20 real models -- single-document mode, level-2 with
--transitive-externals, and a --coverage document carrying compositions --
validated against the official bom-1.4, bom-1.5 and bom-1.6 schemas with only
specVersion swapped. ZERO schema errors at all three. Those schemas set
additionalProperties:false at both the document root and the component
definition, so the zeros describe the output rather than a schema declining to
look. bom-1.3 is permissive, so it is deliberately NOT offered: a version whose
conformance cannot be checked is worse than no version.
So the default moves to 1.6. The content is provably identical -- only the
version string differs -- while 1.7 is unreadable to the great majority of
deployed SBOM tooling, and a default no consumer can ingest is not a useful
default. A caller that wants the newest asks for it by name, through the
spec_version keyword on all three public entry points or --spec-version on the
CLI. An unsupported value raises rather than reaching specVersion untouched:
a document claiming conformance to a specification nothing verified it against
fails downstream in a way no consumer can diagnose, since the only evidence is
a version number that looks deliberate.
BASIC_INFO is class-level and shared by every SBOM built in the process, so the
selected version is written onto the per-call deepcopy and never into it. The
failure that forecloses is not a wrong document but a wrong NEXT document --
one export asks for 1.4 and every later export in that process silently agrees
-- which in a long-lived server is the ordinary case and is invisible from the
call that caused it. A test asserts both the next call's output and the class
attribute itself. Assigning to a key that already exists also preserves its
position, so serialised field order is untouched.
Verified inert beyond the version string: across all 912 documents, every
document is identical to its pre-change counterpart once specVersion, the
generation timestamp and the uuid4 serial the single-document mode mints are
set aside. No field moved, no component appeared or vanished.
The offline conformance test enumerates the keys bom-1.6 defines at each object
level the generator emits, rather than validating against a vendored schema.
Three reasons. The schema does not constrain specVersion at all -- it is
{"type": "string"} with no enum in 1.4, 1.5 and 1.6 alike -- so validating
against it cannot test the selection this change is about; what rejects 1.7 is
the consumer's own version dispatch, which no schema reproduces. bom-1.6 is
262 KB and $refs spdx.schema.json and jsf-0.82.schema.json, so vendoring means
committing 332 KB of third-party JSON. And it would add jsonschema as a test
dependency to a library that declares three runtime ones. What a vendored
schema would really buy is the additionalProperties check, and this states that
in a form a reviewer can read.
The stale 1.7 citations go with it: the component-type enum comment, the
composition aggregate values in external_identification (all three emitted
values are in the 1.4 enum too, so the coverage document stays valid across the
whole range), the properties[].value typing note, and docs/data-formats.md.
Cross-shape: the oldest fixture in the tree, from April 2023, carries none of
the attributes the later fixtures were written around and is asserted to take
the new default with its inventory unchanged. Models are exported on demand
with a multi-month lifetime, so a default proven only on fixtures written in
lockstep with today's analyzer is proven on the easy half of the corpus.
Suite 506 -> 516.
'@' is the VERSION SEPARATOR in a purl. A raw leading '@' in the name component
therefore leaves the string ambiguous to a parser whatever the ecosystem --
pkg:generic/@example/foo@1.0 can be split in more than one place -- so encoding
it is a well-formedness requirement rather than an npm courtesy. The npm package
definition is merely where the rule happens to be written down.
Keyed on npm, the rule missed exactly the population that needs it. A scoped npm
package whose ecosystem does NOT resolve falls through to the 'generic' type and
so never reached the npm branch, and was published with a raw '@'. One reported
export carried 90 such rows. The type an element ended up with after a
resolution FAILURE is the one thing that cannot justify emitting a malformed
identifier.
Applied at the shared assembly point rather than as a second per-ecosystem
branch: purl_for canonicalises once, before its four returns, so a purl type
added later inherits the rule instead of repeating the defect. The maven branch
returns earlier and is unaffected, which is correct -- a maven id is built from
POM coordinates and its charset guard already excludes '@'.
WHAT THIS IS NOT VALIDATED AGAINST. The 20 local models contain ZERO affected
rows. Every one of the 2 171 scoped purls in them is npm and was already
encoded, so across 9 585 emitted purls this change moves nothing at all: it is a
verified no-op on every model we hold. Its actual repair is demonstrated only by
unit tests and a fixture, NOT against real affected data. The corpus run is
evidence of regression safety and of nothing else -- component counts, document
shapes and the identification ledger are unchanged in all 20 -- and should not
be read as confirming the fix works on the rows it exists for.
No double-encoding guard is written, deliberately. An already-encoded name
begins '%40' rather than '@', so it cannot match the condition in the first
place. A guard would be a branch no input can reach, which is worse than absent:
it would read as evidence that some caller passes a pre-encoded name. A test
pins the property directly instead, across every type, because after the move
any type can be the one receiving a pre-encoded name.
The pypi branch now falls through instead of returning, so a scoped name gets
BOTH rules -- folding and encoding -- where before the early return meant the
first rule to match was the only one that ran. A test exercises the one shape
that can tell 'both ran' from 'the first returned'.
The join is untouched, confirmed by execution rather than by reading. match_key
is the other operation and is deliberately wider; it feeds the distinct-package
count and the join index, both of which key on names read from the MODEL rather
than on emitted purls. A fixture holding one scoped package under a resolvable
root and under an unresolvable one shows the generic purl moving from
pkg:generic/@example/pkg@1.0.0 to pkg:generic/%40example/pkg@1.0.0 while the
match identities stay ('npm', '@example/pkg') -- the raw spelling -- in both
runs, along with the ledger outcomes and the walked-element count. Had the
encoding reached the join, every stored scoped package would have stopped
matching itself on the day this shipped.
Suite 516 -> 542.
A level-based export splits at a tree depth, and what sits at that depth is
usually a repository but not always. In one reported export 9 of 689 documents
described elements that are not t="repository" in the model, and every one of
them emitted zero components. To a consumer that is indistinguishable from a
repository with no dependencies, and the two mean opposite things: "nothing to
report" versus "this is not the kind of thing that reports".
component.type cannot carry it. CycloneDX closes that enum and has no
'repository' value, so a softagram:-namespaced property is the schema-lawful
place -- the same reasoning that put softagram:elementPath there, and it goes in
beside it.
Added inside _add_element_location rather than at any call site, so all three
components that describe a model element get it from one edit: the legacy
single-document subject, the per-element subject, and an internal element
inlined into another document. The inlined one matters most, being where a
consumer meets an element it did not ask for a document about.
PUBLISHED ONLY WHEN THE MODEL CARRIES A TYPE, and that is the whole of the
decision. Repo elements are not required to carry a 'type' attribute --
_add_vcs_reference documents that same fact and builds its ancestor walk around
it -- so an absent attribute is evidence of nothing. Emitting a sentinel there,
'unknown' or an empty string, would convert "the model did not say" into a
positive claim that this element is not a repository, and a consumer filtering
on elementType != 'repository' would then exclude genuine repositories on the
strength of a value invented here. That is the same false inference the property
exists to remove, relocated one layer along rather than fixed. An absent
property reads as "not stated", which is the truth; the honest repair for the
silence itself is analyzer-side.
str(): the model stores attributes untyped and CycloneDX types
properties[].value as a string in every version this generator can declare. A
non-string reaching the document would not degrade one field, it would make a
validating consumer discard the whole SBOM.
MEASURED, and wider than the reported shape suggests. Over softagram-live,
CrossTranslate and intra -- 1 790 documents across single, level-2 and level-3
transitive modes -- 1 156 documents gain the property. The values are
repository 5, dir 241, file 632, _file 31, function 66, variable 134, page 33,
unknown_binary 6, class 5, openapi 1, service 1, database 1, plus 1 012 on
inlined components. That is 65% of documents rather than the 1.3% the reported
9-of-689 shape implies, because a level split on a small single-repository model
lands on file and function elements where the same split on a 689-repository
estate lands on repositories. The property is correct in both cases -- it states
what the model says -- but the volume of new output is a fact worth knowing
before release rather than after.
Every one of those 1 790 documents was validated against the official bom-1.4,
bom-1.5 and bom-1.6 schemas: 5 370 validations, ZERO errors. The new property is
a {name, value} pair of strings, which is the only shape those schemas permit
there, so validity was never in question -- it is checked because the schemas set
additionalProperties:false and an unchecked assumption about that is how a whole
document gets discarded.
No committed fixture carries a 'type' attribute on a content element, so the
property is emitted nowhere in them and a test asserts that: every existing
consumer of those documents receives exactly what it received.
Suite 542 -> 547.
couldNotIdentify was the headline number of the coverage report, and it was not answerable. Across the 20 local models it read 80 971. 78 877 of those were elements under External roots this library has no rule for, so no identification was ever attempted on them and none could have failed. WHAT THE NUMBER NOW MEANS, and this is the point of the change rather than a side effect. The old figure was a claim about the ELEMENTS: 80 971 third-party packages were looked for and not found. That claim was not supportable and, as the reporting customer put it, a number that overstates gets discounted rather than acted on. The split makes couldNotIdentify a claim about elements that genuinely asserted a package identity, and moves the rest into a claim about THIS LIBRARY'S COVERAGE OF ROOTS. The second number is actionable in a way the first was not: the work it names is "write rules for a couple of dozen roots", which is bounded and can be finished, rather than "identify tens of thousands of elements", which is neither. SOME GENUINELY MISSED PACKAGES ARE INSIDE THE NEW BUCKET, and this must not be read as 78 877 elements shown to be non-packages. They are not shown to be anything. The reporting customer's own analysis is the clearest case: they identified their JavaScript rows as real npm packages without a version -- true identification misses -- and JavaScript has no ROOT_KINDS entry, so those rows land in unknownRootKind here alongside the type symbols. That is honest, because "no rule exists for this root" is true of all of them equally, but it is not precise, and nothing in this change pretends otherwise. Adding a ROOT_KINDS row per root is what separates them out again, and until that is done the new bucket is a mixture whose composition is unknown by construction. THE ROOTS WITH NO RULE, so the follow-up is named rather than implied. Measured across the 20 local models, with the residue each held before this change: TypeScript 68 689 -- one root is 85% of the entire old headline JavaScript 4 510, Unknown_Binary_Files 1 244, OdooXmlId 1 151, OdooModel 633, androidx 518, Node 276, java 274, Rust 260, org 218, android 162, OdooModule 143, javax 141, DotNet 135, OdooField 131, y 130, OdooRelated 104, kotlinx 103, com 64, kotlin 41, okhttp3 27, coil3 7, PHP 6 Twenty-three roots. Several are obvious on inspection -- Node holds zlib, util, vm and worker_threads, which are builtins; Unknown_Binary_Files is an analyzer catch-all whose 1 244 members carry zero incoming references between them, the same shape as the existing PIP/Unknown Requirements Files row -- but none is added here, because a row is a claim about an analyzer's namespace and this change is about not making claims that were never measured. TWO CAUSES, both a rule never reached rather than a rule that was wrong. role_of returns ROLE_UNKNOWN for any root absent from ROOT_KINDS, and _classify had no branch for that role, so it fell through to the final return: package_candidate_without_version, whose outcome is couldNotIdentify. The fifth outcome, unknownRootKind, says what is true and no more. It is NOT notAPackage: an unmapped JavaScript or TypeScript root holds real npm packages at package depth beside code symbols one level deeper, and calling them non-packages would be the same unfounded claim reversed. It is not couldNotIdentify either, because no identification was attempted. It is the only outcome that reports this library's own limit rather than a fact about the model, which is why it is separate rather than folded into one of the four. THE DISTINCTION IT RESTS ON is the one ROOT_KINDS already states about itself: "A root absent from this table is unknown, which is a different fact from a root that is known to assert no ecosystem." Docker, JVM and Java are IN the table mapped to an empty set -- the tables have looked at them and have an answer -- so they keep today's category exactly, and a test pins that. What is explicitly NOT inferred: that a root's absence from a table makes its contents non-packages. That inference is unsound in both directions and was measured to be so. JavaScript is absent and holds real npm packages; Python is present and holds genuine misses. Depth does not separate them either -- java/util at package depth is the standard library while TypeScript/vue-router at the same depth is a real package -- so no shape rule is invented. Second cause: is_root_node asked whether the element's External-relative path was a key of the ECOSYSTEM table, so a root the tables had no row for was not recognised as a root, and the bucket element itself was reported as a package that failed identification -- named after the bucket it is. external_root_key never agreed with that reading, since it falls back to the first segment; two functions in one module answering "what is a root" differently is the bug. Depth settles it without consulting any table: one segment below External is a bucket, whatever is or is not known about what it holds. 103 elements across the corpus, and every depth-1 element under External was checked to be a bucket and none a package. That second fix is small in count and large in consequence for the fixtures. The committed emission fixture's ENTIRE claim to an incomplete assembly was one element, /ExampleOrg/External/Unknown_Binary_Files, the root bucket itself. With it repaired the fixture has nothing unidentified left, so the completeness test moves to the maven-coordinates fixture, which carries three externals that genuinely cannot be identified. Across all six committed fixtures, every residue element except those three was a misclassified root bucket. The completeness claim is deliberately NOT softened by the split. coverage_compositions read couldNotIdentify alone, so moving rows out of it would have flipped a document from 'incomplete' to the weaker 'unknown' as a side effect of bookkeeping -- telling a consumer the assembly is merely inconclusive when relationships beyond those listed do exist. Both buckets now drive 'incomplete'. Verified: no model's aggregate changed. MEASURED, before and after, over all 20 local models: couldNotIdentify 80 971 -> 1 991 unknownRootKind 0 -> 78 877 notAPackage 9 868 -> 9 971 (the 103 repaired root buckets) intra couldNotIdentify 1352 -> 431, unknownRootKind 916 CrossTranslate couldNotIdentify 225 -> 100, unknownRootKind 121 Conservation holds exactly in all 20: couldNotIdentify_before == couldNotIdentify_after + unknownRootKind + (notAPackage_after - notAPackage_before). elementsWalked and componentsEmitted are unchanged in every model, coveredElsewhere and versionUnknownByDesign are unchanged in every model, and no completeness aggregate moved. Nothing was dropped; one number was split under a truthful name and a second was repaired. The count is published rather than hidden: SUMMARY_PROPERTIES gains a fifth fixed-cardinality entry, coverageUnknownRootKind, so a consumer wanting the old total adds the two. CATEGORY_UNKNOWN_ROOT_KIND is deliberately outside PACKAGE_CLAIMING_CATEGORIES: its members claim nothing, so counting distinct packages among them would answer a question none of them asked. Suite 547 -> 554.
… does Four of this branch's changes are consumer-visible, and the reference asserted things they falsified. Shipping code that contradicts its own documentation is worse than shipping neither: a consumer who trusts a written invariant and finds it false has no way to tell which of the remaining ones still hold. The selectable specification version, the default of 1.6 and the supported set are documented, including the mode asymmetry: --spec-version is accepted in every mode, unlike the closure flags the single-SBOM mode refuses. The reason is recorded rather than left to be rediscovered - a closure flag would be silently ignored there, while refusing a version selection would strand the one export shape that has no --level behind a version its consumer cannot read. softagram:elementType is documented beside softagram:elementPath, and the entry leads with the rule a consumer will otherwise get wrong: the property is ABSENT when the model carries no type, and absence means "not stated", never "not a repository". Filter on presence before filtering on value. It also warns that the value set is wider than repository and dir - a level split on a small model reaches file, function, variable, class and page elements. coverageUnknownRootKind is documented with the reasoning for why it is not part of coverageCouldNotIdentify, and with what it explicitly does NOT claim: a root's absence from the tables does not make its contents non-packages, and depth does not separate them either. The counts are described as five throughout, and the aggregate table's 'incomplete' row now names both buckets. THE serialNumber GUARANTEE WAS SIMPLY FALSE and is now scoped. It read `deterministic_serial(elementPath) == serialNumber` unconditionally. That holds in --level and --element-path mode; in single-SBOM mode serialNumber is a random UUID v4 minted per call. The sharp edge is that single mode DOES publish softagram:elementPath, so a consumer following the documented recipe computes a serial that can never match - the invariant is falsifiable there and fails. Documented rather than repaired on purpose: serialNumber is the identity a consumer files the document under, so making single mode deterministic would re-key or duplicate every existing single-SBOM consumer's records. That is a migration deserving its own change, its own measurement and its own release note. Pre-existing, not introduced here. THE group JUSTIFICATION WAS AN OVERCLAIM, and the correction is to the stated reasoning rather than to the behaviour. group keeps the parent's full path; that is deliberate and unchanged. What was wrong was the claim that the path is "what makes two identically named groups distinguishable", which reads as though the prefix were necessary. Measured across three real single-root estates at level 2 and level 3, every full-path group value mapped one-to-one onto its bare name - 73 to 73, 78 to 78, 11 to 11 - so the prefix disambiguated nothing in any of them. The text now says what is true: it is a guarantee that holds for every model rather than a fix for a collision every model has, the collision it guards against is between GROUPS specifically, and a reader wanting an unambiguous identifier should use softagram:elementPath instead. Both directions of overclaim are avoided - the guarantee is real, and it is not doing the work it was said to be doing. Every invariant written here was executed rather than asserted: the version selection in single mode, the random-versus-derived serials in both modes, the five summary property names, and group + '/' + name == elementPath. README names no CycloneDX version and needed no change; the remaining '1.7' in this file is a JSON size ratio.
Softagram Impact Report for pull/190 (head commit: 8057f3b)TL;DR Arch. Impact: -10 | Changed code files: 6 | Directly impacted code files: 1⭐ Change Overview
⭐ Details of Dependency Changes (diagram)
🤖 AGENTS - machine-readable impact data (6 files changed, 1 impacted, +64/-0 deps)Change overviewHead Added dependencies (61, showing 50)
11 more omitted. Complete data: https://opensource.softagram.com/cdn/impact/d20d45f2-ea4e-4ae4-a9ca-0f350bd8fac5_sgraph_190_impact_change_graph_X07RqV1WOC2CS9T1khMVPJIrydqGGS.png_change_info.json Removed dependencies (0)None. Impacted files (1)Unchanged files that directly depend on files changed in this PR - check them for behavioral impact. Grouped by changed file:
Complete data
[] 📄 Full report
Impact Report explained. Give feedback on this report to support@softagram.com |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Round-3 SBOM export feedback from a customer running the level-3 bulk export into
Dependency-Track. Five separable changes; each commit stands alone and is independently
revertable.
The decision this PR is really asking you to make
The default
specVersionchanges 1.7 → 1.6. Everything else here is additive.CycloneDX 1.7 was published 2025-10-21. A consumer built against an earlier library
rejects a 1.7 document on the version string alone, before reading any content — one
reported instance runs cyclonedx-core-java 9.0.5, where the export is unusable in full.
The question is whether anything we emit actually needs 1.7. Measured, not assumed:
--transitive-externals,and a
--coveragedocument carryingcompositions) validated against the officialbom-1.4/1.5/1.6 schemas with only the version string swapped: 0 errors at all three.
Those schemas set
additionalProperties: falseat both the document root anddefinitions.component, so the zeros are a statement about content, not a schemadeclining to look.
20 identical, 0 differing, once
specVersion,metadata.timestampandserialNumberare excluded. A literal line-level diff over 382,244 output lines found 1,784 differing
lines, all of them
specVersion, and zero others — which also confirms field orderis preserved.
So declaring 1.6 loses a consumer no information and gains it the ability to read the
document at all. A caller wanting the newest asks by name:
--spec-version 1.7.1.3 is deliberately excluded from the supported set — it is permissive where the later
three are strict, so a document passing against it would prove nothing.
Commits
a3455e2--spec-versionflag +spec_versionkeyword on all three public entry points; default 1.6c7199fc70bdc8fsoftagram:elementTypeso a consumer can tell a repository document from adirorfunctionone7bf13638057f3b§8 — a malformed identifier, not an npm courtesy
canonical_purl_nameencoded a leading@only whenpkgtype == 'npm', so a scoped npmname whose ecosystem did not resolve fell through to
genericand was published with a raw@.@is the purl version separator, so a raw leading@is ambiguous to a parserwhatever the ecosystem. Now applied at the shared assembly point.
No double-encoding guard is written, deliberately: an already-encoded name begins
%40, soit cannot match — a guard would be an unreachable branch implying some caller passes
pre-encoded names. A test pins the property instead.
Not validated against real occurrences: our corpus has zero affected rows (0 of 9,585
emitted purls before and after). The corpus run is evidence of regression safety and of
nothing else; the repair is demonstrated by fixture.
§6 — emitted only when the model carries a type
Repo elements are not required to carry a
typeattribute —_add_vcs_referencealreadydocuments that and builds its ancestor walk around it. Emitting
'unknown'would convert"the model did not say" into a positive claim that this element is not a repository, and
a consumer filtering
elementType != 'repository'would then exclude genuine repositorieson a value this code invented. Absence reads as "not stated", which is true.
Review note: the blast radius is larger than the reported symptom implies. On a small
single-repository model a level split lands on
fileandfunctionelements, so ~65% ofdocuments gain the property, not the ~1% that "9 of 689" suggests. Correct in both cases —
it states what the model says — but worth seeing before release.
§5b — the headline number stops overstating
role_ofreturnsROLE_UNKNOWNfor any root absent fromROOT_KINDS, and_classifyhadno branch for it, so it fell through to
package_candidate_without_version→couldNotIdentify. "We have no rule for this kind of root" was published as "a packagewas looked for here and not found."
A fifth outcome
unknownRootKindreports this library's limit instead. Corpus-wide over20 models:
elementsWalked,componentsEmitted,coveredElsewhereandversionUnknownByDesignareunchanged in every model, and no completeness aggregate moved.
Also fixed here:
is_root_nodeasked whether a path was a key ofROOT_ECOSYSTEM, whileexternal_root_keyfell back to the first segment — two functions in one module answering"what is a root" differently, so an unmapped root element was itself reported as a failed
package identification, named after the bucket it is. Fixed by depth, consulting no table.
Stated plainly: some genuinely missed packages are inside the new bucket. An unmapped
JavaScriptroot holds real npm packages without versions; those move tounknownRootKindalongside type symbols. "No rule exists for this root" is true of all of them equally, but
the bucket is a mixture whose composition is unknown by construction. This must not be read
as 78,877 elements shown to be non-packages — they are not shown to be anything. Adding a
ROOT_KINDSrow per root is what separates them again; no speculative rows were added,because a row is a claim about an analyzer's namespace and the point of the change is to
stop making unmeasured claims. 23 roots have no rule;
TypeScriptalone holds 68,689 ofthe old headline.
Deliberately not in scope
groupkeeps the full parent path. The reasoning in the docs was an overclaim and iscorrected — measured across three real estates at two levels, every full-path value maps
1:1 onto its bare name (73→73, 78→78, 11→11), so the prefix disambiguates nothing in any
real model here. It remains a guarantee that holds for every model rather than a fix for
a collision every model has, and
softagram:elementPathalready carries the unambiguousidentifier. Behaviour unchanged; a reviewer who wants the bare name should say so.
--coverage(scoped to a subtree).external_identificationimportsfrom the generator and both docstrings state the generator must never import it, so this
cannot be attached inside
_sbom_for_content_elementwithout inverting the dependency.Its own change.
serialNumberdeterminism. Single-SBOM mode mints a random UUID v4 per call whilemulti mode derives it from the element path; the docs asserted determinism
unconditionally and are now scoped. The code is left alone on purpose:
serialNumberisthe identity a consumer files the document under, so changing it would re-key or
duplicate existing records. Own change, own release note.
Known adjacent defect, not fixed here
resolve_license_spdx_idreturns the literal'UNKNOWN LICENSE'for anything but MIT(
license_mapping_to_spdx_idis{}), andbom_licensesputs it inlicense.id, which$refs the SPDX identifier enum;urlgets the literal'UNKNOWN'. Any document reachingthat path is invalid at 1.4, 1.5, 1.6 and 1.7 alike — unrelated to this change. It does
not fire today: 0 elements under
Externalcarry alicenseattribute across the corpus(which is why the 892-document run is clean), but 1,852 internal ones do. The day a license
analyzer stamps externals, every non-MIT one produces an invalid document.
Verification
sbom_cyclonedx_generator.py:855is pre-existing — same line exists onmainat :831 andis not touched by this diff).
current analyzer — is asserted to take the default with its inventory unchanged and to be
byte-equivalent across all four supported versions.
specVersionhas no enum,const or pattern in any CycloneDX schema, so validation can never catch a wrong
version declaration — the consumer's parser is the sole gate. What the test does catch is
the realistic regression (a future 1.7-only field emitted under a 1.6 claim) by
enumerating bom-1.6's permitted key sets at every object level the generator emits.
Trade-off accepted: the key sets are a hand-maintained mirror, and since CycloneDX adds
members without removing them, drift makes the test permissive rather than falsely red.
Downstream coupling
Releasing this needs a minor bump, and
softagram-livecurrently pinssgraph==1.15.0.Moving that pin changes what the REST export emits with no API change and no announcement,
because
model_api.pypasses no spec version at any of its three call sites. The pin moveand plumbing the selector through the API want to be one piece of work, not two.