Skip to content

SBOM: selectable spec version defaulting to 1.6, honest coverage, element type, purl scope encoding - #190

Merged
villelaitila merged 5 commits into
softagram:mainfrom
villelaitila:feature/sbom-export-feedback-r3
Aug 25, 2026
Merged

SBOM: selectable spec version defaulting to 1.6, honest coverage, element type, purl scope encoding#190
villelaitila merged 5 commits into
softagram:mainfrom
villelaitila:feature/sbom-export-feedback-r3

Conversation

@villelaitila

Copy link
Copy Markdown
Contributor

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 specVersion changes 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:

  • 892 documents from 20 real models (single-SBOM, level-2 with --transitive-externals,
    and a --coverage document carrying compositions) validated against the official
    bom-1.4/1.5/1.6 schemas with only the version string swapped: 0 errors at all three.
    Those schemas set additionalProperties: false at both the document root and
    definitions.component, so the zeros are a statement about content, not a schema
    declining to look.
  • Content is unchanged. Regenerating all 20 models on old and new code and diffing:
    20 identical, 0 differing, once specVersion, metadata.timestamp and serialNumber
    are 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 order
    is 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 §2 --spec-version flag + spec_version keyword on all three public entry points; default 1.6
c7199fc §8 encode a leading scope marker in every purl type, not only npm
70bdc8f §6 publish softagram:elementType so a consumer can tell a repository document from a dir or function one
7bf1363 §5b stop reporting "no rule for this root" as "a package was looked for and not found"
8057f3b docs brought back in line with the code

§8 — a malformed identifier, not an npm courtesy

canonical_purl_name encoded a leading @ only when pkgtype == 'npm', so a scoped npm
name whose ecosystem did not resolve fell through to generic and was published with a raw
@. @ is the purl version separator, so a raw leading @ is ambiguous to a parser
whatever the ecosystem. Now applied at the shared assembly point.
No double-encoding guard is written, deliberately: an already-encoded name begins %40, so
it 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 type attribute — _add_vcs_reference already
documents 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 repositories
on 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 file and function elements, so ~65% of
documents 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_of returns ROLE_UNKNOWN for any root absent from ROOT_KINDS, and _classify had
no 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 package
was looked for here and not found."

A fifth outcome unknownRootKind reports this library's limit instead. Corpus-wide over
20 models:

couldNotIdentify   80 971 ->  1 991
unknownRootKind         0 -> 78 877
notAPackage         9 868 ->  9 971   (103 repaired root buckets)
SUM                91 632 == 91 632   conservation holds exactly

elementsWalked, componentsEmitted, coveredElsewhere and versionUnknownByDesign are
unchanged in every model, and no completeness aggregate moved.

Also fixed here: is_root_node asked whether a path was a key of ROOT_ECOSYSTEM, while
external_root_key fell 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
JavaScript root holds real npm packages without versions; those move to unknownRootKind
alongside 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_KINDS row 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; TypeScript alone holds 68,689 of
the old headline.

Deliberately not in scope

  • group keeps the full parent path. The reasoning in the docs was an overclaim and is
    corrected — 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:elementPath already carries the unambiguous
    identifier. Behaviour unchanged; a reviewer who wants the bare name should say so.
  • Per-document --coverage (scoped to a subtree). external_identification imports
    from the generator and both docstrings state the generator must never import it, so this
    cannot be attached inside _sbom_for_content_element without inverting the dependency.
    Its own change.
  • serialNumber determinism. Single-SBOM mode mints a random UUID v4 per call while
    multi mode derives it from the element path; the docs asserted determinism
    unconditionally and are now scoped. The code is left alone on purpose: serialNumber is
    the 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_id returns the literal 'UNKNOWN LICENSE' for anything but MIT
