Skip to content
Merged
10 changes: 6 additions & 4 deletions dev-docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ is one file with an ID, date, and status. To record a new decision, copy
the [index](adr/README.md). This document stays the architecture narrative;
it links to ADRs where a section's behavior comes from a recorded decision.

An active cross-repo program to mature the SDK model (content-addressable
identity, SBOM-complete typed fields, single-home PURL/SPDX behavior, Go
1.27) is planned in [`SDK_MATURITY_PLAN.md`](SDK_MATURITY_PLAN.md), backed by
ADR-0036 through ADR-0039. The standing placement rule the program restores —
An active cross-repo program to mature the SDK model (canonical-PURL
identity on a typed node union, SBOM-complete typed fields, single-home
PURL/SPDX behavior, Go 1.27) is planned in
[`SDK_MATURITY_PLAN.md`](SDK_MATURITY_PLAN.md), backed by ADR-0037 through
ADR-0039 and [ADR-0041](adr/0041-identity-is-the-canonical-purl-on-typed-nodes.md)
(which supersedes ADR-0036's content-addressable design). The standing placement rule the program restores —
model behavior lands in the SDK first; the CLI and plugins hold only what is
theirs by nature — is
[ADR-0040](adr/0040-the-sdk-is-the-default-home-for-behavior.md).
Expand Down
34 changes: 23 additions & 11 deletions dev-docs/SDK_MATURITY_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

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

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

One sentence per decision; the ADRs carry the detail.

- **Identity** (ADR-0036): identity facets defined once in the SDK — a
readable ID (canonical PURL + hashed occurrence suffix) and a derived
128-bit content address over a versioned facet encoding — minted only by
SDK entry points.
- **Identity** (ADR-0041): a sealed typed node union — manifest, module,
and dependency nodes — where a dependency node's identity is its
canonical PURL with qualifiers (valid by the PURL specification's own
rules, missing versions warned, URL-valued evidence qualifiers redirected
through the origin gates), origin is metadata (`Origins` list,
union-merged, never an identity term), comparison is the kind-scoped
equals/key pair, and node IDs are the identity itself — no suffixes, no
occurrence machinery, no content address.
- **Model** (ADR-0037): every preserved SBOM assertion is a typed,
`omitempty`, boundary-validated field with a declared merge class; typed
edges; a per-entry document-assertions carrier on `GraphEntry`; `Metadata`
Expand Down Expand Up @@ -140,10 +146,16 @@ Breaking in-process changes are allowed (v0 policy); the wire stays additive
|---|---|---|
| 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 |
| 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 |
| 1.3 | Identity (ADR-0036): facet definition, versioned content address, ecosystem-qualified `StableID` successor, `NewDependency` derivation; the normative ID and address spec with golden test vectors; wire-compat fixtures extended | v0.6.0 |
| 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, path-scoped; manifests structural, path-identified, never matched); origin becomes metadata (`Origins` list, union-merged, never identity); kind-scoped equals/key comparison; node IDs are the canonical PURL / path identity itself — no suffixes or occurrence machinery; fold-by-identity insertion; wire flat-node shape plus additive `kind` discriminator and `origins` field with frozen explicit/legacy/conflicting/unknown fixtures; no content address | v0.6.0 |
| 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 |
| 1.5 | Metadata policy: document reserved `bomly.` prefix; deprecate `MetadataKeyDetectionLicenses` in favor of the typed license field | v0.7.0 |

The scorecard matcher's repository resolution reads the URL-valued PURL
qualifiers that ADR-0041 relocates into origins; its bump at identity
adoption is therefore behavioral, not mechanical — it must consume the
projected origin signal from the match request (or an equivalent vetted
repository field) before the qualifiers disappear from package PURLs.

Each SDK release is followed immediately by Dependabot-or-manual pin bumps in
the nine `bomly-plugin-*` repos (matcherkit and licence-writing matchers are
the ones that materially change at 1.2).
Expand All @@ -157,7 +169,7 @@ the golden refresh happens **once**:
|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
Expand Down Expand Up @@ -201,8 +213,8 @@ the golden refresh happens **once**:
`packageurl-go`/`go-spdx` imports outside the kits, zero PURL string
concatenation outside `purlkit` — each enforced by a guard test, not a
review habit.
4. One identity authority: every node ID and content address in the pipeline
is produced by an SDK entry point, and `left-pad@1.0.0` from two
4. One identity authority: every node ID in the pipeline is produced by an
SDK entry point on the typed node union, and `left-pad@1.0.0` from two
ecosystems are two nodes in one merged graph, proven by test.
5. Both modules on Go 1.27; untrusted SBOM ingest rejects documents with
duplicate object names or invalid UTF-8, each with an actionable error.
Expand Down
2 changes: 2 additions & 0 deletions dev-docs/adr/0033-package-origin-is-detector-asserted.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Origin is therefore asserted where the meaning is known. Each detector reads its

One rule governs every published value, `sdk.NormalizeOriginURL`: absolute `http`/`https`, non-empty host, non-empty path, a usable port, no userinfo, host case and default ports canonicalized, output re-serialized from the parse rather than copied from input. The repository form additionally strips query and fragment (they carry the *requested* ref; the *resolved* one arrives separately from the detector's own field) and requires a non-empty path, because SPDX's `git+<url>@<revision>` grammar has no query component and an empty path would make the `@` suffix re-parse as userinfo. This one function replaces the entire classifier: no archive-extension table, no credential-prefix list, no secret-shape heuristic. Local paths, `file:`, and ssh-style remotes fail the scheme or host check rather than a bespoke rule, and a credentialed URL fails the userinfo check.

> **Amended 2026-08-29 by [ADR-0041](0041-identity-is-the-canonical-purl-on-typed-nodes.md):** the artifact form does not retain a query — `NormalizeOriginURL` rejects a query-carrying artifact URL outright, because a query on an artifact link marks a signed or tokenized download rather than a stable location. This paragraph's strip rule is repository-specific; the artifact rule is rejection, not stripping.

The invariant runs when a detector records a value, again at the JSON boundary in both directions, and again when export reads it through `Origin.Normalized()`. The second pass is not redundant: graphs also arrive from plugins and from hand-built callers, and export must not publish a location no built-in detector could have produced. Composition into a format's locator grammar stays in the encoders, so `Component.VCSURL` remains a plain URL and only SPDX builds the `git+…@…` form; CycloneDX external references have no revision slot, so a resolved commit is expressible in SPDX output and not in CycloneDX output. Neither survives a round trip: origin is written on export and never read back on ingest, because it describes what a manifest said and an ingested document is not one. `TestOriginIsNotReadBackFromAnIngestedDocument` pins that.

Origins are never merged, reconciled, or disputed. Two records of one package are either witnesses of one resolution or distinct occurrences, and the rule is uniform: **identical records fold, a gap fills from whichever record has an origin, and contradicting records stay distinct nodes — no tiebreak ever picks a winner over a contradiction.** One-node-per-PURL is the registry's constraint, not the graph's: many graph nodes may share a PURL, all linked to one registry package through `PackageRef`. Where a lockfile gives records positional identity (cargo's source-qualified package IDs, bun's keys), the detector keeps distinct nodes and `normalizeGraphPackageIdentity` preserves them through the canonical-PURL rewrite instead of collapsing them; across manifests, `preserveContradictingOccurrences` re-IDs a contradicting record before the SDK graph merge, so the merge folds only witnesses and fills only gaps. Where a lockfile references by bare name (uv, poetry, pipenv groups), one graph position exists and the deterministic first/last record wins as a whole — no field-level mixing; scope, relationship, and locations still union everywhere, being usage facts rather than assertions. Node insertion itself goes through one helper, `detectors.EnsureNode`, which deliberately merges nothing — records that differ deserve distinct nodes under distinct IDs, and records that are the same need nothing merged. `TestNodeInsertionGoesThroughTheSharedHelper` fails if a hand-written lookup-then-insert reappears; that guard found four sites nobody had reported when it was introduced. Generalizing positional identity beyond origin contradictions is #399.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ADR-0036: Dependency identity is content-addressable and SDK-derived

- **Date:** 2026-08-26
- **Status:** Accepted
- **Status:** Superseded by [ADR-0041](0041-identity-is-the-canonical-purl-on-typed-nodes.md)

## Context

Expand Down
Loading
Loading