Skip to content

Commit bc6fc8c

Browse files
bomly-guyclaude
andcommitted
docs: address review — normative parameters for identity, scopes, references, projections
ADR-0036: fallback tuples enter identity only post-normalization with idempotence as the guarded invariant; ordinal ordering is stated (lexicographic by contradiction key, ties by manifest path then location); readable-ID parameters fixed (space delimiter, 6-byte sha256 hex suffix, o-decimal ordinals, percent-escaping, last-unescaped-space decode); the address encoding is byte-fixed (UTF-8, 4-byte big-endian lengths, tag/identity/occurrence order, zero-length absents, 16-byte truncation) with golden vectors as a 1.3 deliverable; addresses are post-consolidation only; silent shortening is prohibited. ADR-0037: ExternalReference gains Category to round-trip the SPDX triple, hashes ride the bomly: comment channel in SPDX; complete scope-carrier rules (sorted deduped list, carrier-beats-scalar, unknown-drop-with-warning, scalar ingest mapping); the usage join key is (module root, declaration site) with no cross-site mixing; the merged-document projection is defined per format (SPDX DESCRIBES-per-root + externalDocumentRef with checksum, CycloneDX BOM-Link urn:cdx:serial/version) with checksums captured for every ingested document. ADR-0039: the strict-ingest guarantee is narrowed to exactly duplicate names and invalid UTF-8. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a42eb8d commit bc6fc8c

4 files changed

Lines changed: 86 additions & 31 deletions

dev-docs/SDK_MATURITY_PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ Breaking in-process changes are allowed (v0 policy); the wire stays additive
140140
|---|---|---|
141141
| 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 |
142142
| 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-0036): facet definition, versioned content address, ecosystem-qualified `StableID` successor, `NewDependency` derivation; wire-compat fixtures extended | v0.6.0 |
143+
| 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 |
144144
| 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 — a conjunctive filter such as reachable ∧ runtime ∧ direct then joins evidence to locations within one module root, selecting one usage); per-`GraphEntry` document assertions; per-field-class merge helpers; boundary validation codecs + fuzz targets for every new parser | v0.7.0 |
145145
| 1.5 | Metadata policy: document reserved `bomly.` prefix; deprecate `MetadataKeyDetectionLicenses` in favor of the typed license field | v0.7.0 |
146146

@@ -159,7 +159,7 @@ the golden refresh happens **once**:
159159
| 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 |
160160
| 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 |
161161
| 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 |
162-
| 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); hostile-document fuzz coverage | Deferred #391 items stay deferred per ADR-0037 |
162+
| 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 |
163163
| 2.6 | Export full scope sets (fixes survey defect 2) and adopt json/v2 strict ingest with documented rejection behavior | ADR-0039 |
164164
| 2.7 | Registry-lookup and PURL-fallback helper consolidation across output/render/tui/mcp presentation layers | Survey §3 items 5–6 |
165165
| 2.8 | Usage-attribution adoption: native detectors record per-site scopes and relationship on locations; the four analyzer repos emit per-module-root reachability evidence (behavioral bumps, not mechanical); CLI filtering, rendering, and MCP join conjunctive filters through attribution; regression test for the workspace case where a package is direct-in-dev in one module and transitive-at-runtime in another | Without producer and consumer migration the 1.4 fields stay empty and the conjunction cannot work |

dev-docs/adr/0036-dependency-identity-is-content-addressable.md

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@ identity-bearing keys, and URL-valued qualifiers pass the same
4848
credential/local-path gates as every published URL (ADR-0033), because an
4949
ingested PURL whose qualifier embeds a token must not become a published ID
5050
by canonicalization alone. When no PURL is derivable, package identity is
51-
the coordinate tuple `ecosystem, package manager, type, org, name, version`. The occurrence qualifier distinguishes contradicting
51+
the coordinate tuple `ecosystem, package manager, type, org, name, version`
52+
taken only after `NormalizeDependencyIdentity` has run. The normalization
53+
pass owns the per-ecosystem case, separator, and format rules; identity adds
54+
no folding of its own beyond trimming surrounding whitespace, unnormalized
55+
records never reach identity derivation, and normalization idempotence is
56+
the stated invariant, guarded by test. The occurrence qualifier distinguishes contradicting
5257
resolutions of the same package per ADR-0033, but with a stricter admission
5358
rule than consolidation's current resolution key: only normalized,
5459
machine-independent, credential-free values enter the facet — the first-party
@@ -74,8 +79,11 @@ varies across machines and credential rotations for the same dependency, and
7479
hashing does not protect a low-entropy secret from offline guessing. A node
7580
whose resolution is distinguishable only by raw evidence still gets a
7681
distinct readable ID within the run, but its discriminator is ephemeral and
77-
content-free — a per-identity ordinal assigned deterministically during
78-
consolidation, never a hash of the evidence — because readable IDs are
82+
content-free — a per-identity ordinal assigned during consolidation in a
83+
stated order (the contradicting records sorted lexicographically by the
84+
resolution key that established the contradiction, ties broken by manifest
85+
path then location; never arrival or map-iteration order), never a hash of
86+
the evidence — because readable IDs are
7987
published in scan JSON and SBOMs, and a hash of a machine-specific path or
8088
low-entropy credential would carry the same instability and offline-guessing
8189
exposure into the published document. The persistent content address is
@@ -125,18 +133,34 @@ unescaped fallback base like `a@b 1` would be indistinguishable from base
125133
`a@b` plus suffix `1`. With both families escaping the delimiter, the
126134
suffix split is unambiguous by structure for every readable ID, and the
127135
delimiter change rides the same one-time ID change as the rest of this
128-
decision. What changes is who computes it: `NewDependency`
136+
decision. The decision-level parameters: the delimiter is a single ASCII
137+
space; the hash suffix is the first six bytes of the SHA-256 of the
138+
admitted occurrence facet, lowercase hex; the ordinal form is `o` followed
139+
by a decimal; fallback fields percent-encode space, percent, and control
140+
characters before joining; decoding splits on the last unescaped space
141+
before parsing the base. The full normative grammar, with examples covering
142+
subpaths, delimiter characters, and percent signs, ships as an SDK spec
143+
plus golden tests in the identity phase — the ADR fixes the parameters, the
144+
spec fixes every byte. What changes is who computes it: `NewDependency`
129145
and one SDK rewrite entry point derive it; detectors and the CLI stop minting
130146
IDs by string concatenation, and the three divergent rewrite sites collapse
131147
into one.
132148