(license_mapping_to_spdx_id is {}), and bom_licenses puts it in license.id, which
$refs the SPDX identifier enum; url gets the literal 'UNKNOWN'. Any document reaching
that 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 External carry a license attribute 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

  • Full suite 506 → 554 passed; tree clean; no lint regressions (one E501 at
    sbom_cyclonedx_generator.py:855 is pre-existing — same line exists on main at :831 and
    is not touched by this diff).
  • Cross-shape safety: a fixture model committed in April 2023 — genuinely older than the
    current analyzer — is asserted to take the default with its inventory unchanged and to be
    byte-equivalent across all four supported versions.
  • Offline schema test rather than a vendored 332 KB schema set: specVersion has 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-live currently pins sgraph==1.15.0.
Moving that pin changes what the REST export emits with no API change and no announcement,
because model_api.py passes no spec version at any of its three call sites. The pin move
and plumbing the selector through the API want to be one piece of work, not two.

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

Copy link
Copy Markdown

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

Showing the changed files, dependency changes and the impact - click for full size
(Open in Softagram Desktop for full details)

⭐ Details of Dependency Changes (diagram)

details of dependency changes - click for full size
(Open in Softagram Desktop for full details)

🤖 AGENTS - machine-readable impact data (6 files changed, 1 impacted, +64/-0 deps)

Change overview

Head 8057f3b8950e vs base 1140ed1eb50d. 6 code files changed. 1 unchanged files directly depend on the changed files (see Impacted files). Dependencies: 64 added, 0 removed. New external components: 4. Removed external components: 0.

Added dependencies (61, showing 50)

