Skip to content

Commit 834eebe

Browse files
bomly-guyclaude
andcommitted
docs: address review — three-kind node union, PURL-spec validity, sealed contracts
The union grows to what the CLI actually models, in reviewer-directed vocabulary: manifest, module (the project's own root and workspace artifacts — first-party ownership becomes the kind, not a boolean), and dependency nodes, with both project shapes (manifest→module→dependencies and nested workspace manifests) as plain edge patterns. Dependency-node validity delegates entirely to the PURL specification via packageurl-go — scheme+type+name plus each type's own rules — with missing versions warned as the one Bomly policy. The identity relation is now kind-scoped with the ADR-0033 three-way origin rule (equal folds, gap fills, different coexists), which also makes cross-kind folds impossible by construction. Specified: the identity-form qualifier gate for published IDs, ordinal start/scope/order, and the wire kind discriminator (authoritative when present, deterministic legacy inference, unknown = decode error, four frozen fixtures). Every remaining ADR-0036 identity reference in SDK_MATURITY_PLAN.md is rescoped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3bb4625 commit 834eebe

2 files changed

Lines changed: 129 additions & 62 deletions

File tree

dev-docs/SDK_MATURITY_PLAN.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
- **Date:** 2026-08-26
44
- **Owners:** bomly-sdk and bomly-cli maintainers
5-
- **Decisions:** [ADR-0036](adr/0036-dependency-identity-is-content-addressable.md)
6-
(content-addressable identity), [ADR-0037](adr/0037-sbom-assertions-are-typed-sdk-model-fields.md)
5+
- **Decisions:** [ADR-0041](adr/0041-identity-is-the-canonical-purl-on-typed-nodes.md)
6+
(PURL identity on typed nodes, superseding
7+
[ADR-0036](adr/0036-dependency-identity-is-content-addressable.md)),
8+
[ADR-0037](adr/0037-sbom-assertions-are-typed-sdk-model-fields.md)
79
(typed SBOM model), [ADR-0038](adr/0038-purl-and-spdx-behavior-have-one-home-in-the-sdk.md)
810
(PURL/SPDX single home), [ADR-0039](adr/0039-both-modules-build-on-go-1-27.md)
911
(Go 1.27, strict untrusted parsing)
@@ -51,7 +53,7 @@ version — and the PURL-as-ID rewrite happens at three sites with three
5153
fallback chains
5254
(`internal/engine/consolidation/enrichment.go:26`,
5355
`internal/detectors/sbom/detector.go:164`, `internal/sbom/graph.go:37` — only
54-
the first canonicalizes). Nothing is content-addressable.
56+
the first canonicalizes). No single entry point owns identity.
5557

5658
**SBOM field coverage.** The SDK has no supplier, originator, description,
5759
homepage, external references, declared-vs-concluded license distinction,
@@ -100,10 +102,12 @@ defense for SBOM ingest), stdlib `uuid`, generic methods, `strings.CutLast`.
100102

101103
One sentence per decision; the ADRs carry the detail.
102104

103-
- **Identity** (ADR-0036): identity facets defined once in the SDK — a
104-
readable ID (canonical PURL + hashed occurrence suffix) and a derived
105-
128-bit content address over a versioned facet encoding — minted only by
106-
SDK entry points.
105+
- **Identity** (ADR-0041): a sealed typed node union — manifest, module,
106+
and dependency nodes — where a dependency node's identity is its
107+
canonical PURL (valid by the PURL specification's own rules, missing
108+
versions warned), the occurrence qualifier is the normalized origin, and
109+
comparison is a kind-scoped equals/key pair with ADR-0033 gap-filling;
110+
IDs and ordinals minted only by SDK entry points; no content address.
107111
- **Model** (ADR-0037): every preserved SBOM assertion is a typed,
108112
`omitempty`, boundary-validated field with a declared merge class; typed
109113
edges; a per-entry document-assertions carrier on `GraphEntry`; `Metadata`
@@ -140,7 +144,7 @@ Breaking in-process changes are allowed (v0 policy); the wire stays additive
140144
|---|---|---|
141145
| 1.1 | `purlkit`: qualifiers/subpath-capable build/parse/canonicalize; the single purl-type ↔ ecosystem table (hex non-mapping recorded); `SplitEcosystemName`; canonical-ID rewrite entry point; import-boundary guard | v0.5.0 |
142146
| 1.2 | `spdxkit`: absorb `internal/licenseexpr` semantics (panic guards, `Valid`/`ValidateAll`/`Identifier`/`Compose`/`Satisfies`/`Extract`); deprecated-ID canonicalization via the audited replacement map relocated from `internal/sbom/transform.go` (the list marks deprecation; the map owns replacements); classification-by-validation; deterministic `LicenseRef-*` minting + extracted-text pairing; fix `matcherkit.NormalizeLicenseSet` to classify on write | v0.5.0 |
143-
| 1.3 | Identity (ADR-0041, superseding ADR-0036): typed `GraphNode` union (`PackageNode` requires a valid canonical PURL — type+name mandatory, missing version warned; `ManifestNode` is structural, path-identified, never matched); identity equals/key comparison over (canonical PURL, normalized origin); PURL-based readable IDs with deterministic run-local ordinals for contradicting occurrences; single insertion + finalization entry points; wire flat-node shape kept with an additive kind discriminator; no content address | v0.6.0 |
147+
| 1.3 | Identity (ADR-0041, superseding ADR-0036): sealed `GraphNode` union — manifest / module / dependency nodes (dependency nodes require a spec-valid canonical PURL, missing version warned; modules are the project's own artifacts; manifests structural, path-identified, never matched); kind-scoped identity equals/key over (canonical PURL, normalized origin) with ADR-0033 gap-filling; PURL identity-form readable IDs with per-PURL deterministic ordinals from 1 for coexisting occurrences; single insertion + finalization entry points; wire flat-node shape plus additive `kind` discriminator with frozen explicit/legacy/conflicting/unknown fixtures; no content address | v0.6.0 |
144148
| 1.4 | Model fields (ADR-0037): supplier/originator/description/homepage; `ExternalReference`; `PackageLicense` declared/concluded + extracted text; digest-algorithm registry; set-aware scope ↔ CycloneDX mapping with its scalar projection rule; typed `DependencyEdge.Kind` with a kind-preserving edge-copy/rename primitive for graph reconstruction sites; usage attribution (`PackageLocation` carries per-site scopes and relationship so the node-level union becomes derived; reachability becomes repeatable per-module-root evidence with the vulnerability annotation as derived summary, and evidence may carry optional `DependencyRefs` to the exact occurrence nodes where the analyzer can attribute — a conjunctive filter such as reachable ∧ runtime ∧ direct then joins evidence to locations within one module root, selecting one usage); a derived package → nodes reverse-index helper (the stored truth stays `Dependency.PackageRef`; the registry remains position-free); per-`GraphEntry` document assertions; per-field-class merge helpers; boundary validation codecs + fuzz targets for every new parser | v0.7.0 |
145149
| 1.5 | Metadata policy: document reserved `bomly.` prefix; deprecate `MetadataKeyDetectionLicenses` in favor of the typed license field | v0.7.0 |
146150

@@ -157,7 +161,7 @@ the golden refresh happens **once**:
157161
|---|---|---|
158162
| 2.1 | Adopt `purlkit`: delete `internal/sbom/identity.go` table, `benchmark/summary.go` table, `render/explain.go` string surgery; detectors derive purl types (guard test forbids literals); one canonical-ID rewrite in consolidation, reused by SBOM ingest paths | Findings §2 duplication items |
159163
| 2.2 | Adopt `spdxkit`: delete `internal/licenseexpr`; the deprecated-ID replacement map relocates into the kit; export/import use kit classification | ADR-0035 stays behavioral truth, now SDK-enforced |
160-
| 2.3 | Adopt identity: node IDs SDK-derived end to end; regenerate schemas, goldens, smoke; release-notes callout for the one-time ID change | ADR-0036 |
164+
| 2.3 | Adopt identity: the typed node union and SDK-derived IDs end to end; regenerate schemas, goldens, smoke; release-notes callout for the one-time ID change | ADR-0041 |
161165
| 2.4 | **Close #410**: `LicenseRef-*` + `hasExtractedLicensingInfos` emission, mixed-validity composition, canonical ingest coordinates via `SplitEcosystemName`; round-trip asserts `Org`+`Name`+`EcosystemName()` together | Also removes ADR-0035's recorded limitation |
162166
| 2.5 | **Close #396** on the typed model: ingest populates typed fields through their gates; the export surface takes the prepared entries rather than the bare merged graph, so per-entry document assertions reach the codec; export projects them; merge follows the declared classes; fixed-point test (single-source export → ingest → export byte-stable for preserved fields; a merged export links source identities per ADR-0037, with merged fixtures for both formats validated through the codecs and the official format validators); hostile-document fuzz coverage | Deferred #391 items stay deferred per ADR-0037 |
163167
| 2.6 | Export full scope sets (fixes survey defect 2) and adopt json/v2 strict ingest with documented rejection behavior, and pin the v1 plugin wire's lenient decode with SDK wire fixtures so the migration cannot tighten it by accident | ADR-0039 |
@@ -201,8 +205,8 @@ the golden refresh happens **once**:
201205
`packageurl-go`/`go-spdx` imports outside the kits, zero PURL string
202206
concatenation outside `purlkit` — each enforced by a guard test, not a
203207
review habit.
204-
4. One identity authority: every node ID and content address in the pipeline
205-
is produced by an SDK entry point, and `left-pad@1.0.0` from two
208+
4. One identity authority: every node ID in the pipeline is produced by an
209+
SDK entry point on the typed node union, and `left-pad@1.0.0` from two
206210
ecosystems are two nodes in one merged graph, proven by test.
207211
5. Both modules on Go 1.27; untrusted SBOM ingest rejects documents with
208212
duplicate object names or invalid UTF-8, each with an actionable error.

dev-docs/adr/0041-identity-is-the-canonical-purl-on-typed-nodes.md

Lines changed: 114 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -23,58 +23,116 @@ inherited.
2323

2424
The problems ADR-0036 named are still real: `StableID()` collides across
2525
ecosystems, occurrence identity was minted from hashed raw evidence at a
26-
dozen call sites, and three rewrite sites disagreed. The mechanism changes;
27-
the problem statement stands.
26+
dozen call sites, and three rewrite sites disagreed. A second, older
27+
problem joins them: one untyped `Dependency` struct plays every role — the
28+
project's own root, a workspace module, a manifest file, a resolved
29+
third-party package — distinguished only by convention (a type string, a
30+
boolean), so nothing stops a structural record from masquerading as a
31+
package or a first-party fold from being decided by insertion order. The
32+
mechanism changes; the problem statements stand.
2833

2934
## Decision
3035

31-
**Package identity is the canonical package URL.** The PURL is the industry
32-
standard and `purlkit` (backed by `package-url/packageurl-go`) is its one
33-
home; identity introduces no second grammar beside it. A package node's
34-
identity is valid only when a canonical PURL with at least a type and a
35-
name is derivable; a node that cannot produce one is rejected at the
36-
validation gate. A missing version is accepted with a recorded warning
37-
rather than rejected — first-party roots and some imported SBOM components
38-
legitimately lack one — so the strict triple (type, name, version) is the
39-
norm and its absence is visible, not fatal.
40-
41-
**Graph nodes are a typed union.** The graph holds a sealed `GraphNode`
42-
interface with two concrete types: a package node, which must carry a valid
43-
canonical PURL and is the unit of matching and enrichment, and a manifest
44-
node, which is a structural record identified by its path, is never
45-
matched or enriched, and cannot masquerade as a package. The constructor is
46-
the gate: building a package node from coordinates that cannot mint a valid
47-
PURL is a compile-visible error path, not a silently empty ID. This is a
48-
breaking in-process API change, allowed under the module's v0 policy; the
49-
wire stays inside `bomly.plugin.v1` — nodes keep their flat JSON shape,
50-
gaining one additive `omitempty` kind discriminator, and a legacy payload
51-
without it infers the kind from its package-type field. (A generic
36+
**Graph nodes are a sealed typed union of three kinds.** The graph holds a
37+
sealed `GraphNode` interface with three concrete types, named for what the
38+
CLI actually models (a "package" in Bomly vocabulary is the registry
39+
artifact produced by matching, so graph records deliberately avoid that
40+
word):
41+
42+
- a **manifest node** — a structural file record (a `package.json`, a
43+
lockfile, a build script), identified by its path, never matched or
44+
enriched;
45+
- a **module node** — one of the scanned project's own artifacts: the root
46+
project itself and every workspace or reactor module. First-party
47+
ownership stops being a boolean convention and becomes the node's kind,
48+
so it cannot be dropped by a fold or asserted by an imported document;
49+
- a **dependency node** — one resolved third-party occurrence, the unit of
50+
matching and enrichment.
51+
52+
The two shapes the CLI deals with today are both plain edge patterns over
53+
these kinds, with no fourth kind needed: a single-project scan is
54+
`manifest → module (the root) → dependency…`, and a workspace is
55+
`manifest → module → child manifest → child module → … → dependency…`,
56+
nesting as deep as the build does. Constructors are the gate: building a
57+
dependency node from coordinates that cannot mint a valid PURL is a
58+
compile-visible error path, not a silently empty ID. This is a breaking
59+
in-process API change, allowed under the module's v0 policy. (A generic
5260
`Graph[N]` container was considered and rejected: detection graphs are
53-
heterogeneous — a manifest root beside its package children — and Go's
54-
generics are homogeneous with no sum types.)
55-
56-
**The occurrence qualifier is the normalized origin.** No new storage, no
57-
facet renderings, no hashes: the occurrence half of identity is the
58-
ADR-0033-normalized `Origin` already on the node — exactly the view the
59-
JSON codec persists, so identity comparison is wire-stable by construction.
60-
61-
**Comparison is equals and key, never a published artifact.** The SDK
62-
exposes the Java-style pair: an identity-equality predicate (canonical
63-
PURLs equal and normalized origins equal) and an identity key for map
64-
grouping. Consolidation folds records that compare equal and keeps records
65-
that do not. The key is an in-process comparison value; it never appears in
61+
heterogeneous — a manifest beside its module beside its dependencies — and
62+
Go's generics are homogeneous with no sum types.)
63+
64+
**Dependency identity is the canonical package URL, valid by the PURL
65+
specification's own rules.** The PURL is the industry standard and
66+
`purlkit` (backed by `package-url/packageurl-go`) is its one home; identity
67+
introduces no second grammar and no Bomly-invented validity rule. A
68+
dependency node is valid exactly when the library accepts its PURL under
69+
the specification: scheme, type, and name at minimum, plus each type's own
70+
requirements (Maven's group ID as the namespace, for example) — the
71+
library, not Bomly, decides. The one Bomly policy layered on top is about
72+
versions, which the specification leaves optional: a dependency node
73+
without a version is accepted with a recorded warning rather than
74+
rejected, because first-party-adjacent records and some imported SBOM
75+
components legitimately lack one, and their absence should be visible, not
76+
fatal. Module nodes derive a PURL when their coordinates allow one, under
77+
the same warning policy; a module that cannot mint a PURL falls back to
78+
path-based identity like a manifest, because it is the project's own
79+
record, not a registry lookup key.
80+
81+
**The occurrence qualifier is the normalized origin, and comparison
82+
preserves the gap-fill rule.** The occurrence half of identity is the
83+
ADR-0033-normalized `Origin` already on the node — no new storage, no
84+
facet renderings, no hashes — which makes comparison wire-stable by
85+
construction, since it uses exactly the view the JSON codec persists. The
86+
SDK exposes the Java-style pair — an identity-equality predicate and an
87+
identity key for map grouping — with a three-way origin relation rather
88+
than naive equality, so ADR-0033's folding semantics survive:
89+
90+
- both records carry normalized origins and they are equal → the same
91+
occurrence; witnesses fold;
92+
- exactly one record carries an origin → a gap; the gap witness folds into
93+
the origin-bearing record (ADR-0033's fill rule, unchanged);
94+
- both carry origins and they differ → distinct occurrences that coexist.
95+
96+
Comparison is kind-scoped: nodes of different kinds are never equal, so a
97+
module node and a dependency node sharing a PURL — or sharing the absence
98+
of an origin — can never fold into each other, whatever the insertion
99+
order. Module nodes compare by canonical PURL when both carry one and by
100+
(declaring manifest path, name) otherwise; manifest nodes compare by path.
101+
Keys are in-process comparison values, kind-prefixed, and never appear in
66102
scan JSON, SBOMs, or any published document.
67103

68-
**Readable IDs need no grammar of their own.** A package node's graph ID is
69-
its canonical PURL. When contradicting occurrences of one PURL must coexist,
70-
they are distinguished by a deterministic run-local ordinal suffix — a
71-
single space and `o` plus a decimal, assigned per distinct identity key in
72-
sorted order, never from arrival order and never from a hash of evidence.
73-
Raw resolution evidence (the verbatim `ResolvedURL`, tokenized URLs) never
74-
reaches an ID. Manifest nodes keep their path-based IDs. The insertion
75-
entry point still parks contradicting records under an ephemeral in-process
76-
discriminator (NUL-marked, structurally disjoint from every readable ID)
77-
until consolidation finalizes them; the ephemeral form never reaches output.
104+
**Readable IDs need no grammar of their own.** A dependency node's graph ID
105+
is its canonical PURL in identity form: qualifiers pass only through
106+
purlkit's identity-qualifier allowlist — empty today, so all are dropped —
107+
and the subpath is preserved. That gate exists because imported PURLs can
108+
carry resolution evidence and credentials in qualifiers
109+
(`repository_url=…`, signed download links), and node IDs are published as
110+
SBOM component identifiers; admitting the first allowlist key is a
111+
reviewed act that ships the ADR-0033 credential gates with it. When
112+
distinct occurrences of one canonical PURL must coexist, every one of them
113+
carries a deterministic run-local ordinal suffix — a single space and `o`
114+
plus a decimal. Numbering starts at 1, is scoped per canonical PURL, and
115+
is assigned in the sorted order of the occurrences' identity keys — never
116+
arrival order, never a hash of evidence; golden tests pin the assignment
117+
when the implementation lands. Raw resolution evidence (the verbatim
118+
`ResolvedURL`, tokenized URLs) never reaches an ID. Module and manifest
119+
nodes use their path-derived identities. The insertion entry point still
120+
parks contradicting records under an ephemeral in-process discriminator
121+
(NUL-marked, structurally disjoint from every readable ID) until
122+
consolidation finalizes them; the ephemeral form never reaches output.
123+
124+
**The wire stays inside `bomly.plugin.v1`, with a specified discriminator.**
125+
Nodes keep their flat JSON shape and gain one additive `omitempty` `kind`
126+
field with exactly three values: `manifest`, `module`, and `dependency`.
127+
An explicit `kind` is authoritative and wins when it disagrees with the
128+
legacy package-type field; a payload without one — every pre-union binary
129+
— infers its kind deterministically: package-type manifest → manifest,
130+
first-party or package-type application → module, everything else →
131+
dependency. An unrecognized `kind` value is a decode error, not a guess: a
132+
v1 payload can only carry v1 kinds, and a future kind means a v2
133+
negotiation, per the additive-forever rule. Frozen wire fixtures pin all
134+
four cases — explicit, legacy-inferred, conflicting, and unknown — so
135+
independent implementations cannot classify one payload two ways.
78136

79137
**There is no content address.** Encode-then-hash identity is dropped
80138
entirely. If cloud persistence ever materializes, an address can be derived
@@ -91,10 +149,15 @@ not lost.
91149
- The typed node union is a one-time in-process break that every consumer
92150
absorbs at its own pin bump: the CLI in its phase-2 adoption train, the
93151
component repositories in the deferred plugin round. Matching and
94-
enrichment become simpler, iterating package nodes only.
152+
enrichment become simpler, iterating dependency nodes only, and
153+
first-party suppression stops being a flag check.
95154
- The review-hardened invariants from the closed first round survive as
96-
behavior and tests, not machinery: first-party ownership survives folds,
97-
ordinals are never recycled, identity comparison is stable across the
98-
plugin wire, and discriminator vocabularies fold before derivation.
155+
behavior and tests, not machinery: ownership survives folds (now by
156+
construction — kinds never cross-fold, and the cross-entry merge helper
157+
must preserve module identity the same way), ordinals are never
158+
recycled, identity comparison is stable across the plugin wire, raw
159+
evidence is never provenance for a suffix, and discriminator
160+
vocabularies fold before derivation.
99161
- ADR-0036 is superseded; the identity phase of `SDK_MATURITY_PLAN.md`
100-
(item 1.3) is rescoped to this decision.
162+
(item 1.3) and every other identity reference in that plan are rescoped
163+
to this decision.

0 commit comments

Comments
 (0)