133149
**The content address.** The SDK additionally exposes a content address for
134150
each node: a SHA-256 digest over a versioned canonical encoding of the
135-
facets — the `bomly:node:v1` tag followed by each facet as a length-prefixed
136-
field, so the encoding stays injective even when untrusted input contains
137-
delimiter bytes (a NUL-joined tuple would let `("a\x00b", "c")` and
138-
`("a", "b\x00c")` collide) — truncated to 128 bits and hex-encoded. The
139-
version prefix means the facet set can evolve
151+
facets, and the encoding is fixed at the byte level: facets are UTF-8
152+
strings, each preceded by a four-byte big-endian length; the field order is
153+
the `bomly:node:v1` tag, the package identity, the occurrence facet; an
154+
absent facet is a zero-length field, still length-prefixed. Length prefixes
155+
keep the encoding injective even when untrusted input contains delimiter
156+
bytes (a NUL-joined tuple would let `("a\x00b", "c")` and `("a", "b\x00c")`
157+
collide). The digest is SHA-256 truncated to its first 16 bytes, rendered
158+
lowercase hex; golden test vectors covering representative and edge-case
159+
facet sets ship with the SDK implementation so independent implementations
160+
must agree. The address is defined only over finalized facets: computing it
161+
is a post-consolidation operation, the SDK exposes it on consolidated
162+
records, and anything that caches identity earlier must rekey after
163+
finalization. The version prefix means the facet set can evolve
140164
by bumping to `v2` without silently changing every stored address. The digest
141165
is deliberately derived, not stored as model state — it can always be
142166
recomputed from the facets, so persisting it is an optimization, never a
@@ -165,8 +189,10 @@ of `Dependency` is unchanged.
165189
address, so an address-keyed store must pair it with a store-local
166190
occurrence discriminator or persist those nodes at package granularity;
167191
the SDK documents the address as identifying the stable occurrence class,
168-
never as a per-node primary key. Because it is versioned and truncatable,
169-
the storage layer may shorten or re-derive it without a model change.
192+
never as a per-node primary key. The full 128-bit address is the
193+
canonical form everywhere: a store may re-derive it from the facets, but
194+
never silently shorten it — a shortened rendering is presentation-only
195+
and is never a comparison or storage key.
170196
- Two hashing choices are deliberately conservative: SHA-256 (already the
171197
digest of record in `filecache` and `OccurrenceID`), and no use of
172198
`hash/maphash` (its seeds are per-process, which is exactly what a

dev-docs/adr/0037-sbom-assertions-are-typed-sdk-model-fields.md

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,14 @@ codecs are projections of it, not extensions to it.
3737
**Component-level fields.** `Dependency` and `Package` (or `Coordinates`
3838
where identity-adjacent) gain optional `omitempty` fields for supplier,
3939
originator, description, homepage, and a typed
40-
`ExternalReference{Type, Locator, Comment, Hashes}` list whose type
41-
vocabulary covers both formats' reference categories. The locator is typed
40+
`ExternalReference{Category, Type, Locator, Comment, Hashes}` list whose
41+
vocabulary covers both formats' reference categories — `Category` preserves
42+
SPDX's `referenceCategory` alongside the type, so the SPDX triple
43+
(category, type, locator) round-trips without re-derivation. Reference
44+
hashes round-trip natively in CycloneDX; SPDX 2.3 has no slot for them, so
45+
on SPDX export they ride the existing `bomly:` comment channel, and codec
46+
fixtures cover every supported category and reference hashes in both
47+
directions. The locator is typed
4248
by the reference category, not assumed to be a web URL: both formats carry
4349
non-URL locators — Bomly itself emits `pkg:` PURLs and `cpe:` values as
4450
SPDX external references today, and advisory references may be bare
@@ -55,11 +61,16 @@ exactly one place and is exercised in both directions — and it is defined
5561
for the scope *set*, not just a scalar. Both native fields hold one value
5662
(CycloneDX `scope` is a scalar, and the neutral component model mirrors it),
5763
so the SDK also defines the scalar projection rule for a multi-scope node
58-
(runtime presence wins) and the lossless side channel that carries the full
59-
set — a namespaced `bomly:scopes` CycloneDX property and the existing
60-
`bomly:scope` SPDX comment field, both read back on ingest — so the scope
61-
union PR #406 established survives the round trip instead of being
62-
flattened.
64+
and the lossless side channel that carries the full set — a namespaced
65+
`bomly:scopes` CycloneDX property and the existing `bomly:scope` SPDX
66+
comment field, both read back on ingest — so the scope union PR #406
67+
established survives the round trip instead of being flattened. The rules
68+
are complete, not illustrative: the scalar projection is any-runtime →
69+
`required`, otherwise `excluded`; the carrier is the lexicographically
70+
sorted, deduplicated, comma-joined list of SDK scope tokens; on ingest the
71+
carrier wins over the scalar when both are present, unknown tokens are
72+
dropped with a warning, and a bare scalar maps `required` → runtime and
73+
`optional`/`excluded` → development.
6374

6475
**Usage facts carry their attribution.** Scope, relationship, and
6576
reachability answer questions about a *usage*, and filters compose them
@@ -78,10 +89,15 @@ summary of that list. A package present in several module roots is analyzed
7889
per root, so a singular annotation with one root field could retain at most
7990
one analysis after vulnerability consolidation; the evidence list keeps
8091
every record. The conjunctive join is evidence-to-location within the same
81-
module root — never the summary joined to every location. A conjunctive
82-
filter then selects usages, and the display shows the dependency path whose
83-
attribution satisfies every conjunct — instead of a node that satisfies
84-
each conjunct somewhere.
92+
module root — never the summary joined to every location. The join key is
93+
explicit: the usage unit is (module root, declaration site). Scope and
94+
relationship attach per site and are never combined across sites; a
95+
conjunctive filter must find its scope and relationship conjuncts on a
96+
single site, and its reachability on that site's module root — legitimate,
97+
because reachability is a fact about the module's own code and so covers
98+
every site within its root. A conjunctive filter then selects usages, and
99+
the display shows the dependency path whose attribution satisfies every
100+
conjunct — instead of a node that satisfies each conjunct somewhere.
85101

86102
**Typed edges.** `DependencyEdge` gains an optional relationship kind
87103
(default depends-on; contains, describes, and the other members both formats
@@ -114,10 +130,20 @@ and one primary component, so a merged document *links* each source's
114130
identity rather than re-asserting it — SPDX `externalDocumentRefs`,
115131
CycloneDX an external reference of type `bom` carrying the source serial —
116132
while component-level assertions are preserved in full. The carrier retains
117-
what those link forms require, captured at ingest while the original bytes
118-
are still in hand: for SPDX, the source document namespace and a checksum
119-
over the original document (an `externalDocumentRef` is invalid without
120-
both); for CycloneDX, the source serial and version. The fixed-point
133+
what the link forms require, captured at ingest while the original bytes
134+
are still in hand — namespace or serial, document version, and a checksum
135+
over the original bytes, for every ingested document regardless of its
136+
format. The projection is then defined per output format. SPDX has no
137+
primary-component field, so the aggregate document `DESCRIBES` each root
138+
(the existing ADR-0032 mechanism, one relationship per root) and links each
139+
source via `externalDocumentRef` — the source's namespace, or its serial
140+
URN when the source was CycloneDX, plus the ingest-computed checksum, since
141+
the ref is invalid without both. CycloneDX links each source via an
142+
external reference of type `bom`: a BOM-Link `urn:cdx:<serial>/<version>`
143+
when the source has a serial (version captured at ingest, defaulting to 1),
144+
or the source namespace URI when it does not. Merged fixtures for both
145+
formats are validated through the codecs and the official format validators
146+
as part of the adoption phase. The fixed-point
121147
round-trip promise is correspondingly scoped to single-source flows: one
122148
ingested document re-exported reproduces its own assertions; a merged
123149
export preserves component assertions and references its sources.

dev-docs/adr/0039-both-modules-build-on-go-1-27.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,12 @@ the CLI moves with it.
3737
**Strict parsing for untrusted documents.** Parsers of untrusted input —
3838
SBOM ingest first, baseline and plugin-manifest codecs as they are touched —
3939
move to `encoding/json/v2` with duplicate-name rejection and UTF-8 validity
40-
enforced. A document that two readers could disagree about is rejected with
41-
an actionable error, and the change is documented as deliberate behavior in
42-
`docs/SBOM.md`. The plugin wire (`bomly.plugin.v1`) keeps its current
40+
enforced. The guarantee is exactly those two ambiguity classes — a document
41+
with duplicate object names or invalid UTF-8 is rejected with an actionable
42+
error. Other reader-divergence classes (case-variant field matching, for
43+
one) are not covered by json/v2's defaults and are not claimed here; closing
44+
any of them would be its own validation with its own decision. The change is
45+
documented as deliberate behavior in `docs/SBOM.md`. The plugin wire (`bomly.plugin.v1`) keeps its current
4346
decoding semantics: its compatibility contract is frozen fixtures, not
4447
strictness, and tightening it is a protocol decision that would need its own
4548
ADR.

0 commit comments

Comments
 (0)