from to type roles signal
sgraph/tests/converters/sbom_cyclonedx_generator_test.py sgraph/src> sgraph/converters/external_identification.py/attach_coverage_compositions import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py sgraph/src> sgraph/converters/external_identification.py/attach_coverage_summary import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py sgraph/src> sgraph/converters/external_identification.py/external_coverage_report import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_unsupported_spec_version_is_refused_naming_the_ones_that_work External/Python/call ref test→external new external component
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_key_the_default_document_carries_is_one_the_default_version_defines External/Python/sgraph/converters/external_identification/attach_coverage_compositions import test→external new external component
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_key_the_default_document_carries_is_one_the_default_version_defines External/Python/sgraph/converters/external_identification/external_coverage_report import test→external new external component
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_type_reaches_the_legacy_and_inlined_components_too sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/typed_estate_model sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/test_external_identification.py/test_a_root_known_to_assert_no_kind_keeps_exactly_todays_answer sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/test_external_identification.py/test_an_unknown_root_is_recognised_as_a_root_whatever_the_tables_say External/Python/import ref test→external new external component
sgraph/tests/converters/test_external_identification.py/test_an_unmatched_image_under_a_known_root_is_left_alone sgraph/src> sgraph/sgraph.py/SElement import test→prod expands test coverage
sgraph/tests/converters/test_external_identification.py/test_an_unmatched_image_under_a_known_root_is_left_alone sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/test_external_identification.py/unknown_root_model sgraph/src> sgraph/sgraph.py/SGraph import test→prod expands test coverage
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_repository_is_told_apart_from_a_non_repository_element External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_requested_spec_version_cannot_leak_into_the_next_call External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_unsupported_spec_version_is_refused_naming_the_ones_that_work External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_unsupported_spec_version_is_refused_naming_the_ones_that_work External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_untyped_element_publishes_no_type_rather_than_a_guess External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_each_supported_spec_version_reaches_every_entry_point External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_each_supported_spec_version_reaches_every_entry_point External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_key_the_default_document_carries_is_one_the_default_version_defines External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_key_the_default_document_carries_is_one_the_default_version_defines External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_stored_fixture_is_unchanged_because_none_carries_a_type External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_stored_fixture_is_unchanged_because_none_carries_a_type External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_default_spec_version_is_one_the_deployed_estate_can_read External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_oldest_stored_fixture_takes_the_new_default_like_every_other External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_published_type_is_a_string_as_cyclonedx_requires External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_spec_version_is_the_only_thing_the_spec_version_changes External/Python/Usual dependencies import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_type_reaches_the_legacy_and_inlined_components_too External/Python/sgraph/converters/sbom_cyclonedx_generator/generate_multi_from_sgraph import test→external external dependency
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_type_reaches_the_legacy_and_inlined_components_too External/Python/Usual dependencies import test→external external dependency
sgraph/src> sgraph/converters/external_identification.py sgraph/src> sgraph/converters/external_root_semantics.py/ROLE_UNKNOWN import prod→prod regular
sgraph/src> sgraph/converters/external_root_semantics.py/is_root_node sgraph/src> sgraph/converters/external_root_semantics.py/external_relative_segments func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/generate_for_element_from_sgraph sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_validated_spec_version func_ref prod→prod regular
sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/generate_multi_from_sgraph sgraph/src> sgraph/converters/sbom_cyclonedx_generator.py/_validated_spec_version func_ref prod→prod regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_generic_name_without_a_scope_is_untouched sgraph/tests/converters/sbom_cyclonedx_generator_test.py/canonical_component func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_repository_is_told_apart_from_a_non_repository_element sgraph/tests/converters/sbom_cyclonedx_generator_test.py/find_property func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_scoped_name_whose_ecosystem_did_not_resolve_is_still_encoded sgraph/tests/converters/sbom_cyclonedx_generator_test.py/canonical_component func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_a_scoped_name_whose_ecosystem_did_not_resolve_is_still_encoded sgraph/tests/converters/sbom_cyclonedx_generator_test.py/purl_type_resolution func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_untyped_element_publishes_no_type_rather_than_a_guess sgraph/tests/converters/sbom_cyclonedx_generator_test.py/sbom_of func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_an_untyped_element_publishes_no_type_rather_than_a_guess sgraph/tests/converters/sbom_cyclonedx_generator_test.py/find_property func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_key_the_default_document_carries_is_one_the_default_version_defines sgraph/tests/converters/sbom_cyclonedx_generator_test.py/objects_by_cyclonedx_kind func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_every_stored_fixture_is_unchanged_because_none_carries_a_type sgraph/tests/converters/sbom_cyclonedx_generator_test.py/find_property func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_generic_encoding_does_not_touch_the_disclosed_name sgraph/tests/converters/sbom_cyclonedx_generator_test.py/canonical_component func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_oldest_stored_fixture_takes_the_new_default_like_every_other sgraph/tests/converters/sbom_cyclonedx_generator_test.py/without_the_fields_allowed_to_move func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_spec_version_is_the_only_thing_the_spec_version_changes sgraph/tests/converters/sbom_cyclonedx_generator_test.py/without_the_fields_allowed_to_move func_ref test→test regular
sgraph/tests/converters/sbom_cyclonedx_generator_test.py/test_the_type_reaches_the_legacy_and_inlined_components_too sgraph/tests/converters/sbom_cyclonedx_generator_test.py/find_property func_ref test→test regular
sgraph/tests/converters/test_external_identification.py/test_a_root_known_to_assert_no_kind_keeps_exactly_todays_answer sgraph/tests/converters/test_external_identification.py/category_of func_ref test→test regular
sgraph/tests/converters/test_external_identification.py/test_a_root_known_to_assert_no_kind_keeps_exactly_todays_answer sgraph/tests/converters/test_external_identification.py/external func_ref test→test regular
sgraph/tests/converters/test_external_identification.py/test_a_root_known_to_assert_no_kind_keeps_exactly_todays_answer sgraph/tests/converters/test_external_identification.py/identification func_ref test→test regular
sgraph/tests/converters/test_external_identification.py/test_an_element_under_a_root_with_no_rule_makes_no_identification_claim sgraph/tests/converters/test_external_identification.py/category_of func_ref test→test regular

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:

changed file directly impacted dependents
sgraph/src/sgraph/converters/sbom_cyclonedx_generator.py 1: sgraph/src/sgraph/graphdataservice.py

Complete data

[]

📄 Full report

Impact Report explained. Give feedback on this report to support@softagram.com

@villelaitila
villelaitila merged commit 730a24e into softagram:main Aug 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants