@blamejs/blamejs-shop 0.1.31 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +4 -0
  3. package/lib/admin.js +18 -10
  4. package/lib/asset-manifest.json +17 -5
  5. package/lib/bundles.js +28 -0
  6. package/lib/checkout.js +48 -2
  7. package/lib/storefront.js +792 -45
  8. package/lib/vendor/MANIFEST.json +2 -2
  9. package/lib/vendor/blamejs/CHANGELOG.md +8 -0
  10. package/lib/vendor/blamejs/README.md +3 -1
  11. package/lib/vendor/blamejs/api-snapshot.json +99 -2
  12. package/lib/vendor/blamejs/index.js +3 -0
  13. package/lib/vendor/blamejs/lib/crypto-oprf.js +110 -0
  14. package/lib/vendor/blamejs/lib/network-tsig.js +404 -0
  15. package/lib/vendor/blamejs/lib/network.js +1 -0
  16. package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +44 -9
  17. package/lib/vendor/blamejs/lib/vendor/noble-curves.cjs +19 -0
  18. package/lib/vendor/blamejs/lib/worm.js +246 -0
  19. package/lib/vendor/blamejs/package.json +1 -1
  20. package/lib/vendor/blamejs/release-notes/v0.12.x.json +1844 -0
  21. package/lib/vendor/blamejs/release-notes/v0.13.0.json +22 -0
  22. package/lib/vendor/blamejs/release-notes/v0.13.1.json +18 -0
  23. package/lib/vendor/blamejs/scripts/vendor-update.sh +11 -1
  24. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +3 -1
  25. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-oprf.test.js +101 -0
  26. package/lib/vendor/blamejs/test/layer-0-primitives/network-tsig.test.js +149 -0
  27. package/lib/vendor/blamejs/test/layer-0-primitives/sandbox.test.js +2 -2
  28. package/lib/vendor/blamejs/test/layer-0-primitives/testing.test.js +3 -3
  29. package/lib/vendor/blamejs/test/layer-0-primitives/worm.test.js +126 -0
  30. package/package.json +2 -2
  31. package/lib/vendor/blamejs/release-notes/v0.12.0.json +0 -64
  32. package/lib/vendor/blamejs/release-notes/v0.12.1.json +0 -32
  33. package/lib/vendor/blamejs/release-notes/v0.12.10.json +0 -65
  34. package/lib/vendor/blamejs/release-notes/v0.12.11.json +0 -39
  35. package/lib/vendor/blamejs/release-notes/v0.12.12.json +0 -48
  36. package/lib/vendor/blamejs/release-notes/v0.12.13.json +0 -31
  37. package/lib/vendor/blamejs/release-notes/v0.12.14.json +0 -18
  38. package/lib/vendor/blamejs/release-notes/v0.12.15.json +0 -27
  39. package/lib/vendor/blamejs/release-notes/v0.12.16.json +0 -18
  40. package/lib/vendor/blamejs/release-notes/v0.12.17.json +0 -22
  41. package/lib/vendor/blamejs/release-notes/v0.12.18.json +0 -22
  42. package/lib/vendor/blamejs/release-notes/v0.12.19.json +0 -22
  43. package/lib/vendor/blamejs/release-notes/v0.12.2.json +0 -45
  44. package/lib/vendor/blamejs/release-notes/v0.12.20.json +0 -18
  45. package/lib/vendor/blamejs/release-notes/v0.12.21.json +0 -27
  46. package/lib/vendor/blamejs/release-notes/v0.12.22.json +0 -18
  47. package/lib/vendor/blamejs/release-notes/v0.12.23.json +0 -18
  48. package/lib/vendor/blamejs/release-notes/v0.12.24.json +0 -18
  49. package/lib/vendor/blamejs/release-notes/v0.12.25.json +0 -18
  50. package/lib/vendor/blamejs/release-notes/v0.12.26.json +0 -30
  51. package/lib/vendor/blamejs/release-notes/v0.12.27.json +0 -26
  52. package/lib/vendor/blamejs/release-notes/v0.12.28.json +0 -26
  53. package/lib/vendor/blamejs/release-notes/v0.12.29.json +0 -31
  54. package/lib/vendor/blamejs/release-notes/v0.12.3.json +0 -23
  55. package/lib/vendor/blamejs/release-notes/v0.12.30.json +0 -18
  56. package/lib/vendor/blamejs/release-notes/v0.12.31.json +0 -18
  57. package/lib/vendor/blamejs/release-notes/v0.12.32.json +0 -27
  58. package/lib/vendor/blamejs/release-notes/v0.12.33.json +0 -31
  59. package/lib/vendor/blamejs/release-notes/v0.12.34.json +0 -18
  60. package/lib/vendor/blamejs/release-notes/v0.12.35.json +0 -22
  61. package/lib/vendor/blamejs/release-notes/v0.12.36.json +0 -18
  62. package/lib/vendor/blamejs/release-notes/v0.12.37.json +0 -27
  63. package/lib/vendor/blamejs/release-notes/v0.12.38.json +0 -18
  64. package/lib/vendor/blamejs/release-notes/v0.12.39.json +0 -18
  65. package/lib/vendor/blamejs/release-notes/v0.12.4.json +0 -19
  66. package/lib/vendor/blamejs/release-notes/v0.12.40.json +0 -18
  67. package/lib/vendor/blamejs/release-notes/v0.12.41.json +0 -18
  68. package/lib/vendor/blamejs/release-notes/v0.12.42.json +0 -18
  69. package/lib/vendor/blamejs/release-notes/v0.12.43.json +0 -18
  70. package/lib/vendor/blamejs/release-notes/v0.12.44.json +0 -18
  71. package/lib/vendor/blamejs/release-notes/v0.12.45.json +0 -18
  72. package/lib/vendor/blamejs/release-notes/v0.12.46.json +0 -18
  73. package/lib/vendor/blamejs/release-notes/v0.12.47.json +0 -18
  74. package/lib/vendor/blamejs/release-notes/v0.12.48.json +0 -22
  75. package/lib/vendor/blamejs/release-notes/v0.12.49.json +0 -31
  76. package/lib/vendor/blamejs/release-notes/v0.12.5.json +0 -40
  77. package/lib/vendor/blamejs/release-notes/v0.12.50.json +0 -18
  78. package/lib/vendor/blamejs/release-notes/v0.12.51.json +0 -18
  79. package/lib/vendor/blamejs/release-notes/v0.12.52.json +0 -18
  80. package/lib/vendor/blamejs/release-notes/v0.12.53.json +0 -18
  81. package/lib/vendor/blamejs/release-notes/v0.12.54.json +0 -18
  82. package/lib/vendor/blamejs/release-notes/v0.12.55.json +0 -18
  83. package/lib/vendor/blamejs/release-notes/v0.12.56.json +0 -27
  84. package/lib/vendor/blamejs/release-notes/v0.12.57.json +0 -18
  85. package/lib/vendor/blamejs/release-notes/v0.12.58.json +0 -22
  86. package/lib/vendor/blamejs/release-notes/v0.12.6.json +0 -45
  87. package/lib/vendor/blamejs/release-notes/v0.12.60.json +0 -18
  88. package/lib/vendor/blamejs/release-notes/v0.12.61.json +0 -18
  89. package/lib/vendor/blamejs/release-notes/v0.12.62.json +0 -18
  90. package/lib/vendor/blamejs/release-notes/v0.12.63.json +0 -27
  91. package/lib/vendor/blamejs/release-notes/v0.12.64.json +0 -18
  92. package/lib/vendor/blamejs/release-notes/v0.12.65.json +0 -27
  93. package/lib/vendor/blamejs/release-notes/v0.12.66.json +0 -18
  94. package/lib/vendor/blamejs/release-notes/v0.12.68.json +0 -27
  95. package/lib/vendor/blamejs/release-notes/v0.12.69.json +0 -27
  96. package/lib/vendor/blamejs/release-notes/v0.12.7.json +0 -86
  97. package/lib/vendor/blamejs/release-notes/v0.12.8.json +0 -81
  98. package/lib/vendor/blamejs/release-notes/v0.12.9.json +0 -61
@@ -0,0 +1,1844 @@
1
+ {
2
+ "$schema": "../scripts/release-notes-consolidated-schema.json",
3
+ "minor": "0.12",
4
+ "releases": [
5
+ {
6
+ "version": "0.12.70",
7
+ "date": "2026-05-26",
8
+ "headline": "`b.network.dns.tsig` — RFC 8945 DNS transaction signatures",
9
+ "summary": "Sign and verify DNS messages with RFC 8945 TSIG — the shared-key HMAC that authenticates a DNS transaction (zone transfers, dynamic updates, query/response pairs) and proves it was not altered in flight. b.network.dns.tsig.sign(message, opts) appends a TSIG resource record and returns the signed wire; b.network.dns.tsig.verify(message, opts) locates the TSIG record, recomputes the HMAC over the RFC 8945 §4.3.3 digest, compares it in constant time, and checks the time window (valid only within `fudge` seconds of `timeSigned`). HMAC-SHA-256 is the default; SHA-384 / SHA-512 are available and the broken HMAC-MD5 / HMAC-SHA-1 algorithms are refused unless allowLegacy is set. Signing a response chains the request MAC into the digest. Verified byte-for-byte against dnspython 2.8.0 reference signatures. TSIG completes the DNS-trust set alongside the existing DNSSEC (zone-data authentication) and DANE primitives — DNSSEC authenticates the data end-to-end, TSIG authenticates a single hop's transaction with a pre-shared key.",
10
+ "sections": [
11
+ {
12
+ "heading": "Added",
13
+ "items": [
14
+ {
15
+ "title": "`b.network.dns.tsig.sign` / `b.network.dns.tsig.verify`",
16
+ "body": "RFC 8945 TSIG transaction authentication. `sign(message, { keyName, secret, algorithm, fudge, time, requestMac })` appends a TSIG RR to a DNS wire message and returns `{ wire, mac }`; `verify(message, { keys, now, requestMac })` returns `{ valid, keyName, algorithm, timeSigned, error, macValid, timeValid, reason }`, with a constant-time MAC compare (via `b.crypto.timingSafeEqual`), a `fudge`-second time-window check, truncated-MAC handling per §5.2.2.1, and request-MAC chaining for responses (§5.4.1). HMAC-SHA-256 default; HMAC-SHA-384 / SHA-512 supported; HMAC-MD5 / HMAC-SHA-1 refused unless `allowLegacy: true`. The transaction-level companion to `b.network.dns.dnssec` and `b.network.dns.dane`."
17
+ }
18
+ ]
19
+ }
20
+ ]
21
+ },
22
+ {
23
+ "version": "0.12.69",
24
+ "date": "2026-05-26",
25
+ "headline": "`b.middleware.botGuard` no longer blocks browsers that omit Sec-Fetch-Mode",
26
+ "summary": "b.middleware.botGuard treated a missing Sec-Fetch-Mode header as a bot signal and returned 403 Forbidden, which refused legitimate browsers on any origin where the browser does not emit Fetch Metadata: every plain-HTTP non-localhost origin (Umbrel apps, LAN and *.local reverse-proxy deployments) and Safari before 16.4 even over HTTPS. Browsers only send Sec-Fetch-* in a secure context, so its absence is normal there — not a bot. Sec-Fetch-Mode is now advisory only: it never blocks, and it sets req.suspectedBot in mode:\"tag\" only on a secure-context HTML GET where a modern browser would have sent it. Drive-by bots are still blocked by the missing-Accept-Language and User-Agent heuristics. No configuration change is needed; if you had widened skipPaths or disabled bot-guard to work around this, you can revert that.",
27
+ "sections": [
28
+ {
29
+ "heading": "Fixed",
30
+ "items": [
31
+ {
32
+ "title": "`b.middleware.botGuard` no longer 403s browsers over plain HTTP or older Safari",
33
+ "body": "A missing `Sec-Fetch-Mode` was a blocking heuristic, but browsers omit Fetch Metadata outside a secure context (every plain-HTTP non-localhost origin — Umbrel, LAN, `*.local` proxies) and Safari < 16.4 omits it even over HTTPS. Those legitimate browsers were refused with `403 Forbidden`. `Sec-Fetch-Mode` is now advisory: it never blocks, and only sets `req.suspectedBot` in `mode: \"tag\"` on a secure-context HTML GET. The `Accept-Language` and User-Agent heuristics (which catch the same bots) are unchanged."
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ "heading": "Detectors",
39
+ "items": [
40
+ {
41
+ "title": "reserved-hostname trailing-dot detector recognizes regex strips",
42
+ "body": "The codebase-patterns gate that requires stripping the RFC 1034 trailing root-zone dot before a reserved-hostname comparison now also recognizes end-anchored regex strips (`.replace(/\\.$/, …)`), not only the `charAt` / `while`-loop forms."
43
+ }
44
+ ]
45
+ }
46
+ ]
47
+ },
48
+ {
49
+ "version": "0.12.68",
50
+ "date": "2026-05-26",
51
+ "headline": "`b.jwk` — RFC 7638 JWK thumbprint",
52
+ "summary": "Compute the RFC 7638 thumbprint of a JSON Web Key — the canonical base64url(SHA-256(canonical-JSON)) identifier used to name a key (DPoP jkt bindings, ACME account-key thumbprints, DBSC session pins, kid derivation). b.jwk.thumbprint(jwk) returns the digest; b.jwk.canonicalize(jwk) returns the exact JSON that is hashed — only the key-type's required members, member names in lexicographic order, no whitespace, so the same key always yields the same thumbprint regardless of how its JWK was serialized. The standard key types are supported (EC, RSA, oct, OKP per RFC 8037) plus AKP, the IANA key type Node uses for ML-DSA / SLH-DSA post-quantum public keys; SHA-256 is the default, with hash: \"sha384\" | \"sha512\" for RFC 9278 thumbprint-with-hash. Verified against the RFC 7638 §3.1 worked example. b.auth.dpop, b.acme, and b.dbsc now compute their thumbprints through this primitive.",
53
+ "sections": [
54
+ {
55
+ "heading": "Added",
56
+ "items": [
57
+ {
58
+ "title": "`b.jwk.thumbprint` / `b.jwk.canonicalize`",
59
+ "body": "RFC 7638 JWK thumbprint. `thumbprint(jwk, opts)` returns `base64url(hash(canonical-JSON))` — only the key-type's required members feed the hash, so optional fields (`kid`, `use`, `alg`, …) never change the result. `canonicalize(jwk)` returns the canonical JSON string itself. Supports EC / RSA / oct / OKP and the AKP post-quantum key type; SHA-256 default, `hash` selects SHA-384 / SHA-512. Throws `JwkError` on an invalid key or unknown hash."
60
+ }
61
+ ]
62
+ },
63
+ {
64
+ "heading": "Changed",
65
+ "items": [
66
+ {
67
+ "title": "DPoP, ACME, and DBSC compose `b.jwk`",
68
+ "body": "`b.auth.dpop` (the `jkt` proof-key thumbprint), `b.acme` (the RFC 8555 account-key authorization), and `b.dbsc` (the session-pin thumbprint) now compute RFC 7638 thumbprints through `b.jwk` instead of carrying their own implementations. Behavior is unchanged — DPoP still refuses symmetric key types, and each surface keeps its own error codes."
69
+ }
70
+ ]
71
+ }
72
+ ]
73
+ },
74
+ {
75
+ "version": "0.12.66",
76
+ "date": "2026-05-26",
77
+ "headline": "`b.uriTemplate` — RFC 6570 URI Template expansion",
78
+ "summary": "Expand RFC 6570 URI Templates — the {var} syntax that OpenAPI links, HAL _links, and hypermedia API clients use to turn a template plus a set of variables into a concrete URI. The full Level 4 grammar is supported: every operator ({+var} reserved, {#var} fragment, {.var} label, {/var} path, {;var} path-style parameters, {?var} query, {&var} query continuation), the {var:3} prefix modifier, and the {var*} explode modifier for lists and associative arrays. b.uriTemplate.expand(template, vars) returns the expanded string; b.uriTemplate.compile(template) parses once for templates applied to many variable sets. A malformed template (unclosed expression, reserved operator, non-numeric prefix, unmatched brace) throws UriTemplateError. Verified against the official uritemplate-test conformance suite (all 135 spec, extended, and negative cases).",
79
+ "sections": [
80
+ {
81
+ "heading": "Added",
82
+ "items": [
83
+ {
84
+ "title": "`b.uriTemplate.expand` / `b.uriTemplate.compile`",
85
+ "body": "RFC 6570 URI Template expansion, full Level 4. `expand(template, vars)` substitutes variables into a template and returns the URI; `compile(template)` returns a reusable `{ expand }` for repeated use. Variable values may be strings, numbers, booleans, arrays (lists), or plain objects (associative arrays); undefined, null, and empty list/map variables are omitted. All eight operators, the `:N` prefix modifier, and the `*` explode modifier follow §3.2, including reserved-set encoding for `{+var}` / `{#var}`. Composes naturally with `b.hal`, `b.linkHeader`, and `b.openapi` link objects. A malformed template throws `UriTemplateError`."
86
+ }
87
+ ]
88
+ }
89
+ ]
90
+ },
91
+ {
92
+ "version": "0.12.65",
93
+ "date": "2026-05-26",
94
+ "headline": "`b.base32` — RFC 4648 Base32 encode / decode",
95
+ "summary": "Encode and decode RFC 4648 Base32 — the case-insensitive alphabet behind TOTP / 2FA secrets, DNSSEC NSEC3 hashes, and human-transcribable identifiers. Both RFC 4648 variants are supported: the standard alphabet (the default) and the extended-hex alphabet. b.base32.encode pads to an 8-character boundary by default (pass padding: false for the bare form TOTP key URIs use); b.base32.decode is strict by default but accepts the real-world shapes humans produce — lower-case, embedded spaces and dashes, missing padding — under loose: true. Verified against the RFC 4648 §10 test vectors for both alphabets. The TOTP primitive now composes this codec instead of carrying its own Base32 implementation.",
96
+ "sections": [
97
+ {
98
+ "heading": "Added",
99
+ "items": [
100
+ {
101
+ "title": "`b.base32.encode` / `b.base32.decode`",
102
+ "body": "RFC 4648 Base32 codec. `encode(buf, opts)` takes a Buffer or Uint8Array and returns a Base32 string, padded to an 8-character boundary unless `padding: false`; `decode(str, opts)` returns a Buffer. The `variant` option selects the standard (`\"rfc4648\"`, default) or extended-hex (`\"rfc4648-hex\"`) alphabet. Decoding is strict by default — any character outside the alphabet throws `Base32Error` — and `loose: true` up-cases the input and ignores embedded spaces, dashes, and missing padding, which is how copied TOTP keys and hand-typed codes arrive."
103
+ }
104
+ ]
105
+ },
106
+ {
107
+ "heading": "Changed",
108
+ "items": [
109
+ {
110
+ "title": "TOTP composes `b.base32`",
111
+ "body": "`b.auth.totp` now encodes and decodes its secrets through `b.base32` rather than a private Base32 implementation. Behavior is unchanged — secrets are still emitted unpadded and parsed leniently (case-insensitive, ignoring spaces and dashes)."
112
+ }
113
+ ]
114
+ }
115
+ ]
116
+ },
117
+ {
118
+ "version": "0.12.64",
119
+ "date": "2026-05-25",
120
+ "headline": "`b.jsonSchema` — JSON Schema 2020-12 validation",
121
+ "summary": "Validate JSON against a JSON Schema 2020-12 document — the dialect OpenAPI 3.1 adopted and the most widely implemented schema language. b.jsonSchema.compile(schema) returns a reusable validator; b.jsonSchema.validate(schema, instance) compiles and runs in one call, returning { valid, errors } where each error names the failing instance location, keyword, and schema path. The full 2020-12 vocabulary is supported: every applicator (allOf / anyOf / oneOf / not / if-then-else, properties / patternProperties / additionalProperties / prefixItems / items / contains), the annotation-aware unevaluatedProperties / unevaluatedItems, every assertion keyword, and reference resolution ($ref / $anchor / $dynamicRef / $dynamicAnchor / $defs / $id base URIs). format is an annotation by default (opt in to assertion with assertFormat). External references resolve through an operator-supplied schema map — never a network fetch. Verified against the official JSON-Schema-Test-Suite (1292 of 1295 draft2020-12 cases; the remainder need the bundled dialect metaschema or $vocabulary selection, both opt-in). This is the standards-track counterpart to the fluent b.safeSchema builder and the portable b.jtd.",
122
+ "sections": [
123
+ {
124
+ "heading": "Added",
125
+ "items": [
126
+ {
127
+ "title": "`b.jsonSchema` — JSON Schema 2020-12",
128
+ "body": "`compile(schema, opts)` returns `{ validate, isValid }`; `validate(schema, instance, opts)` and `isValid(schema, instance, opts)` compile and run in one call. `validate` returns `{ valid, errors }`, each error a `{ instancePath, keyword, schemaPath, message }`. The full 2020-12 vocabulary is implemented — applicators, annotation-aware `unevaluatedProperties` / `unevaluatedItems`, every assertion keyword, and `$ref` / `$anchor` / `$dynamicRef` / `$dynamicAnchor` / `$defs` / `$id` resolution. `format` is an annotation by default (`assertFormat: true` to assert). External references resolve through `opts.schemas` (a URI→schema map), never a network fetch. Reach for it when the schema is an existing JSON Schema document (an API contract, OpenAPI component, or config schema); `b.safeSchema` remains the fluent in-process builder and `b.jtd` the portable codegen-friendly option. Validating a schema document against the dialect metaschema requires supplying that metaschema via `opts.schemas`, and `$vocabulary`-based keyword selection is not honored (every standard keyword always asserts)."
129
+ }
130
+ ]
131
+ }
132
+ ]
133
+ },
134
+ {
135
+ "version": "0.12.63",
136
+ "date": "2026-05-25",
137
+ "headline": "`b.cloudEvents` gains the JSON event format, batch, and the HTTP binding",
138
+ "summary": "b.cloudEvents grows beyond wrap / parse into a full CloudEvents 1.0.2 surface. b.cloudEvents.validate / isValid check an envelope against the spec without throwing (the non-throwing companion to parse). toJSON / fromJSON serialize and parse the JSON event format, and toJSONBatch / fromJSONBatch handle the JSON batch format; untrusted bodies parse through the framework's bounded, prototype-pollution-safe JSON reader. The new http.* binding speaks both content modes the spec defines — binary mode spreads context attributes across percent-encoded ce-* headers with the data in the body, structured mode carries the whole event as application/cloudevents+json — plus the batch mode, and http.decode auto-detects the incoming mode from Content-Type exactly as a conformant receiver does. Verified against the spec's normative example events.",
139
+ "sections": [
140
+ {
141
+ "heading": "Added",
142
+ "items": [
143
+ {
144
+ "title": "`b.cloudEvents` JSON event format, batch, and HTTP binding",
145
+ "body": "`validate` / `isValid` report spec violations without throwing (the non-throwing companion to `parse`). `toJSON` / `fromJSON` and `toJSONBatch` / `fromJSONBatch` serialize and parse the JSON event and batch formats over the existing envelope shape. `http.encodeBinary` / `http.encodeStructured` / `http.encodeBatch` render the three HTTP content modes — binary spreads attributes across percent-encoded `ce-*` headers, structured and batched carry the event(s) as `application/cloudevents+json` / `application/cloudevents-batch+json` — and `http.decode` parses a request back into an envelope (or array) by auto-detecting the mode from `Content-Type`. `b.jtd` or `b.safeSchema` still validate the event's `data` payload."
146
+ }
147
+ ]
148
+ },
149
+ {
150
+ "heading": "Fixed",
151
+ "items": [
152
+ {
153
+ "title": "`b.csp.build` accepts `fenced-frame-src` and `webrtc`",
154
+ "body": "The CSP3 `fenced-frame-src` directive — which the default security-headers policy emits to block `<fencedframe>` embeds — was missing from the builder's recognized-directive set, so the default policy could not round-trip through `b.csp.build` (it threw `csp/unknown-directive`). Both `fenced-frame-src` and the CSP3 `webrtc` directive are now recognized."
155
+ }
156
+ ]
157
+ }
158
+ ]
159
+ },
160
+ {
161
+ "version": "0.12.62",
162
+ "date": "2026-05-26",
163
+ "headline": "`b.jtd` — JSON Type Definition validation (RFC 8927)",
164
+ "summary": "Validate JSON against a JSON Type Definition schema (RFC 8927) — a small, portable, cross-implementation schema language, the interop-friendly companion to the framework's fluent b.safeSchema builder. b.jtd.validate(schema, instance) returns an array of { instancePath, schemaPath } errors (empty = valid); b.jtd.isValid is the boolean form. All eight schema forms are supported — empty, type, enum, elements, properties (with optional / additional properties and nullable), values, discriminator (with mapping), and ref (with definitions) — including the integer-range and RFC 3339 timestamp types. A malformed schema is rejected at compile time with jtd/bad-schema rather than silently mis-validating. Verified against the official json-typedef-spec suites: all 316 validation cases and all 49 invalid-schema cases.",
165
+ "sections": [
166
+ {
167
+ "heading": "Added",
168
+ "items": [
169
+ {
170
+ "title": "`b.jtd.validate(schema, instance)` / `b.jtd.isValid(schema, instance)`",
171
+ "body": "`validate` returns the RFC 8927 error list — each `{ instancePath, schemaPath }` naming the offending value and the broken schema rule — and `isValid` is the boolean convenience form. Supports every JTD form and type: the numeric types enforce their exact ranges (int8 … uint32, float32 / float64), `timestamp` requires an RFC 3339 date-time, `properties` honours `optionalProperties` / `additionalProperties` / `nullable`, and `discriminator` selects a `mapping` schema by a tag property. The schema is checked for well-formedness before validation, so unknown keywords, multiple forms, bad refs, or a discriminator over a non-properties mapping all throw `jtd/bad-schema`. Use JTD for schemas you share across implementations or generate code from; use `b.safeSchema` for in-process fluent validation."
172
+ }
173
+ ]
174
+ }
175
+ ]
176
+ },
177
+ {
178
+ "version": "0.12.61",
179
+ "date": "2026-05-26",
180
+ "headline": "`b.jsonPath` — JSONPath query (RFC 9535)",
181
+ "summary": "A full RFC 9535 JSONPath query evaluator, complementing the framework's JSONPath guards (which screen path strings). b.jsonPath.query(doc, path) compiles a path and returns the matched node values; b.jsonPath.paths returns their normalized locations. The complete surface is implemented: name / wildcard / index / slice selectors, descendant segments (..), filter selectors (?) with comparison and logical operators, relative (@) and absolute ($) embedded queries, and the five standard functions length / count / match / search / value — with the spec's well-typedness rules enforced at compile time so a malformed or ill-typed query is rejected rather than silently mis-evaluated. Descendant walks are node-capped to bound work on hostile input. Verified against all 703 cases of the official jsonpath-compliance-test-suite.",
182
+ "sections": [
183
+ {
184
+ "heading": "Added",
185
+ "items": [
186
+ {
187
+ "title": "`b.jsonPath.query(doc, path)` / `b.jsonPath.paths(doc, path)`",
188
+ "body": "`query` returns the array of node values selected by an RFC 9535 JSONPath; `paths` returns the normalized-path string of each match (e.g. `$['a'][1]['p']`). Supports every selector (name, wildcard `*`, index incl. negative, slice `start:end:step` incl. negative step, comma-separated selections), child and descendant (`..`) segments, and filter expressions with `==` / `!=` / `<` / `<=` / `>` / `>=`, `&&` / `||` / `!`, existence tests, and the standard functions. The well-typedness rules are checked when the path is compiled — a non-singular query used as a comparison operand, an ill-typed function argument, or a value-typed function used as a test all throw `json-path/invalid`. Pairs with `b.guardJsonPath`, which screens operator-supplied path strings before they reach the evaluator."
189
+ }
190
+ ]
191
+ }
192
+ ]
193
+ },
194
+ {
195
+ "version": "0.12.60",
196
+ "date": "2026-05-25",
197
+ "headline": "`b.jsonMergePatch` — JSON Merge Patch (RFC 7396)",
198
+ "summary": "The simpler companion to JSON Patch: b.jsonMergePatch.merge applies an RFC 7396 merge patch (application/merge-patch+json), the partial-document PATCH body. A member present in the patch replaces or — for nested objects — merges into the target, a member whose value is null removes that key, and a patch that is itself an array, scalar, or null replaces the target wholesale. The inputs are never mutated (the merge runs on a deep copy) and member keys are written as literal own properties, so a \"__proto__\" member cannot reach any prototype. Verified against every RFC 7396 Appendix A test case.",
199
+ "sections": [
200
+ {
201
+ "heading": "Added",
202
+ "items": [
203
+ {
204
+ "title": "`b.jsonMergePatch.merge(target, patch)`",
205
+ "body": "Applies a JSON Merge Patch to a target document and returns the result without mutating either input. When the patch is an object it overlays the target (a null member deletes the key, a nested object merges recursively, any other value replaces); when the patch is an array, scalar, or null it replaces the whole target. Member keys are set via `Object.defineProperty`, so a `\"__proto__\"` member becomes a literal own key rather than altering a prototype. Pairs with `b.jsonPatch` — merge patch reads like the resource you want, JSON Patch expresses precise operations (array reordering, literal-null values)."
206
+ }
207
+ ]
208
+ }
209
+ ]
210
+ },
211
+ {
212
+ "version": "0.12.58",
213
+ "date": "2026-05-25",
214
+ "headline": "`b.jsonPointer` (RFC 6901) + `b.jsonPatch` (RFC 6902) — JSON Pointer + Patch",
215
+ "summary": "Two related JSON primitives. b.jsonPointer.get references a value within a JSON document by RFC 6901 path (/foo/0/bar), handling the ~1 / ~0 escapes and array indices, and refusing pointers that do not resolve. b.jsonPatch.apply applies an RFC 6902 patch — add / remove / replace / move / copy / test — the standard HTTP PATCH payload (application/json-patch+json). It is atomic: operations run against a deep copy, so a failure at any step (an out-of-range index, a missing source, or a failed test) throws and leaves the input document untouched, and test compares values structurally. Verified against the official json-patch/json-patch-tests conformance suite (every enabled result and error case) plus the RFC 6901 §5 pointer examples.",
216
+ "sections": [
217
+ {
218
+ "heading": "Added",
219
+ "items": [
220
+ {
221
+ "title": "`b.jsonPointer.get(doc, pointer)` / `b.jsonPointer.parse(pointer)`",
222
+ "body": "Resolve an RFC 6901 JSON Pointer against a document — walking object keys and array indices, decoding `~1` → `/` and `~0` → `~`, and returning the whole document for the empty pointer. Throws `json-pointer/not-found` for a missing key, an out-of-range or non-numeric (leading-zero) array index, or descent into a primitive, and `json-pointer/bad-pointer` for a non-`/`-prefixed pointer. `parse` exposes the decoded reference tokens."
223
+ },
224
+ {
225
+ "title": "`b.jsonPatch.apply(doc, operations)`",
226
+ "body": "Apply an RFC 6902 patch and return the result. Supports `add` (insert / append with `-` for arrays, set for objects, whole-document for `\"\"`), `remove`, `replace` (overwrite an existing location), `move`, `copy`, and `test` (structural equality). Atomic — the patch runs on a deep copy, so the input `doc` is never mutated and any failure (unknown op, missing `path` / `value` / `from`, bad index, failed `test`, or moving a location into its own child) throws a typed error. Paths are RFC 6901 pointers resolved through `b.jsonPointer`; suitable for HTTP PATCH endpoints."
227
+ }
228
+ ]
229
+ }
230
+ ]
231
+ },
232
+ {
233
+ "version": "0.12.57",
234
+ "date": "2026-05-25",
235
+ "headline": "`b.linkHeader` — RFC 8288 Web Linking (HTTP Link header) codec",
236
+ "summary": "Parse and build the HTTP Link header (RFC 8288) — the standard way to convey resource relations, most visibly REST pagination (Link: <…?page=2>; rel=\"next\"). b.linkHeader.parse returns one { uri, rel, params } per link, splitting multiple links on top-level commas and unwrapping quoted parameter values via the framework's RFC 8941 structured-field helpers, so a comma inside a quoted title never fake-splits the list; rel is exposed as its array of space-separated relation types. b.linkHeader.serialize is the inverse, angle-bracketing the URI, emitting rel first, and double-quoting parameter values. Verified against the RFC 8288 §3.5 examples and GitHub-style pagination links.",
237
+ "sections": [
238
+ {
239
+ "heading": "Added",
240
+ "items": [
241
+ {
242
+ "title": "`b.linkHeader.parse(headerValue)` / `b.linkHeader.serialize(links)`",
243
+ "body": "`parse` reads an HTTP Link header into `[{ uri, rel, params }]` — `uri` is the angle-bracketed target, `rel` the array of space-separated relation types, `params` the remaining parameters with quoted values unwrapped (a repeated parameter keeps the first occurrence per RFC 8288 §3.4). It refuses a link without a bracketed URI, an unterminated URI or quoted parameter, control bytes, and oversized headers. `serialize` builds the header value from `{ uri, rel, params? }` objects (or a single one), angle-bracketing the URI and double-quoting parameter values. Composes the framework's structured-field splitter so quoting is handled consistently; pair with `b.pagination` to emit standard `rel=\"next\"` / `rel=\"prev\"` navigation."
244
+ }
245
+ ]
246
+ }
247
+ ]
248
+ },
249
+ {
250
+ "version": "0.12.56",
251
+ "date": "2026-05-25",
252
+ "headline": "`b.canonicalJson` — RFC 8785 JSON Canonicalization Scheme, now a public primitive",
253
+ "summary": "The deterministic JSON serializer the framework uses internally for audit-chain and config-drift fingerprints is now an operator-facing primitive, for signing your own JSON (custom credentials, receipts, deterministic request signing). b.canonicalJson.stringifyJcs is strict RFC 8785: keys sorted in UTF-16 code-unit order at every depth, numbers in the ECMAScript format JCS references, and types JCS does not define (BigInt / Buffer / Date / Map / Set / circular references) refused rather than silently lost. b.canonicalJson.stringify is a lenient variant that also serializes Buffers (hex), Dates (ISO-8601), and BigInts. Exposing it surfaced and fixed a latent ordering bug: the serializer built a sorted-key object and let JSON.stringify emit it, but V8 hoists integer-like keys (\"1\", \"10\") to the front — so canonical output was wrong for objects with integer-like string keys. Members are now written in sorted order directly. Validated against the official cyberphone/json-canonicalization conformance vectors.",
254
+ "sections": [
255
+ {
256
+ "heading": "Added",
257
+ "items": [
258
+ {
259
+ "title": "`b.canonicalJson.stringifyJcs(value)` / `stringify(value, opts?)` / `sortKeys(obj)`",
260
+ "body": "`stringifyJcs` produces strict RFC 8785 canonical JSON — the byte-for-byte stable form to hash or sign — with UTF-16 code-unit key sorting and ECMAScript number formatting, refusing BigInt / Buffer / Date / Map / Set / RegExp / Symbol / function / circular references. `stringify` is the lenient framework variant (Buffers → hex, Dates → ISO-8601, BigInts → decimal; `opts.bufferAs: \"reject\"` to forbid binary). `sortKeys` returns an object's own keys in the canonical UTF-16 ordering. These were framework-internal; they are now documented public API."
261
+ }
262
+ ]
263
+ },
264
+ {
265
+ "heading": "Fixed",
266
+ "items": [
267
+ {
268
+ "title": "Canonical JSON now emits integer-like keys in sorted order",
269
+ "body": "The canonical serializer built a sorted-key object and serialized it with JSON.stringify, which hoists integer-like string keys (\"1\", \"10\") to the front per V8 own-property ordering — producing non-canonical output for objects containing such keys (a violation of RFC 8785 §3.2.3). Members are now written in sorted-key order directly. Real-world consumers (audit-chain, config-drift) use named fields and are unaffected; only objects with integer-like string keys change, and the new output is the correct canonical form."
270
+ }
271
+ ]
272
+ }
273
+ ]
274
+ },
275
+ {
276
+ "version": "0.12.55",
277
+ "date": "2026-05-25",
278
+ "headline": "`b.structuredFields` — RFC 9651 Date and Display String types",
279
+ "summary": "Brings the Structured Fields codec up to RFC 9651, which obsoletes RFC 8941 by adding two bare-item types. A Date (`@1659578233`) is an Integer number of seconds since the Unix epoch; a Display String (`%\"f%c3%bc%c3%bc\"`) is a Unicode string conveyed as percent-escaped UTF-8. parse returns them as distinct SfDate / SfDisplayString values, and serialize emits them canonically — a Date as `@` + integer, a Display String as `%\"`-wrapped lowercase-percent-escaped UTF-8 that escapes only what RFC 9651 requires. Parsing is strict: a Date rejects a decimal / out-of-range value, and a Display String rejects uppercase escapes, raw non-ASCII, bad hex, and invalid UTF-8. Validated against the official httpwg structured-field-tests date and display-string vectors.",
280
+ "sections": [
281
+ {
282
+ "heading": "Added",
283
+ "items": [
284
+ {
285
+ "title": "RFC 9651 Date (`@…`) and Display String (`%\"…\"`) in `b.structuredFields`",
286
+ "body": "`parse` now reads the two RFC 9651 types: `@` + an Integer yields an `SfDate` (rejecting a decimal `@1.5`, an empty `@`, a sign-only `@-`, and out-of-range values), and `%\"…\"` yields an `SfDisplayString` (decoding lowercase `%XX` escapes as UTF-8, rejecting uppercase escapes, raw non-ASCII or control characters, malformed hex, and invalid UTF-8). `serialize` is the inverse — a Date as `@` + the integer, a Display String percent-escaping only non-printable / non-ASCII bytes plus `%` and `\"`. The new `b.structuredFields.Date` and `b.structuredFields.DisplayString` wrappers construct these values. The module now tracks RFC 9651 (which obsoletes RFC 8941); the existing Item / List / Dictionary parsing is unchanged."
287
+ }
288
+ ]
289
+ }
290
+ ]
291
+ },
292
+ {
293
+ "version": "0.12.54",
294
+ "date": "2026-05-25",
295
+ "headline": "`b.structuredFields.parse` / `serialize` — full RFC 8941 Structured Fields codec",
296
+ "summary": "The structured-fields module gains a complete RFC 8941 parser and serializer alongside its existing quote-aware helpers. b.structuredFields.parse reads an Item, List, or Dictionary into a typed value model — items are { value, params }, lists are arrays of items / inner lists, dictionaries are Maps — with Tokens and byte sequences returned as distinct SfToken / SfByteSequence instances. It enforces the grammar strictly: integer and decimal digit caps, printable-ASCII strings, canonical base64 byte sequences, valid token and key grammar, and no trailing characters. b.structuredFields.serialize is the exact inverse. This is the real parser the framework's Content-Digest, Client Hints, Web Push, and HTTP Message Signature surfaces can build on instead of open-coding each field. Validated against the official httpwg structured-field-tests conformance vectors.",
297
+ "sections": [
298
+ {
299
+ "heading": "Added",
300
+ "items": [
301
+ {
302
+ "title": "`b.structuredFields.parse(input, type, opts?)` / `serialize(value, type, opts?)` / `Token` / `ByteSequence`",
303
+ "body": "`parse` accepts `type` of `\"item\"`, `\"list\"`, or `\"dictionary\"` and returns the value model (items as `{ value, params }` with a `Map` of parameters; lists as arrays of items or inner lists; dictionaries as `Map`s). Bare items are JS numbers (Integer / Decimal), strings, booleans, `SfToken`, or `SfByteSequence`. Malformed input is rejected — out-of-range integers, over-long decimals, non-printable string bytes, non-canonical base64, invalid tokens / keys, and any trailing characters — and `opts.ErrorClass` yields a typed error. `serialize` is the inverse, rounding decimals to three fractional digits and refusing values outside the RFC's ranges or grammar. `b.structuredFields.Token` and `b.structuredFields.ByteSequence` wrap those bare-item types for serialization. The existing `splitTopLevel` / `refuseControlBytes` / `unquoteSfString` helpers are unchanged."
304
+ }
305
+ ]
306
+ }
307
+ ]
308
+ },
309
+ {
310
+ "version": "0.12.53",
311
+ "date": "2026-05-25",
312
+ "headline": "`b.contentDigest` — HTTP Content-Digest / Repr-Digest fields (RFC 9530)",
313
+ "summary": "Emit and verify the Content-Digest / Repr-Digest HTTP fields so a recipient can detect a corrupted or tampered message body. b.contentDigest.create builds the RFC 8941 dictionary value (sha-256=:base64:, sha-512=:base64:) over a body; b.contentDigest.verify recomputes each modern digest over the body and compares it in constant time. Only SHA-256 and SHA-512 are computed — the legacy algorithms RFC 9530 §6 marks insecure (MD5, SHA-1, the unix checksums) are ignored on verify, and a field carrying no modern digest is refused, so an attacker cannot downgrade integrity to an MD5-only digest. Content-Digest is the integrity companion to HTTP Message Signatures (b.httpSig, RFC 9421): sign the digest rather than the whole body. Verified against the RFC 9530 Appendix D worked examples.",
314
+ "sections": [
315
+ {
316
+ "heading": "Added",
317
+ "items": [
318
+ {
319
+ "title": "`b.contentDigest.create(body, opts?)` / `b.contentDigest.verify(fieldValue, body, opts?)`",
320
+ "body": "`create` returns a Content-Digest / Repr-Digest field value over the body — SHA-256 by default, or any subset of `[\"sha-256\",\"sha-512\"]` via `opts.algorithms` — and refuses insecure or unknown algorithms. `verify` parses the field, recomputes each SHA-256 / SHA-512 entry over the body, and compares constant-time; it throws `content-digest/mismatch` on any mismatch, ignores legacy / unknown entries, throws `content-digest/no-modern-digest` if the field has no SHA-256 / SHA-512 entry at all, and honours `opts.required` to force specific algorithms to be present and match. Composes the framework's structured-field helpers and constant-time compare; Repr-Digest is the same machinery over the selected representation (RFC 9110)."
321
+ }
322
+ ]
323
+ }
324
+ ]
325
+ },
326
+ {
327
+ "version": "0.12.52",
328
+ "date": "2026-05-25",
329
+ "headline": "`b.privacyPass` — Privacy Pass origin-side token verification (RFC 9577 / 9578)",
330
+ "summary": "Anonymous, publicly verifiable authorization: an origin issues a WWW-Authenticate: PrivateToken challenge and verifies a presented token cryptographically, without learning who the client is and without a callback to the issuer. b.privacyPass implements the publicly verifiable token type 0x0002 (Blind RSA, 2048-bit): the token's authenticator is an RSA Blind Signature (RFC 9474) checked as RSASSA-PSS (SHA-384, 48-byte salt) over token_input = token_type ‖ nonce ‖ challenge_digest ‖ token_key_id, using only the issuer's public key. The token is bound to that key (token_key_id) and, optionally, to the challenge it answers, so a token minted for another origin is refused. Blind RSA is the algorithm Privacy Pass defines on the wire — like the DNSSEC / DANE verifiers it validates an external protocol's signatures rather than introducing classical crypto as a framework default. Verified against the RFC 9578 §8.2 test vector.",
331
+ "sections": [
332
+ {
333
+ "heading": "Added",
334
+ "items": [
335
+ {
336
+ "title": "`b.privacyPass.verifyToken(opts)` / `parseToken` / `buildChallenge`",
337
+ "body": "`buildChallenge` builds a TokenChallenge (RFC 9577 §2.1) and the matching `WWW-Authenticate: PrivateToken challenge=…, token-key=…` header an origin returns to request a token, scoped to an issuer (and optionally an origin and a 32-byte redemption context). `parseToken` splits a token into its fields (type / nonce / challenge_digest / token_key_id / authenticator). `verifyToken` verifies a type 0x0002 (Blind RSA) token: it confirms the token's `token_key_id` is the SHA-256 of the supplied issuer public key, optionally that its `challenge_digest` matches `opts.challenge`, and that the authenticator is a valid RSASSA-PSS signature over the token input. Refuses unknown / privately verifiable token types (the VOPRF type 0x0001 needs the issuer secret and is an issuer-side operation), key-id and challenge mismatches, and tampered authenticators. Marked experimental while the issuance protocols see deployment."
338
+ }
339
+ ]
340
+ }
341
+ ]
342
+ },
343
+ {
344
+ "version": "0.12.51",
345
+ "date": "2026-05-25",
346
+ "headline": "`b.network.dns.dane.matchCertificate` — DANE / TLSA certificate matching (RFC 6698 / 7671)",
347
+ "summary": "Pin a service's certificate through DNS instead of a public CA. matchCertificate checks a server certificate against a set of TLSA records: the selected data — the full certificate (selector 0) or its subjectPublicKeyInfo (selector 1) — is hashed per the matching type (exact / SHA-256 / SHA-512) and compared in constant time to the record's association data. For a DANE-EE (usage 3) record a match is self-authenticating — the TLSA pins the key, so no public-CA path is needed (the common SMTP-DANE case, RFC 7672); for the PKIX usages a match is reported as necessary-but-not-sufficient so the caller still runs PKIX. This is the payoff of the DNSSEC verifier: verify the TLSA RRset with b.network.dns.dnssec, then match the certificate. Verified against a live DNSSEC-signed TLSA record and the matching server certificate.",
348
+ "sections": [
349
+ {
350
+ "heading": "Added",
351
+ "items": [
352
+ {
353
+ "title": "`b.network.dns.dane.matchCertificate(opts)`",
354
+ "body": "Matches a leaf certificate (and optional `chain`) against a TLSA RRset (`{ usage, selector, matchingType, data }`). Selector 0 hashes the full certificate DER, selector 1 the subjectPublicKeyInfo; matching type 0 is an exact comparison, 1 SHA-256, 2 SHA-512 (SHA-1 and any other type are refused, not guessed). End-entity usages (PKIX-EE 1, DANE-EE 3) match the leaf; trust-anchor usages (PKIX-TA 0, DANE-TA 2) match the leaf or any supplied chain certificate. Returns `{ ok, matched, daneAuthenticated, trustAnchorMatch, pkixRequired, matchedCertIndex }` — `daneAuthenticated` is true only for a DANE-EE match (the key is pinned, no CA needed); `pkixRequired` flags the PKIX usages. Throws `dane/no-match` when nothing matches, and refuses unknown usage / selector / matching values and unparseable certificates. Verify the TLSA RRset with `b.network.dns.dnssec` first — an unauthenticated TLSA record proves nothing."
355
+ }
356
+ ]
357
+ }
358
+ ]
359
+ },
360
+ {
361
+ "version": "0.12.50",
362
+ "date": "2026-05-25",
363
+ "headline": "`b.network.dns.dnssec.verifyChain` — validate a DNSSEC delegation chain to a pinned root anchor",
364
+ "summary": "Completes local DNSSEC verification: validate a full delegation chain from the root down to a zone against a pinned trust anchor (RFC 4035 §5), instead of trusting any single resolver. For each link, the zone's DNSKEY RRset must be self-signed by one of its keys, and that key must be vouched for either by a pinned anchor (at the root) or by a DS record served + signed by the already-trusted parent — so trust flows root → TLD → zone with no gap. The IANA root KSKs (KSK-2017 tag 20326, KSK-2024 tag 38696) ship as the default anchors; override with opts.trustAnchors for a private root. verifyChain returns the leaf zone's trusted DNSKEY set, which you then hand to verifyRrset / verifyDenial for the actual answer. Composes verifyRrset + verifyDs + the key tag; verified end-to-end against a live root→org chain.",
365
+ "sections": [
366
+ {
367
+ "heading": "Added",
368
+ "items": [
369
+ {
370
+ "title": "`b.network.dns.dnssec.verifyChain(opts)`",
371
+ "body": "Walks an ordered, root-first list of `links` ({ zone, dnskeys, dnskeyRrsig, dsRdatas?, dsRrsig? }). At each link it verifies the DNSKEY RRset's self-signature (composing `verifyRrset`), then establishes trust in the signing key: at the root by matching a pinned anchor's DS digest (`verifyDs`), at every delegation by verifying the parent-served DS RRset's signature with the already-trusted parent key and confirming the signing KSK matches one of those DS records. Returns `{ ok, zone, keys, path }` with the leaf zone's trusted DNSKEY set. Refuses a root key that matches no anchor (`dnssec/chain-anchor-mismatch`), a KSK that matches no parent DS (`dnssec/chain-ds-mismatch`), and a missing parent key (`dnssec/chain-no-parent-key`). The default `DEFAULT_ROOT_ANCHORS` are the published IANA root KSK DS records; `opts.trustAnchors` overrides them for a private or test root."
372
+ }
373
+ ]
374
+ }
375
+ ]
376
+ },
377
+ {
378
+ "version": "0.12.49",
379
+ "date": "2026-05-25",
380
+ "headline": "`b.network.dns.dnssec.verifyDenial` — NSEC / NSEC3 denial-of-existence",
381
+ "summary": "Prove a DNS name does not exist, or has no records of a given type, from the signed NSEC (RFC 4034 §4) or NSEC3 (RFC 5155) records a server returns. This is the other half of local DNSSEC verification: verifyRrset proves a positive answer, verifyDenial proves a negative — so a resolver client can confirm an NXDOMAIN / NODATA itself instead of trusting the upstream resolver. NSEC3 proofs run the closest-encloser / next-closer / covering-range logic over iterated-SHA-1 hashes, with the iteration count capped (default 500) to bound the work an attacker can force, and an Opt-Out NXDOMAIN refused unless explicitly accepted (opt-out only proves 'no signed records', not non-existence). The companion b.network.dns.dnssec.nsec3Hash computes the RFC 5155 §5 hash directly. NSEC verifyRrset support is also enabled: per RFC 6840 §5.1 the NSEC Next Domain Name is not downcased, so its RDATA is verbatim-canonical.",
382
+ "sections": [
383
+ {
384
+ "heading": "Added",
385
+ "items": [
386
+ {
387
+ "title": "`b.network.dns.dnssec.verifyDenial(opts)`",
388
+ "body": "Proves NXDOMAIN or NODATA from already-verified NSEC / NSEC3 records (supply one of `opts.nsec3` or `opts.nsec`). Like `verifyDs`, it checks the denial RELATION — closest-encloser matching, covering ranges, and type-bitmap absence — not the record signatures, which the caller verifies with `verifyRrset` first. NSEC3 supports name-error proofs (matching closest encloser + covered next-closer + covered wildcard), NODATA (matching record with the type and CNAME absent from the bitmap), Opt-Out DS NODATA, and wildcard NODATA. The iterated-SHA-1 count is capped by `opts.maxIterations` (default 500); an NXDOMAIN proof that depends on an Opt-Out NSEC3 is refused unless `opts.allowOptOut` is set. NSEC supports covering-name NXDOMAIN (with the source-of-synthesis wildcard) and matching-name NODATA. Verified end-to-end against a live iana.org NXDOMAIN proof."
389
+ },
390
+ {
391
+ "title": "`b.network.dns.dnssec.nsec3Hash(name, opts)`",
392
+ "body": "Computes the RFC 5155 §5 NSEC3 hash of a name — iterated SHA-1 over the canonical (lowercased, root-terminated) wire form with the zone salt. The base32hex encoding of the result is the NSEC3 owner label. SHA-1 is the only hash IANA registers for NSEC3, so this is a wire-protocol constant rather than a cryptographic default. Useful for checking an owner label or analyzing a zone's hashing parameters."
393
+ }
394
+ ]
395
+ },
396
+ {
397
+ "heading": "Changed",
398
+ "items": [
399
+ {
400
+ "title": "`verifyRrset` now accepts NSEC and NSEC3 RRsets",
401
+ "body": "NSEC (type 47) and NSEC3 (type 50) are no longer refused as uncanonicalizable: NSEC3's next-owner is a hash, and per RFC 6840 §5.1 the NSEC Next Domain Name field is not downcased for DNSSEC canonical form, so both RDATAs are verbatim-canonical. This lets a caller verify the signatures on the records that `verifyDenial` then reasons over."
402
+ }
403
+ ]
404
+ }
405
+ ]
406
+ },
407
+ {
408
+ "version": "0.12.48",
409
+ "date": "2026-05-25",
410
+ "headline": "`b.network.dns.dnssec` — local DNSSEC signature verification (RFC 4035)",
411
+ "summary": "Verify a DNS answer's RRSIG signature yourself instead of trusting the upstream resolver's AD bit. b.network.dns.dnssec.verifyRrset reconstructs the RFC 4034 §3.1.8.1 signed data — the RRSIG RDATA without the signature, followed by the RRset in canonical form (owner names lowercased, RRs ordered by canonical RDATA, the RRSIG's Original TTL) — and checks the signature against the DNSKEY, enforcing the inception / expiration window. Supports RSA/SHA-256 (alg 8), ECDSA P-256/SHA-256 (13), ECDSA P-384/SHA-384 (14), and Ed25519 (15) — the modern deployed set. verifyDs checks a delegation-signer digest against a DNSKEY (SHA-256 / SHA-384) and keyTag computes the RFC 4034 Appendix B key tag. The verification core is what a chain-walker composes; it defends against a compromised or on-path resolver that lies about authentication.",
412
+ "sections": [
413
+ {
414
+ "heading": "Added",
415
+ "items": [
416
+ {
417
+ "title": "`b.network.dns.dnssec.verifyRrset(opts)`",
418
+ "body": "Verifies an RRSIG over a canonicalised RRset against a DNSKEY. `opts` carries the owner `name`, the RR `type`, the wire-format `rdatas`, the parsed `rrsig` (algorithm / labels / originalTtl / inception / expiration / keyTag / signerName / signature), and the `dnskey` (algorithm + raw public key). The signed data is rebuilt per RFC 4034 §3.1.8.1: the RRSIG prefix (type covered | algorithm | labels | original TTL | expiration | inception | key tag | canonical signer name) followed by each RR in canonical form (lowercased owner | type | class | original TTL | rdlen | rdata), sorted by `Buffer.compare` on the RDATA. The validity window is enforced against `opts.at` (defaults to now; an invalid Date is refused, not treated as now). An RRSIG whose algorithm disagrees with the DNSKEY is refused before any key is built. RR types that embed domain names in their RDATA (NS, CNAME, SOA, MX, SRV, …) need RDATA-internal name-lowercasing this version does not perform, so they are refused with `dnssec/uncanonicalizable-type` rather than mis-validated; the security-critical DNSKEY / DS and the name-free address / text types (A, AAAA, TXT, CAA, TLSA, …) are fully supported."
419
+ },
420
+ {
421
+ "title": "`b.network.dns.dnssec.verifyDs(opts)` / `b.network.dns.dnssec.keyTag(dnskeyRdata)`",
422
+ "body": "`verifyDs` confirms a delegation-signer record matches a DNSKEY: it checks the key tag, then compares the DS digest (SHA-256 type 2 / SHA-384 type 4) against the digest computed over the canonical owner name and the DNSKEY RDATA, constant-time. `keyTag` computes the RFC 4034 Appendix B 16-bit key tag from a DNSKEY's full RDATA — the identifier an RRSIG or DS uses to select the signing key. Together with `verifyRrset` these are the per-RRset building blocks a recursive chain-walk (root → TLD → zone) composes; the chain-walk itself, NSEC / NSEC3 denial-of-existence, and the bundled IANA root trust anchor are not part of this core."
423
+ }
424
+ ]
425
+ }
426
+ ]
427
+ },
428
+ {
429
+ "version": "0.12.47",
430
+ "date": "2026-05-25",
431
+ "headline": "`b.cose.mac0` / `b.cose.macVerify0` — COSE_Mac0 (RFC 9052 §6.2)",
432
+ "summary": "Completes the COSE message-type set (COSE_Sign1 / COSE_Encrypt0 / COSE_Mac0) with single shared-key MACs. b.cose.mac0 produces a tagged COSE_Mac0 over a payload using HMAC-SHA-256/384/512 (the COSE-standard MAC algorithms; HMAC is symmetric, so its post-quantum strength is preserved). b.cose.macVerify0 recomputes the tag over the MAC_structure and compares it in constant time, with a mandatory algorithm allowlist. Use when both parties hold a shared key — e.g. an ECDH-derived key — and a non-repudiable signature is not wanted; detached payloads are supported (the proximity mdoc device-MAC variant and MACed CWTs are the consumers). Composes b.cbor + the framework's constant-time compare; no new runtime dependency.",
433
+ "sections": [
434
+ {
435
+ "heading": "Added",
436
+ "items": [
437
+ {
438
+ "title": "`b.cose.mac0(payload, opts)` / `b.cose.macVerify0(coseMac0, opts)`",
439
+ "body": "`mac0` emits a tagged COSE_Mac0 (tag 17) with `alg` (`HMAC-256/256` | `HMAC-384/384` | `HMAC-512/512`) in the protected header and the HMAC tag computed over the MAC_structure `[\"MAC0\", protected, external_aad, payload]`; `detached: true` emits a nil payload. `macVerify0` reads the algorithm from the protected header (must be in the required `opts.algorithms` allowlist), recomputes the tag, and compares it constant-time — a wrong key, tampered tag, or `external_aad` mismatch is refused with `cose/bad-tag`; a detached payload is supplied via `opts.externalPayload`. `external_aad` binds context into the tag."
440
+ }
441
+ ]
442
+ }
443
+ ]
444
+ },
445
+ {
446
+ "version": "0.12.46",
447
+ "date": "2026-05-25",
448
+ "headline": "`b.mdoc.verifyDeviceAuth` — ISO 18013-5 mdoc device authentication",
449
+ "summary": "Completes mdoc verification with the holder-binding half (ISO 18013-5 §9.1.3, signature variant). verifyIssuerSigned proves the data is issuer-signed; verifyDeviceAuth proves the presenter controls the device key the issuer bound into the MSO, so a captured issuer-signed document cannot be replayed by anyone else. The device's COSE_Sign1 (deviceSigned.deviceAuth.deviceSignature) is verified over the detached DeviceAuthentication structure [\"DeviceAuthentication\", SessionTranscript, DocType, DeviceNameSpacesBytes] using the device key from verifyIssuerSigned().deviceKey (now surfaced) and the operator-supplied SessionTranscript that binds the proof to this exact exchange (the presentation protocol — e.g. OpenID4VP — defines the transcript). Composes the v0.12.45 b.cose detached-payload verify + importKey. The MAC variant (deviceMac / COSE_Mac0, used in proximity flows with a reader ephemeral key) is deferred and refused with mdoc/device-mac-unsupported. No new runtime dependency.",
450
+ "sections": [
451
+ {
452
+ "heading": "Added",
453
+ "items": [
454
+ {
455
+ "title": "`b.mdoc.verifyDeviceAuth(opts)` + `deviceKey` on the verifyIssuerSigned result",
456
+ "body": "`verifyDeviceAuth({ deviceKey, deviceSigned, docType, sessionTranscript, algorithms })` imports the device key (a COSE_Key via `b.cose.importKey`, or a KeyObject), reconstructs the detached `DeviceAuthentication` payload, and verifies the `deviceSignature` COSE_Sign1 against the mandatory algorithm allowlist — a mismatched `sessionTranscript` or `docType` fails the signature. `verifyIssuerSigned` now returns `deviceKey` (the MSO `deviceKeyInfo.deviceKey`) so the two checks chain. The MAC variant (`deviceMac`) is refused with `mdoc/device-mac-unsupported` pending COSE_Mac0 + reader-key support."
457
+ }
458
+ ]
459
+ }
460
+ ]
461
+ },
462
+ {
463
+ "version": "0.12.45",
464
+ "date": "2026-05-25",
465
+ "headline": "`b.cose` adds detached-payload sign/verify + `b.cose.importKey` (COSE_Key)",
466
+ "summary": "Two RFC 9052 / 9053 completions to the COSE substrate, both useable today and the prerequisites for mdoc device authentication and C2PA claim verification. Detached payloads (RFC 9052 §4.1): b.cose.sign with detached:true emits a COSE_Sign1 whose payload slot is nil — the signature still covers the payload, and the caller transmits it out of band; b.cose.verify takes the payload back as opts.externalPayload and binds it into the Sig_structure. A detached token verified without externalPayload is refused, and supplying externalPayload for an attached token is refused as ambiguous. COSE_Key import (RFC 9052 §7): b.cose.importKey turns a COSE_Key CBOR map into a node:crypto public KeyObject for b.cose.verify, accepting EC2 (P-256 / P-384 / P-521) and OKP (Ed25519) with the curve allowlisted so an unexpected key type is refused. No new runtime dependency.",
467
+ "sections": [
468
+ {
469
+ "heading": "Added",
470
+ "items": [
471
+ {
472
+ "title": "Detached COSE_Sign1 payloads + `b.cose.importKey(coseKey)`",
473
+ "body": "`b.cose.sign(payload, { detached: true })` emits a nil-payload COSE_Sign1 (the signature covers the payload regardless); `b.cose.verify(coseSign1, { externalPayload })` reconstructs the Sig_structure from the supplied payload, refusing a detached token with no `externalPayload` (`cose/detached-no-payload`) and refusing `externalPayload` on an attached token (`cose/payload-ambiguous`). `b.cose.importKey(coseKey)` maps a COSE_Key map (`kty` 2/EC2 with `crv` P-256/384/521, or `kty` 1/OKP with Ed25519) to a public KeyObject, allowlisting `kty`/`crv` and refusing anything else with `cose/unsupported-key` — the verification key embedded in an mdoc MSO or COSE_Key header is consumed this way."
474
+ }
475
+ ]
476
+ }
477
+ ]
478
+ },
479
+ {
480
+ "version": "0.12.44",
481
+ "date": "2026-05-25",
482
+ "headline": "`b.did` adds the did:jwk method",
483
+ "summary": "Completes b.did's method set with did:jwk alongside did:key and did:web. did:jwk encodes a public key as a base64url-encoded JWK directly in the identifier, so resolution is deterministic and offline — the same self-contained shape as did:key but in JWK form, which is what OpenID4VCI and the EU Digital Identity Wallet ecosystem commonly use. b.did.resolve(\"did:jwk:…\") returns the verification key as a node:crypto KeyObject (kty/crv allowlisted — Ed25519 / P-256 / P-384 / secp256k1 — so an unexpected key type is refused, not blindly imported), and b.did.keyToDid(publicKey, { method: \"jwk\" }) produces a did:jwk from a key (the private member is stripped). No new runtime dependency.",
484
+ "sections": [
485
+ {
486
+ "heading": "Added",
487
+ "items": [
488
+ {
489
+ "title": "did:jwk in `b.did.resolve` / `b.did.keyToDid`",
490
+ "body": "`resolve` decodes the base64url JWK (bounded via `b.safeJson`), allowlists its `kty`/`crv`, and returns `{ didDocument, verificationMethods: [{ publicKey, … }] }` with the key as a KeyObject ready for `b.vc` / `b.mdoc` / `b.scitt`; `keyToDid(publicKey, { method: \"jwk\" })` encodes a public key as `did:jwk:<base64url-JWK>` (default remains `did:key`). Malformed base64url-JSON is refused with `did/bad-jwk` and an unsupported key type with `did/unsupported-key`."
491
+ }
492
+ ]
493
+ }
494
+ ]
495
+ },
496
+ {
497
+ "version": "0.12.43",
498
+ "date": "2026-05-25",
499
+ "headline": "`b.crypto.selfTest` — FIPS 140-3-style power-on self-test for the crypto stack",
500
+ "summary": "A power-on self-test over the framework's cryptographic primitives — the integrity check a FIPS 140-3-validated module runs at start-up. The hash / XOF checks are known-answer tests against NIST FIPS 202 published vectors (SHA3-256 / SHA3-512 / SHAKE256), so they confirm the framework's hashing matches the standard rather than merely itself; the AEAD check round-trips XChaCha20-Poly1305 and confirms a tampered ciphertext is rejected; and the post-quantum checks run a pairwise-consistency + negative test for ML-KEM-1024, ML-DSA-87, and SLH-DSA-SHAKE-256f (a fresh keypair must encaps/decaps and sign/verify consistently and reject a tampered signature — FIPS 140-3 §10.3 pairwise consistency, since the runtime exposes no seed-injection API for a fixed-seed KAT). selfTest returns a structured report and, by default, throws on any failure so a broken crypto stack fails closed at boot rather than silently producing bad output. Operators in regulated deployments can run it at start-up as a self-integrity gate.",
501
+ "sections": [
502
+ {
503
+ "heading": "Added",
504
+ "items": [
505
+ {
506
+ "title": "`b.crypto.selfTest(opts?)`",
507
+ "body": "Runs eight checks — SHA3-512 / SHA3-256 / SHAKE256 known-answer tests (NIST FIPS 202), HMAC-SHA3-512 determinism, XChaCha20-Poly1305 round-trip + tamper-detect, and ML-KEM-1024 / ML-DSA-87 / SLH-DSA-SHAKE-256f pairwise-consistency + negative tests — and returns `{ ok, results: [{ name, ok, detail? }], failures, ranAt }`. Throws `crypto/self-test-failed` (with the report attached) on any failure unless `opts.throwOnFailure` is `false`. Exercises the framework's real primitive paths so a self-test failure means the shipped crypto is broken."
508
+ }
509
+ ]
510
+ }
511
+ ]
512
+ },
513
+ {
514
+ "version": "0.12.42",
515
+ "date": "2026-05-24",
516
+ "headline": "`b.vc.present` / `b.vc.verifyPresentation` — W3C Verifiable Presentations",
517
+ "summary": "Completes b.vc with the holder side: a Verifiable Presentation is a holder-signed envelope wrapping one or more credentials, proving the presenter controls the key the credentials were issued to. b.vc.present builds and signs a VerifiablePresentation (each credential enveloped per VC-JOSE-COSE) as a compact JWS (vp+jwt) or COSE_Sign1 (application/vp+cose), matching b.vc.issue's algorithms; an optional nonce / audience is embedded in the signed presentation for holder-binding and replay protection. b.vc.verifyPresentation verifies the holder signature (auto-detected jose/cose, mandatory algorithm allowlist, JOSE none refused), the VCDM structure, and the embedded nonce / audience / expectedHolder when given, and — with verifyCredentials: true — verifies each enveloped credential through b.vc.verify and returns them. The holder is typically a DID, resolved to a key via b.did. Composes b.cose; no new runtime dependency.",
518
+ "sections": [
519
+ {
520
+ "heading": "Added",
521
+ "items": [
522
+ {
523
+ "title": "`b.vc.present(opts)` / `b.vc.verifyPresentation(secured, opts)`",
524
+ "body": "`present` wraps `opts.credentials` (secured VCs — compact-JWS strings or COSE_Sign1 bytes, each enveloped as an `EnvelopedVerifiableCredential` data: URI) in a `VerifiablePresentation` signed by the holder, with optional `nonce` / `audience` embedded for binding. `verifyPresentation` verifies the holder signature against the mandatory `opts.algorithms` allowlist (JOSE `none` always refused), re-checks the VCDM structure, enforces `expectedHolder` / `nonce` / `audience` when supplied, and with `verifyCredentials: true` verifies each enveloped credential through `b.vc.verify` (using `opts.credentialOpts`), returning `{ presentation, holder, credentials, securing, alg }`. The enveloped-credential count is bounded. A `vp+jwt` presentation is refused by `b.vc.verify` and a `vc+jwt` credential is refused by `verifyPresentation` — the media-type binding keeps the two surfaces distinct."
525
+ }
526
+ ]
527
+ }
528
+ ]
529
+ },
530
+ {
531
+ "version": "0.12.41",
532
+ "date": "2026-05-24",
533
+ "headline": "`b.did` — W3C DID resolution (did:key + did:web) feeding the credential verifiers",
534
+ "summary": "Resolve W3C Decentralized Identifiers (DID Core 1.0) to verification keys — the link that lets a credential's issuer be named by a DID rather than a raw key. Resolve the issuer DID of a b.vc / b.mdoc / b.scitt credential to a node:crypto KeyObject and hand it to the verifier. did:key encodes the public key in the identifier (multicodec + base58btc), so resolution is deterministic and offline — Ed25519, P-256, P-384, and secp256k1 round-trip; did:web places the DID document at an HTTPS URL derived from the identifier, with the network fetch left to the operator (the framework parses the operator-fetched document and extracts its verification methods, as publicKeyMultibase or publicKeyJwk). b.did.keyToDid encodes a KeyObject as a did:key (an issuer naming itself), b.did.parse splits the identifier (and returns the did:web URL to fetch), and b.did.resolve returns the document and verification keys. DID Core 1.0 is a W3C Recommendation; the method specs (did:key W3C CCG report, did:web DID method registry — EUDI-mandated) are deployed-stable. Composes node:crypto; no new runtime dependency.",
535
+ "sections": [
536
+ {
537
+ "heading": "Added",
538
+ "items": [
539
+ {
540
+ "title": "`b.did.resolve(did, opts?)` / `b.did.keyToDid(publicKey)` / `b.did.parse(did)`",
541
+ "body": "`resolve` returns `{ didDocument, verificationMethods: [{ id, controller, type, publicKey }] }` with each `publicKey` a `node:crypto` KeyObject ready for `b.vc.verify` / `b.mdoc.verifyIssuerSigned` / `b.scitt.verifyStatement`. did:key resolves deterministically and offline (base58btc + multicodec → Ed25519 raw key or EC compressed point, rebuilt via SPKI); did:web requires the operator to pass the fetched DID document as `opts.document` (the URL to GET is on `b.did.parse(did).url`) and the document `id` must match the requested DID. A publicKeyJwk in a DID document is imported only after its `kty`/`crv` is allowlisted (Ed25519 / P-256 / P-384 / secp256k1) — an unexpected key type from an untrusted document is refused, not blindly imported. `keyToDid` encodes an Ed25519 / P-256 / P-384 / secp256k1 KeyObject as a did:key; `parse` derives the did:web HTTPS URL (`host[:port][:path]` → `https://host/path/did.json`, or `/.well-known/did.json`). Unknown methods, malformed base58, unsupported multicodec codes, and unsupported key types are each refused."
542
+ }
543
+ ]
544
+ }
545
+ ]
546
+ },
547
+ {
548
+ "version": "0.12.40",
549
+ "date": "2026-05-24",
550
+ "headline": "`b.mdoc` — ISO 18013-5 mdoc / mDL issuer-data verification",
551
+ "summary": "Verify the issuer-signed data of an ISO/IEC 18013-5 mdoc — the credential format behind mobile driving licences (mDL) and the ISO track of the EU Digital Identity Wallet. This is the relying-party side: confirm that the data elements a holder presents were signed by the issuer and have not been altered. An mdoc's IssuerSigned carries the disclosed data elements and an issuerAuth that is a COSE_Sign1 (b.cose) over a Mobile Security Object (MSO) holding a per-element digest. b.mdoc.verifyIssuerSigned verifies the COSE signature with the issuer certificate from the COSE x5chain header, parses the MSO, enforces its validityInfo window, and recomputes each disclosed element's digest (the full Tag-24 IssuerSignedItemBytes) to match it against the MSO constant-time — the integrity check that makes selective disclosure trustworthy. An absent or mismatched digest is refused. Signing algorithms follow b.cose verification (the classical ES256/384/512 + EdDSA that real mDL issuers use; the caller names the allowlist); opts.trustAnchorsPem additionally verifies the issuer certificate chain. This completes the credential trio alongside W3C VCDM (b.vc) and IETF SD-JWT VC (b.auth.sdJwtVc). Composes b.cose + b.cbor; no new runtime dependency.",
552
+ "sections": [
553
+ {
554
+ "heading": "Added",
555
+ "items": [
556
+ {
557
+ "title": "`b.mdoc.verifyIssuerSigned(issuerSigned, opts)`",
558
+ "body": "Takes the CBOR `IssuerSigned` map (the operator extracts it from the device response / QR) and returns `{ docType, version, digestAlgorithm, validityInfo, namespaces, signerCert, alg }`. Verifies the COSE_Sign1 `issuerAuth` against the mandatory `opts.algorithms` allowlist using the issuer certificate from its `x5chain` (label 33) header; parses the Tag-24 Mobile Security Object; enforces the MSO `validityInfo` window against `opts.at` (default now; must be a valid Date; malformed dates fail closed); and recomputes the digest of every disclosed `IssuerSignedItem` (over the full Tag-24 bytes, with the MSO `digestAlgorithm` — SHA-256/384/512) to match the MSO `valueDigests` constant-time — an absent or mismatched digest is refused with `mdoc/digest-mismatch`. `opts.expectedDocType` pins the document type; `opts.trustAnchorsPem` (a PEM string or array) additionally verifies the issuer certificate chain and validity at the asserted time. A malformed `x5chain` certificate is refused with a clean `mdoc/bad-cert`. The mdoc device-authentication half (the SessionTranscript-bound holder-binding proof) is a presentation-protocol concern and is not part of issuer-data verification."
559
+ }
560
+ ]
561
+ }
562
+ ]
563
+ },
564
+ {
565
+ "version": "0.12.39",
566
+ "date": "2026-05-24",
567
+ "headline": "`b.vc` — W3C Verifiable Credentials 2.0 (issue / verify, JOSE + COSE securing)",
568
+ "summary": "Issue and verify W3C Verifiable Credentials (VC Data Model 2.0, a W3C Recommendation) secured per Securing Verifiable Credentials using JOSE and COSE (VC-JOSE-COSE, also a W3C Recommendation, May 2025). A verifiable credential is a tamper-evident, signed set of claims an issuer makes about a subject — a diploma, a membership, a license, an age assertion. Two securing mechanisms are supported, both signing the credential itself (no JWT/CWT claims wrapper): JOSE produces a compact JWS with the vc+jwt media type, signed with ES256/384/512 or EdDSA; COSE produces a COSE_Sign1 (application/vc+cose) over b.cose, which also accepts ML-DSA-87 for PQC-forward deployments. b.vc.verify auto-detects the form from the input, requires an algorithm allowlist, always refuses the JOSE none algorithm, re-checks the VCDM 2.0 structural rules, and enforces the validFrom / validUntil window. This is the W3C credential model, distinct from the IETF SD-JWT VC already at b.auth.sdJwtVc. Composes b.cose; no new runtime dependency.",
569
+ "sections": [
570
+ {
571
+ "heading": "Added",
572
+ "items": [
573
+ {
574
+ "title": "`b.vc.issue(credential, opts)` / `b.vc.verify(secured, opts)`",
575
+ "body": "`issue` validates the credential against the VCDM 2.0 structural rules (the `credentials/v2` context first, a `VerifiableCredential` type, an issuer, a credential subject) and signs it: `securing: \"jose\"` returns a compact JWS string (`typ` header `vc+jwt`), `securing: \"cose\"` returns COSE_Sign1 bytes (`typ` header `application/vc+cose`, content type `application/vc`) via `b.cose`. The credential is the exact signed payload — no JWT/CWT claims are injected. `verify` auto-detects the securing form from the input (compact-JWS string vs. COSE_Sign1 bytes), verifies the signature against the mandatory `opts.algorithms` allowlist (the JOSE `none` algorithm is always refused), re-checks the structural rules, enforces the `validFrom` / `validUntil` window against `opts.at` (default now; must be a valid Date), and optionally matches `opts.expectedIssuer` against the credential issuer id. Returns `{ credential, securing, alg, issuer }`."
576
+ }
577
+ ]
578
+ }
579
+ ]
580
+ },
581
+ {
582
+ "version": "0.12.38",
583
+ "date": "2026-05-24",
584
+ "headline": "`b.tsa` — RFC 3161 trusted timestamping client (build / parse / verify)",
585
+ "summary": "A timestamp authority binds a hash of your data to a trusted time, producing a token that proves the data existed at that instant — timestamp a release artifact, an audit-log checkpoint, a b.scitt signed statement, or a contract. b.tsa is the requester/verifier side of RFC 3161: buildRequest produces the DER TimeStampReq (the message imprint plus an optional nonce and a cert request), parseResponse reads the TimeStampResp (PKIStatus, failure-info bits, and the token), and verifyToken checks a token against your data and returns the asserted time. Verification is done in full per §2.4.2 / §2.3: the token is a CMS SignedData (b.cms) whose eContentType must be id-ct-TSTInfo; the message imprint must equal the hash of your data (constant-time); a sent nonce must round-trip; the signer certificate's extendedKeyUsage must be a critical, sole id-kp-timeStamping; and the CMS signature over the signed attributes must verify after the messageDigest attribute is matched to the recomputed eContent digest. An optional trust-anchor set verifies the certificate chain and validity at the asserted time. The HTTP transport to the TSA is the operator's to make. Composes b.cms and the in-tree ASN.1 DER codec; no new runtime dependency.",
586
+ "sections": [
587
+ {
588
+ "heading": "Added",
589
+ "items": [
590
+ {
591
+ "title": "`b.tsa.buildRequest(data, opts?)` / `b.tsa.parseResponse(der)` / `b.tsa.verifyToken(token, opts)`",
592
+ "body": "`buildRequest` returns `{ der, nonce, hashAlg, messageImprint }`; the imprint hash defaults to SHA-512 and may be SHA-256/384/512 or SHA3-256/512, a random 64-bit nonce and a certificate request are included by default, and a pre-hashed input is accepted with `hashed: true`. `parseResponse` returns `{ granted, status, statusString, failInfo, token }`, decoding the PKIFailureInfo bits for a non-granted response rather than throwing. `verifyToken` enforces the imprint match (`opts.data` or `opts.hash`), the nonce round-trip, the critical/sole `id-kp-timeStamping` EKU, and the CMS signature, returning `{ genTime, policy, serialHex, accuracy, hashAlg, signerCertPem }`; pass `opts.trustAnchorsPem` to also verify the certificate chain and validity at the asserted time. Timestamp tokens are third-party artifacts, so verification accepts the classical RSA (PKCS#1 v1.5 and PSS) and ECDSA-over-SHA-2 signatures that public TSAs emit — the same consume-what-exists posture as `b.cose` verification, not a framework signing default."
593
+ }
594
+ ]
595
+ }
596
+ ]
597
+ },
598
+ {
599
+ "version": "0.12.37",
600
+ "date": "2026-05-24",
601
+ "headline": "`b.scitt.signStatement` / `b.scitt.verifyStatement` — SCITT signed statements over COSE (RFC 9052 + RFC 9597)",
602
+ "summary": "A SCITT signed statement is a signed, attributable claim about an artifact — a signed SBOM, a build attestation, a release approval. It is a COSE_Sign1 (b.cose) whose integrity-protected CWT_Claims header (label 15, RFC 9597) binds the issuer (who makes the statement) and the subject (the artifact it is about); the artifact, or a hash/reference to it, is the payload. signStatement places iss/sub in the protected header and declares the payload media type; verifyStatement checks the COSE signature (the algorithm allowlist is mandatory) and refuses any statement that lacks the iss/sub binding, with optional expected-issuer/subject matching. Signing uses the same algorithms as b.cose — classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) plus ML-DSA-87 (PQC-forward). This is the issuer side of SCITT, buildable today on finalized RFCs; the transparency receipt (an inclusion proof from a transparency service, the COSE Receipts draft) is not yet shipped — a statement produced here is the input a transparency service registers, and the receipt format is the part still in flux. It opts in when COSE Receipts publishes.",
603
+ "sections": [
604
+ {
605
+ "heading": "Added",
606
+ "items": [
607
+ {
608
+ "title": "`b.scitt.signStatement(payload, opts)` / `b.scitt.verifyStatement(statement, opts)`",
609
+ "body": "`signStatement` produces a COSE_Sign1 whose protected CWT_Claims header (label 15) carries `iss` (`opts.issuer`) and `sub` (`opts.subject`), with the payload media type declared via `opts.contentType` and extra CWT claims allowed by integer label (iss/sub cannot be overridden through `opts.claims`). `verifyStatement` verifies the signature through `b.cose.verify` (passing `opts.algorithms` as the mandatory allowlist), then requires a CWT_Claims header with both `iss` and `sub` — a bare COSE_Sign1 with no such binding is refused with `scitt/missing-cwt-claims` — and enforces `expectedIssuer` / `expectedSubject` when given. Returns `{ payload, issuer, subject, cwtClaims, alg, protectedHeaders, unprotectedHeaders }`. Because the identity binding lives in the integrity-protected header it is covered by the signature and cannot be substituted without detection."
610
+ }
611
+ ]
612
+ },
613
+ {
614
+ "heading": "Changed",
615
+ "items": [
616
+ {
617
+ "title": "`b.cose.sign` accepts `protectedHeaders` and a media-type-string `contentType`",
618
+ "body": "`opts.protectedHeaders` (a numeric-keyed object or Map) adds extra integrity-protected header parameters — the CWT_Claims map (label 15) is the SCITT case. Label 1 (alg) is reserved and managed via `opts.alg`; setting it through `protectedHeaders` is refused with `cose/reserved-header`. `opts.contentType` now accepts a media-type string (RFC 9052 §3.1 tstr form, e.g. `\"application/spdx+json\"`) in addition to a CoAP Content-Format uint; a string was previously dropped."
619
+ }
620
+ ]
621
+ }
622
+ ]
623
+ },
624
+ {
625
+ "version": "0.12.36",
626
+ "date": "2026-05-24",
627
+ "headline": "`b.cose.encrypt0` / `b.cose.decrypt0` — COSE_Encrypt0 single-recipient AEAD (RFC 9052 §5.2)",
628
+ "summary": "Completes the COSE family with encryption alongside the v0.12.33 signing: COSE_Encrypt0 is the single-recipient AEAD container where the recipient already holds the symmetric key (direct mode). The default algorithm is ChaCha20/Poly1305 (COSE alg 24) — AES-GCM stays opt-in, since hard-rule #2 forbids AES-GCM as a default. The Enc_structure (`[\"Encrypt0\", protected, external_aad]`) is bound as the AEAD associated data so the algorithm + any external context are authenticated, and the authentication tag is appended to the ciphertext per COSE. Composes the in-tree `b.cbor` codec and `node:crypto` AEAD.",
629
+ "sections": [
630
+ {
631
+ "heading": "Added",
632
+ "items": [
633
+ {
634
+ "title": "`b.cose.encrypt0(plaintext, opts)` / `b.cose.decrypt0(coseEncrypt0, opts)`",
635
+ "body": "`encrypt0` produces a tagged COSE_Encrypt0 with `alg` in the protected header and a random 12-byte IV in the unprotected header (label 5); `alg` is `\"ChaCha20-Poly1305\"` (default), `\"A256GCM\"`, or `\"A128GCM\"`, with the key length enforced (32 / 16 bytes). `decrypt0` reads the algorithm from the protected header (must be in the required `opts.algorithms` allowlist), reconstructs the Enc_structure as the AEAD AAD, and returns `{ plaintext, alg, protectedHeaders, unprotectedHeaders }`; a wrong key, tampered ciphertext, or `external_aad` mismatch fails AEAD authentication and is refused with `cose/decrypt-failed`. `external_aad` binds request context into the tag."
636
+ }
637
+ ]
638
+ }
639
+ ]
640
+ },
641
+ {
642
+ "version": "0.12.35",
643
+ "date": "2026-05-24",
644
+ "headline": "`b.eat` — Entity Attestation Token (RFC 9711) over `b.cwt`",
645
+ "summary": "An EAT is the token a Relying Party asks a device or software entity to produce to prove what it is and what state it is in — a freshness nonce, a Universal Entity ID, OEM / hardware identifiers, debug status, software measurements, and nested submodule attestations. `b.eat` is the RFC 9711 profile over the v0.12.34 `b.cwt`: it maps the EAT claim names to their IANA CWT claim-key integer labels and adds the attestation-specific verification on top of the CWT signature + time checks. The central control is the verifier-nonce binding: when the Relying Party supplies a fresh `expectedNonce`, the token's `eat_nonce` (claim 10) must match it (constant-time compare) — without it a captured attestation replays forever. `verify` also enforces a debug-status policy (`requireDebugDisabled` refuses an `enabled` or absent `dbgstat`) and pins the `eat_profile`. RFC 9711 is a finalized standard; signing follows `b.cwt` / `b.cose` (ES256/384/512 + EdDSA interoperable today, ML-DSA-87 PQC-forward).",
646
+ "sections": [
647
+ {
648
+ "heading": "Added",
649
+ "items": [
650
+ {
651
+ "title": "`b.eat.sign(claims, opts)` / `b.eat.verify(eat, opts)`",
652
+ "body": "`sign` maps EAT claim names (`nonce`, `ueid`, `oemid`, `hwmodel`, `dbgstat`, `eat_profile`, `swname`/`swversion`, `measurements`, `submods`, …) to their RFC 9711 integer labels and accepts the `dbgstat` enum by name (`disabled-since-boot` → 2); standard CWT claims (`iss` / `exp` / …) pass through. `verify` returns `{ claims, raw, alg, protectedHeaders }` with the labels mapped back to friendly names and `dbgstat` decoded to its enum name. Attestation enforcement: `expectedNonce` requires a matching `eat_nonce` (refused `eat/nonce-mismatch`, missing `eat/nonce-missing` — `eat_nonce` may be a single byte string or an array for multiple verifiers), `requireDebugDisabled` refuses a non-disabled `dbgstat` (`eat/debug-not-disabled`), and `expectedProfile` pins `eat_profile`. The signature, algorithm allowlist, and `exp`/`nbf` checks delegate to `b.cwt` / `b.cose`."
653
+ },
654
+ {
655
+ "title": "`b.cwt.sign` accepts a `Map`",
656
+ "body": "`b.cwt.sign` now takes either a plain object (string keys, standard claims mapped by name) or a `Map`, which preserves integer claim keys verbatim — profiles like `b.eat` resolve their claim names to integer labels and pass them through without the keys being stringified. The plain-object path is unchanged."
657
+ }
658
+ ]
659
+ }
660
+ ]
661
+ },
662
+ {
663
+ "version": "0.12.34",
664
+ "date": "2026-05-24",
665
+ "headline": "`b.cwt` — CBOR Web Token (RFC 8392) sign / verify over `b.cose`",
666
+ "summary": "A CWT is the CBOR-native counterpart to JWT — a signed claims set for constrained / IoT, FIDO attestation, and verifiable-credential contexts. `b.cwt` composes the v0.12.33 `b.cose` (COSE_Sign1 signature + mandatory algorithm allowlist) and v0.12.32 `b.cbor` (deterministic claims encoding) and layers the standard-claim handling on top: `sign` takes a friendly claims object, maps the standard claims to their RFC 8392 §3.1.1 integer labels (iss=1, sub=2, aud=3, exp=4, nbf=5, iat=6, cti=7), and signs; `verify` checks the COSE signature, decodes the claims, and enforces the time + identity claims — a passed `exp` (with clock-skew tolerance), a future `nbf`, and an `iss` / `aud` mismatch against the expected values are each refused. Signing algorithms follow `b.cose`: classical ES256/384/512 + EdDSA (final COSE ids, interoperable today) and ML-DSA-87 (PQC-forward). RFC 8392 is a finalized standard, so CWTs produced here interoperate with other COSE/CWT implementations.",
667
+ "sections": [
668
+ {
669
+ "heading": "Added",
670
+ "items": [
671
+ {
672
+ "title": "`b.cwt.sign(claims, opts)` / `b.cwt.verify(cwt, opts)`",
673
+ "body": "`sign` maps standard claim names to integer labels and keeps custom claims verbatim; `exp` / `nbf` / `iat` must be non-negative integer NumericDates. `opts.tagged` wraps the COSE_Sign1 in the CWT CBOR tag 61 (RFC 8392 §6); `verify` accepts tagged or bare input. `verify` returns `{ claims, raw, alg, protectedHeaders }` — `claims` is the friendly object (labels mapped back to names), `raw` the integer-keyed Map. Standard-claim enforcement: `exp` past `now + clockSkewSec` (default 60s) is refused with `cwt/expired`, `nbf` beyond `now - skew` with `cwt/not-yet-valid`, and `expectedIssuer` / `expectedAudience` mismatches with `cwt/issuer-mismatch` / `cwt/audience-mismatch` (aud may be a single value or an array). `opts.now` overrides the clock for testing. The signature itself is verified by `b.cose.verify`, so a tampered token fails there."
674
+ }
675
+ ]
676
+ }
677
+ ]
678
+ },
679
+ {
680
+ "version": "0.12.33",
681
+ "date": "2026-05-24",
682
+ "headline": "`b.cose` — COSE_Sign1 sign / verify (RFC 9052) over the in-tree CBOR codec",
683
+ "summary": "COSE is the signed-statement substrate under SCITT, CWT, and C2PA — the CBOR-native counterpart to JWS. `b.cose` ships COSE_Sign1 signing and verification composing the v0.12.32 `b.cbor` codec for the deterministic Sig_structure encoding. It signs with the classical COSE algorithms that interoperate today — ES256 / ES384 / ES512 (ECDSA) and EdDSA (Ed25519), all with final IANA algorithm ids (RFC 9053) — and with ML-DSA-87 (FIPS 204) for PQC-forward deployments. Verification accepts the same set, so the framework both produces COSE other implementations read today and consumes third-party COSE. There is no classical default: the caller names the algorithm and supplies the key.",
684
+ "sections": [
685
+ {
686
+ "heading": "Added",
687
+ "items": [
688
+ {
689
+ "title": "`b.cose.sign(payload, opts)` / `b.cose.verify(coseSign1, opts)`",
690
+ "body": "`sign` produces a tagged COSE_Sign1 with `alg` in the integrity-protected header; `verify` returns `{ payload, alg, protectedHeaders, unprotectedHeaders }`. The Sig_structure (`[\"Signature1\", protected, external_aad, payload]`) is deterministically CBOR-encoded; ECDSA signatures use the IEEE-P1363 fixed-width encoding COSE mandates (RFC 9053 §2.1), not ASN.1 DER. `external_aad` is bound into the signature. v1 is single-signer with an attached payload; detached payload, COSE_Sign (multi-signer), COSE_Mac0, and COSE_Encrypt are deferred-with-condition (operator demand)."
691
+ }
692
+ ]
693
+ },
694
+ {
695
+ "heading": "Security",
696
+ "items": [
697
+ {
698
+ "title": "Bounded, alg-allowlisted, crit-checked verification",
699
+ "body": "`verify` decodes the COSE_Sign1 bytes AND the protected-header bstr through the bounded `b.cbor.decode` (depth + size caps, indefinite-length / tag / duplicate-key refusal). `opts.algorithms` is a required allowlist (no defaults — name the accepted algorithms). A `crit` header (label 2) listing a header label the verifier does not understand is refused (RFC 9052 §3.1 crit-bypass defense), as is a `crit` label absent from the protected header. The COSE algorithm switch refuses any unrecognized id at the default branch."
700
+ },
701
+ {
702
+ "title": "ML-DSA-87 COSE algorithm id is a non-final draft",
703
+ "body": "ML-DSA-87 uses COSE algorithm id `-50`, a requested (non-final) IANA assignment from draft-ietf-cose-dilithium — an ML-DSA-87 COSE_Sign1 is not yet broadly interoperable and the id may change; it is pinned deliberately with the re-open condition being IANA finalization. SLH-DSA-SHAKE-256f has no registered COSE algorithm id at all and cannot be represented in COSE. The COSE_Sign1 mechanism and the classical algorithms are stable; ML-DSA-87 is the forward-looking opt-in."
704
+ }
705
+ ]
706
+ }
707
+ ]
708
+ },
709
+ {
710
+ "version": "0.12.32",
711
+ "date": "2026-05-24",
712
+ "headline": "`b.cbor` — bounded, deterministic in-tree CBOR codec (RFC 8949)",
713
+ "summary": "CBOR is the binary serialization underneath COSE (RFC 9052), CWT, SCITT, and WebAuthn attestation — a foundational substrate the framework needs in-tree to build signed-statement primitives without a third-party parser. `b.cbor` is that codec, bounded by default like every parser the framework ships: a binary decoder is attack surface, so the defaults refuse the shapes a hostile encoder uses to exhaust memory or stack. The encoder emits Deterministically Encoded CBOR (RFC 8949 §4.2) — shortest-form heads, definite lengths, map keys sorted by encoded bytes, no indefinite-length items — so two semantically-equal values encode to byte-identical output, the property COSE signatures and SCITT receipts depend on.",
714
+ "sections": [
715
+ {
716
+ "heading": "Added",
717
+ "items": [
718
+ {
719
+ "title": "`b.cbor.encode(value, opts?)` / `b.cbor.decode(buffer, opts?)` / `b.cbor.Tag`",
720
+ "body": "`encode` produces deterministic CBOR from numbers (integers + float64), bigint (64-bit range), strings, `Buffer` / `Uint8Array`, arrays, `Map` or plain objects, `b.cbor.Tag`, and the simple values. `decode` returns the value with maps decoded to a `Map` (CBOR keys may be integers — COSE header labels are) and byte strings to `Buffer`. `b.cbor.Tag(tag, value)` carries a major-type-6 tagged item. `decode(buf, { requireDeterministic: true })` additionally asserts the input was itself canonically encoded (decode → re-encode → byte-compare), refusing a non-canonical re-encoding on a signature-verify path where it would be a malleability vector."
721
+ }
722
+ ]
723
+ },
724
+ {
725
+ "heading": "Security",
726
+ "items": [
727
+ {
728
+ "title": "Bounded-by-default decoder",
729
+ "body": "`maxDepth` (default 64, ceiling 256) caps nesting against stack exhaustion; `maxBytes` (default 16 MiB, ceiling 64 MiB) caps total input, and a declared string / array / map length exceeding the remaining bytes is refused before any allocation (no length-prefix memory bomb). Indefinite-length items (additional-info 31) are refused — a streaming-complexity / DoS vector forbidden by deterministic encoding. Reserved additional-info (28–30) is refused. Tags are refused unless allowlisted via `allowedTags` (a tag triggers semantic reprocessing — an un-vetted tag is a confused-deputy vector). Duplicate map keys (RFC 8949 §5.6) and trailing bytes after the data item are refused."
730
+ }
731
+ ]
732
+ }
733
+ ]
734
+ },
735
+ {
736
+ "version": "0.12.31",
737
+ "date": "2026-05-24",
738
+ "headline": "`b.auth.jar.parse` — verify RFC 9101 JWT-Secured Authorization Requests (server side)",
739
+ "summary": "A plain OAuth authorization request carries its parameters in the URL query string, where a browser, proxy, or referer log can tamper with or leak them. RFC 9101 JAR packs those parameters into a JWT the client signs — the request object — so the authorization server can confirm they arrived exactly as sent. `b.auth.jar.parse(jar, opts)` is the server-side verifier and the request-side counterpart to the existing JARM response handling (`b.auth.oauth.parseJarmResponse`). It delegates the signature check to `b.auth.jwt.verifyExternal` — which already enforces a mandatory `algorithms` allowlist and refuses the alg-confusion (`alg: \"none\"`, HMAC-vs-RSA) and JWE-on-a-JWS-verifier shapes against a JWKS public-key trust source — then pins `iss` and the `client_id` claim to the expected client, pins `aud` to this server's issuer identifier, refuses a nested `request` / `request_uri` (RFC 9101 §6.3 recursion / confused-deputy vector), and returns the authorization parameters with the JWT envelope claims stripped.",
740
+ "sections": [
741
+ {
742
+ "heading": "Added",
743
+ "items": [
744
+ {
745
+ "title": "`b.auth.jar.parse(jar, opts)` — request-object verification",
746
+ "body": "`opts.clientId` (the expected client — pins `iss` + the `client_id` claim), `opts.audience` (this server's issuer identifier — pins `aud`), `opts.algorithms` (required signature allowlist — no defaults, the alg-confusion defense), and one of `opts.jwks` / `opts.jwksUri` / `opts.keyResolver` (the client's verification key). Returns `{ params, claims }` where `params` is the authorization parameters (`response_type`, `redirect_uri`, `scope`, `state`, `nonce`, …) with the JWT envelope claims (`iss`, `aud`, `exp`, `iat`, `nbf`, `jti`) removed. A request object whose `client_id` claim disagrees with `opts.clientId`, or that nests a `request` / `request_uri`, is refused. Emitting a request object (the client side) is deferred-with-condition: it requires signing with the client's key under a classical JWS algorithm, and the framework's own JWT signer is PQC-only for the tokens it issues — a PQC-signed request object would not interoperate with a standard authorization server; client-side emission re-opens when a classical JWS signer lands or operators surface the need. Until then clients sign request objects with their existing JOSE tooling."
747
+ }
748
+ ]
749
+ }
750
+ ]
751
+ },
752
+ {
753
+ "version": "0.12.30",
754
+ "date": "2026-05-24",
755
+ "headline": "`bundleAdapterStorage.keyRotation(opts)` — verified whole-repository envelope key rotation",
756
+ "summary": "Rotating the key that wraps a backup repository is only safe if you can prove every bundle still reads under the new key — a rotation that silently corrupts one bundle is a time-bomb the operator discovers at restore time, exactly when they can least afford it. `storage.keyRotation(opts)` rotates every bundle's envelope from the old key to the new key (composing `rewrapAllBundles`) and then re-reads every bundle under the NEW key (composing `verifyAllBundles`), so a bad rotation surfaces as `verifyFailed > 0` immediately instead of at restore. It emits a `backup/key-rotated` audit event with the rotation id + per-status counts — a key-rotation event is a compliance record (SOC 2 CC6.1, PCI DSS 3.6.4) operators wire into their signed audit chain. Works for both `recipient` (hybrid PQC envelope) and `passphrase` (Argon2id) storage; refused cleanly on plaintext (`cryptoStrategy: \"none\"`) storage and when the new key is missing.",
757
+ "sections": [
758
+ {
759
+ "heading": "Added",
760
+ "items": [
761
+ {
762
+ "title": "`bundleAdapterStorage.keyRotation(opts)` — rotate then prove",
763
+ "body": "`opts.newRecipient` / `opts.newPassphrase` is the key bundles rotate TO (matched to the storage's `cryptoStrategy`); `opts.oldRecipient` / `opts.oldPassphrase` unwraps the current envelope when it differs from the configured key. Returns `{ rotationId, rotatedAt, total, rotated, skipped, failed, verified, verifyFailed, rotateResults, verifyResults }`. `opts.verify` (default true) runs the post-rotation read-back under the new key; `opts.concurrency` / `opts.stopOnFirstFailure` forward to the batch passes. Plaintext bundles + non-wrappable formats are skipped cleanly; a rotation that leaves any bundle unreadable reports `verifyFailed > 0` and emits the audit event with `outcome: \"failure\"`. A true overlap window where BOTH the old and new key decrypt a bundle (`dualWrap: true`) is refused with `backup/dual-wrap-unsupported` — it needs multi-recipient archive envelopes `b.archive.wrap` does not yet emit, and re-opens when the wrap layer gains them; until then stage a rotation by keeping the old key available to readers until `keyRotation` reports `failed: 0` + `verifyFailed: 0`, then retire it."
764
+ }
765
+ ]
766
+ }
767
+ ]
768
+ },
769
+ {
770
+ "version": "0.12.29",
771
+ "date": "2026-05-24",
772
+ "headline": "`b.ai.dp` — float-safe differential privacy: snapping-mechanism Laplace + discrete Gaussian + Rényi-DP budgets",
773
+ "summary": "Differential privacy adds calibrated noise so an aggregate is provably insensitive to any single record — but the guarantee is fragile: Mironov (2012) showed that a Laplace mechanism sampled with naive double-precision floats lets an attacker distinguish neighbouring datasets with > 35% probability from a single output, silently destroying the promise. `b.ai.dp` ships only mechanisms whose sampling is hardened against that attack class: Laplace via the snapping mechanism (clamp + CSPRNG sign + full-mantissa uniform + power-of-two-grid rounding) and the discrete Gaussian (Canonne–Kamath–Steinke 2020) via integer-exact rejection sampling built from Bernoulli(exp(−γ)) over exact rationals — no floating-point noise at all. All randomness comes from `b.crypto.generateBytes` (SHAKE256 over the OS CSPRNG), never `Math.random`. `b.ai.dp.budget({ scope, epsilon, delta })` tracks a privacy budget per scope and refuses a `consume` that would exceed it, accounting composition either by basic summation (default) or a Rényi-DP accountant (Mironov 2017) for a much tighter bound under repeated Gaussian releases. NIST SP 800-226 (2025) is the evaluation standard; Dwork & Roth is the canonical reference. The exponential and sparse-vector mechanisms are deferred-with-condition — their float-safe constructions (base-2 / permute-and-flip; snapped SVT) re-open on operator demand, since shipping them float-unsafe would defeat the module's purpose.",
774
+ "sections": [
775
+ {
776
+ "heading": "Added",
777
+ "items": [
778
+ {
779
+ "title": "`b.ai.dp.mechanism({ type, sensitivity, epsilon, ... })` — float-safe noise mechanisms",
780
+ "body": "`type: \"laplace\"` is the snapping mechanism (pure ε-DP, real-valued, requires a clamp `bound` the guarantee depends on); `type: \"gaussian\"` is the discrete Gaussian (integer-valued, (ε, δ)-DP, requires `delta`). The Gaussian uses the classic calibration σ = √(2 ln(1.25/δ))·Δ/ε, proven for ε ≤ 1 — larger ε is refused with a pointer to splitting the release under an rdp budget. Descriptors are validated + frozen at construction so a malformed parameter fails fast."
781
+ },
782
+ {
783
+ "title": "`b.ai.dp.budget({ scope, epsilon, delta, accounting })` — per-scope privacy budget",
784
+ "body": "Returns `{ consume, remaining, spent, reset }`. `consume(mechanism, value)` adds the mechanism's noise, charges the accountant, and throws `aiDp/budget-exhausted` if the release would push the scope past its (ε, δ). `accounting: \"basic\"` (default) sums per-release ε and δ; `accounting: \"rdp\"` runs a Rényi-DP accountant across a grid of orders and converts to (ε, δ) at the scope's δ for a tight composition bound under repeated Gaussian releases (requires `delta > 0`). The scope budget is enforced on both ε and δ independently."
785
+ }
786
+ ]
787
+ },
788
+ {
789
+ "heading": "Security",
790
+ "items": [
791
+ {
792
+ "title": "`b.crypto.generateBytes` uniformity fix at 1-byte length",
793
+ "body": "Node's SHAKE256 XOF is non-uniform at `outputLength: 1` — the byte values 0x00 and 0xff never occur and the low bit skews to ~0.54. `b.crypto.generateBytes(1)` (and the underlying `random(1)`) now draws at least 2 bytes and slices, so a single-byte CSPRNG request is uniform. Surfaced by `b.ai.dp` per-byte noise sampling; any per-byte consumer of `generateBytes` inherits the fix. A regression test asserts 0x00 / 0xff occur and the low bit is balanced."
794
+ }
795
+ ]
796
+ }
797
+ ]
798
+ },
799
+ {
800
+ "version": "0.12.28",
801
+ "date": "2026-05-24",
802
+ "headline": "`b.ai.capability` — model-capability registry + cheapest-satisfying-model router",
803
+ "summary": "`b.ai.capability.create({ models })` turns a fleet of AI model descriptors into a routing decision: given a set of requirements (context window, input/output modalities, tool use, structured output, reasoning tier, citation support, prompt-caching size), it picks the cheapest model that satisfies all of them. NIST AI RMF (AI 100-1) MAP 2.x requires documenting each model's capabilities and limitations; the Model Cards convention (Mitchell et al., 2019) formalizes that descriptor — this primitive makes the descriptor actionable. Routing to the cheapest sufficient model is a front-line defense against over-provisioning spend and composes directly with `b.ai.quota`'s `cost-usd` dimension (the chosen descriptor's rate feeds the budget charge); refusing to route a request to a model that cannot satisfy it (missing modality, too-small context window, no tool use) catches a capability mismatch before the inference call burns tokens on a guaranteed-bad result. Cost ranking uses a supplied `costBasis` (`{ inputTokens, outputTokens }`) for real per-call spend, else the sum of the per-1k rates; ties break by model id so the choice is deterministic across calls and nodes.",
804
+ "sections": [
805
+ {
806
+ "heading": "Added",
807
+ "items": [
808
+ {
809
+ "title": "`b.ai.capability.create({ models })` — capability registry + router",
810
+ "body": "Returns `{ describe, list, register, satisfies, route }`. A descriptor carries `maxContextTokens`, `maxOutputTokens`, `modalitiesIn` / `modalitiesOut` (arrays), `toolUse`, `structuredOutput`, `fineTunable`, `reasoningTier` (`none` / `basic` / `standard` / `advanced`, ordered), `citationSupport`, `promptCachingMaxTokens`, and the cost rates `costPer1kInputTokens` / `costPer1kOutputTokens`. Descriptors are validated + frozen at registration so a typo (negative cost, unknown reasoning tier, non-array modality list) surfaces at config time rather than as a silent mis-route. `describe(modelId)` returns the frozen descriptor; `register(modelId, descriptor)` adds or replaces one at runtime."
811
+ },
812
+ {
813
+ "title": "`route({ requirements, fallback?, costBasis? })` — cheapest-satisfying selection",
814
+ "body": "Collects every model whose descriptor satisfies all requirements, then returns the cheapest (`{ modelId, descriptor, estimatedCost, reason }`). Requirements: `minContextTokens`, `minOutputTokens`, `modalitiesIn` / `modalitiesOut` (model must support every listed modality), `toolUse`, `structuredOutput`, `fineTunable`, `minReasoningTier` (tier ordering — `standard` is met by `standard` or `advanced`), `citationSupport`, `minPromptCachingTokens`. When no model matches, `fallback` (a registered model id) is returned with `reason: \"fallback\"`, or the call refuses with `aiCapability/no-candidate` if no fallback was supplied. Routing decisions emit `ai/capability-routed` / `ai/capability-fallback` / `ai/capability-no-candidate` through the drop-silent audit chain."
815
+ },
816
+ {
817
+ "title": "`satisfies(modelId, requirements)` — precise capability-mismatch reasons",
818
+ "body": "Returns `{ ok, failures }` where each failure names the `requirement`, the `need`, and what the model `have`s — so a caller surfaces a precise reason (e.g. `minReasoningTier need advanced have basic`) instead of a bare boolean. Use it to explain a routing miss or to gate a request against a specific model before calling it."
819
+ }
820
+ ]
821
+ }
822
+ ]
823
+ },
824
+ {
825
+ "version": "0.12.27",
826
+ "date": "2026-05-24",
827
+ "headline": "`b.ai.quota` — per-tenant, per-model AI usage budgets with atomic consume-and-check",
828
+ "summary": "`b.ai.quota.create(opts)` builds an enforcer that caps AI inference usage per `(tenant, model, dimension, period)` and defends OWASP LLM Top 10 2025 LLM10 (Unbounded Consumption) — the class that includes denial-of-wallet, where an attacker drives a high volume of pay-per-use inferences until the bill itself is the attack. Meter by `tokens`, `requests`, `cost-usd`, or `compute-hours` over a calendar-aligned UTC window (`second` through `month`). `consume(tenant, model, amount)` is a single atomic check-and-charge: under the default `hard` enforcement it reserves the amount only if it fits under the ceiling, otherwise it refuses without charging — the limit test and the charge are one indivisible operation, so there is no charge-then-refund window for a concurrent call to observe. The in-memory counter is per-process; multi-node deployments supply an `opts.store` adapter whose `reserve` (an atomic conditional test-and-charge — a Redis Lua script, a SQL `UPDATE ... WHERE used + :amt <= :limit RETURNING used`) and `add` are atomic on the shared backend to enforce one aggregate ceiling across the cluster without false denials under contention. Limit resolution is most-specific-first: `perTenantModel` over `perTenant` over `perModel` over the default `limit`; tenant and model identifiers are percent-encoded into the counter key so a hostile tenant name cannot collide with another tenant's budget.",
829
+ "sections": [
830
+ {
831
+ "heading": "Added",
832
+ "items": [
833
+ {
834
+ "title": "`b.ai.quota.create(opts)` — per-tenant AI usage-budget enforcer",
835
+ "body": "Returns `{ consume, check, snapshot, reset }` scoped to one `dimension` (`tokens` / `requests` / `cost-usd` / `compute-hours`) and one `period` (`second` / `minute` / `hour` / `day` / `week` (Monday-aligned) / `month` (1st-of-month), all UTC-aligned). `consume(tenant, model, amount, opts?)` returns `{ used, limit, remaining, allowed, exceeded, windowStart, resetsAt, ... }`. `check(tenant, model)` is the read-only snapshot. Spin up one enforcer per dimension you meter — a monthly `cost-usd` budget and a per-minute `tokens` burst cap coexist as two `create()` calls sharing one store. Defends OWASP LLM10:2025 Unbounded Consumption / denial-of-wallet; maps to NIST AI RMF (AI 100-1) MANAGE 2.x and EU AI Act Art. 15 (robustness / resource-exhaustion resilience)."
836
+ },
837
+ {
838
+ "title": "`hard` / `soft` / `warn` enforcement",
839
+ "body": "`hard` (default) refuses the over-budget call and throws `aiQuota/exceeded` without charging — the rejected reservation is refunded so the counter is untouched. `soft` admits the charge but reports `allowed: false` so the caller decides whether to honor it. `warn` admits and allows (advisory), flagging `exceeded: true`. A per-call `consume(..., { enforcement })` override lets one endpoint soften the mode for a trusted internal caller without a second enforcer. Every over-budget event emits `ai/quota-exceeded` through the drop-silent audit chain (`ai/quota-applied` on success), tagged with the active cluster node id for attribution."
840
+ },
841
+ {
842
+ "title": "Cross-node aggregate budgets via `opts.store`",
843
+ "body": "The default counter is in-memory (per-process). Supply `opts.store` exposing atomic `reserve` / `add` / `get` / `reset` (a Redis Lua script, a shared SQL row) and the ceiling is enforced on the cluster-wide aggregate. `hard` mode goes through `reserve`, an atomic conditional test-and-charge that adds the amount only if it fits — so a concurrent over-budget call cannot transiently inflate the counter and falsely deny a smaller call that should fit. Per-tenant and per-model limit overrides (`perTenant` / `perModel` / `perTenantModel`) are validated at config time so a malformed cap surfaces at boot, not as a silent fall-through to the default."
844
+ }
845
+ ]
846
+ }
847
+ ]
848
+ },
849
+ {
850
+ "version": "0.12.26",
851
+ "date": "2026-05-24",
852
+ "headline": "`b.compliance` posture cascades — `eu-ai-act` + `ca-ab-853` + `cac-genai-label` POSTURE_DEFAULTS + backup encryption refusal",
853
+ "summary": "Three new posture cascades wired into `b.compliance.POSTURE_DEFAULTS` + `KNOWN_POSTURES` + `REGIME_MAP` so operators globally pinning the EU AI Act / California AB-853 / China CAC GenAI postures get the right floors automatically: backupEncryptionRequired:true, auditChainSignedRequired:true, tlsMinVersion:TLSv1.3, requireVacuumAfterErase:true. `b.backup.bundleAdapterStorage` extends the encryption-required posture list to include the three new postures so `cryptoStrategy: \"none\"` is refused upfront under any of them (parity with HIPAA + PCI-DSS, which the operator surface has carried since v0.12.10). The canonical `eu-ai-act` posture is the production name; the legacy `ai-act` short name stays in KNOWN_POSTURES for back-compat with operators who pinned it pre-v0.12.26.",
854
+ "sections": [
855
+ {
856
+ "heading": "Added",
857
+ "items": [
858
+ {
859
+ "title": "`eu-ai-act` posture cascade — Regulation (EU) 2024/1689",
860
+ "body": "POSTURE_DEFAULTS entry: backupEncryptionRequired:true (Art. 12 logging + Art. 15 robustness/cybersecurity demand encryption-at-rest for high-risk system training logs), auditChainSignedRequired:true (Art. 12 + Art. 13 audit-chain integrity), tlsMinVersion:TLSv1.3, requireVacuumAfterErase:true (Art. 50(4) synthetic-content provenance — residual EXIF / metadata pointing at the generating model must be cleared on erase). REGIME_MAP entry under jurisdiction:\"EU\" domain:\"ai-governance\". KNOWN_POSTURES carries both `eu-ai-act` (canonical) and `ai-act` (legacy short name)."
861
+ },
862
+ {
863
+ "title": "`ca-ab-853` posture cascade — California AB-853 effective 2026",
864
+ "body": "Same encryption + audit floor as eu-ai-act; jurisdiction:\"US-CA\". Model-generated content watermarking + disclosure regime. Operators serving California traffic pin this posture for the AB-853 §22949.91 obligations the v0.12.12 deepfake primitive's crossWalk references."
865
+ },
866
+ {
867
+ "title": "`cac-genai-label` posture cascade — China CAC GenAI Service Measures",
868
+ "body": "Synthetic-content labelling per Art. 12 + algorithm filing per Art. 4. Same backup encryption + signed audit chain floor. Operators serving Chinese traffic pin this posture so the bundleAdapterStorage refuses plaintext bundles and the disclosure primitive's `jurisdiction: \"cn\"` cross-walk produces the right legal-reference array."
869
+ },
870
+ {
871
+ "title": "`bundleAdapterStorage` BACKUP_ENCRYPTION_REQUIRED_POSTURES extended",
872
+ "body": "`hipaa` + `pci-dss` (the v0.12.10 baseline) joined by the three AI postures. `cryptoStrategy: \"none\"` refused upfront under any of `eu-ai-act` / `ca-ab-853` / `cac-genai-label` with `backup/posture-requires-encryption`. Operators wiring backup storage in a regulated AI deployment now get the same posture-driven gate that the storage primitive has always applied to health + payment data."
873
+ }
874
+ ]
875
+ }
876
+ ]
877
+ },
878
+ {
879
+ "version": "0.12.25",
880
+ "date": "2026-05-24",
881
+ "headline": "`b.ai.disclosure.applyAll(scenario)` — bundle Art. 50(1) / 50(3) / 50(4) disclosures for mixed-modality AI systems",
882
+ "summary": "Composes the three v0.12.12 disclosure primitives (chatbot / deepfake / emotion) into a single bundled emit. Operators running mixed-modality AI systems (e.g. a chatbot that also generates images, or an emotion-recognition system embedded in a chat flow) declare which Art. 50 obligations apply via `scenario.kinds` and the primitive fans out to the per-obligation emit calls in one pass. Shared opts (jurisdiction, language, audit, correlationId) propagate to every per-kind emission so the cross-walk + audit-chain entries stay correlated across the bundle.",
883
+ "sections": [
884
+ {
885
+ "heading": "Added",
886
+ "items": [
887
+ {
888
+ "title": "`b.ai.disclosure.applyAll(scenario)` — multi-obligation bundled emit",
889
+ "body": "`scenario.kinds: [\"chatbot\", \"deepfake\", \"emotion\"]` (subset) selects which Art. 50 obligations to satisfy. Per-kind required fields (session for chatbot, content + contentType for deepfake) refused upfront when missing. Returns `{ disclosures: { chatbot?, deepfake?, emotion? } }` with each entry being the corresponding primitive's emission payload. Shared opts propagate: `scenario.jurisdiction` / `scenario.language` / `scenario.audit` / `scenario.correlationId` reach every per-kind call so a US-CA deployment serving chat + image gets both the AB-853 cross-walk AND the Art. 50(1) audit event under the same correlationId."
890
+ }
891
+ ]
892
+ }
893
+ ]
894
+ },
895
+ {
896
+ "version": "0.12.24",
897
+ "date": "2026-05-24",
898
+ "headline": "`bundleAdapterStorage.findBundles(predicate, opts?)` — predicate-based filtering over listBundles entries",
899
+ "summary": "Small helper that composes with listBundles for operators wanting to filter the bundle set without hand-rolling the walk. `storage.findBundles(predicate, opts?)` iterates listBundles + returns entries where `predicate(entry)` is truthy. Predicate sees the listBundles entry shape (`{ bundleId, format, createdAt, size }`); `opts.withStats: true` enables `createdAt` + `size` for predicates that need them. Common operator filters — by format (`b => b.format === \"tar.gz\"`), by age (`b => Date.parse(b.createdAt) < cutoff`), by size — now read as a single call.",
900
+ "sections": [
901
+ {
902
+ "heading": "Added",
903
+ "items": [
904
+ {
905
+ "title": "`storage.findBundles(predicate, opts?)` — predicate-based bundle filter",
906
+ "body": "Operator-supplied predicate runs against every listBundles entry; matches accumulate into the returned array. `opts.withStats: true` is forwarded to listBundles so predicates relying on `createdAt` / `size` see populated values. Non-function predicate refused upfront with `backup/bad-arg`. Predicate throws bubble up to the caller (operators see their own filter errors, not swallowed). Stable ordering is whatever listBundles produces (reverse-chronological by bundleId)."
907
+ }
908
+ ]
909
+ }
910
+ ]
911
+ },
912
+ {
913
+ "version": "0.12.23",
914
+ "date": "2026-05-24",
915
+ "headline": "`bundleAdapterStorage.cloneBundle(src, dst, opts?)` — same-storage byte-verbatim bundle clone for pre-rotation snapshots",
916
+ "summary": "`storage.cloneBundle(srcBundleId, dstBundleId, opts?)` copies a bundle's adapter payload (bundle.tar / bundle.tar.gz / every directory key) from src to dst WITHOUT touching the envelope or inner archive. Encrypted bundles are cloned byte-verbatim — the new bundleId carries the same envelope under the same recipient/passphrase. Operators preserving a known-good snapshot before a destructive operation (rewrap, key rotation, schema migration, manual operator-side editing) get a single-call atomic clone instead of a manual readBundle → writeBundle cycle (which would re-encode through the envelope and adapter contracts, breaking byte-identity).",
917
+ "sections": [
918
+ {
919
+ "heading": "Added",
920
+ "items": [
921
+ {
922
+ "title": "`storage.cloneBundle(src, dst, opts?)` — byte-verbatim payload clone",
923
+ "body": "Reads the source bundle's storage keys + writes them under the destination bundleId without invoking the wrap layer, gunzip path, or tar walker. Encrypted bundles produce byte-identical clones (a tar.gz wrap-recipient envelope cloned via cloneBundle has bit-for-bit equal bytes to the source). Returns `{ srcBundleId, dstBundleId, format, keysCopied, bytesCopied }`. `opts.overwrite` (default false) gates whether to refuse if dstBundleId already exists. Same-id clones refused upfront with `backup/clone-same-id`."
924
+ }
925
+ ]
926
+ }
927
+ ]
928
+ },
929
+ {
930
+ "version": "0.12.22",
931
+ "date": "2026-05-24",
932
+ "headline": "`bundleAdapterStorage.rewrapAllBundles(opts)` — bounded-parallel batch envelope rotation with mixed-storage skip semantics",
933
+ "summary": "Batch wrapper over the v0.12.21 rewrapBundle primitive. `storage.rewrapAllBundles(opts?)` iterates `listBundles()` + rotates each bundle's wrap envelope through a bounded-parallel pool (default 4 workers). Plaintext bundles + directory-format bundles get skipped cleanly (recorded as `status: \"skipped\"` with a `reason` field); rewrap failures get bucketed into `status: \"failed\"`. Operators completing a key-rotation event across an entire backup repository now have a single call that handles mixed-strategy storage correctly. `opts.newRecipient` / `opts.newPassphrase` / `opts.oldRecipient` / `opts.oldPassphrase` / `opts.concurrency` / `opts.stopOnFirstFailure` mirror the verifyAllBundles + rewrapBundle surface.",
934
+ "sections": [
935
+ {
936
+ "heading": "Added",
937
+ "items": [
938
+ {
939
+ "title": "`storage.rewrapAllBundles(opts?)` — batch envelope rotation",
940
+ "body": "Iterates listBundles() + dispatches each bundle through rewrapBundle with the operator-supplied new key. Returns `{ total, rotated, skipped, failed, results }` where the per-bundle results carry `{ status: \"rotated\" | \"skipped\" | \"failed\", oldEnvelopeKind, newEnvelopeKind, reason }`. Bounded-parallel fan-out (default 4) keeps the storage backend under control; opts.stopOnFirstFailure short-circuits on the first rotation that throws an unexpected error (skips don't trip the short-circuit — they're expected for mixed-strategy storage). Plaintext + directory bundles skipped with `reason: \"format-not-wrappable\"` / `reason: \"no-envelope\"` rather than reported as failures."
941
+ }
942
+ ]
943
+ }
944
+ ]
945
+ },
946
+ {
947
+ "version": "0.12.21",
948
+ "date": "2026-05-24",
949
+ "headline": "`bundleAdapterStorage.rewrapBundle(bundleId, opts)` — key rotation without restore + rewrite of inner archive bytes",
950
+ "summary": "`storage.rewrapBundle(bundleId, opts?)` rotates a bundle's wrap envelope under a new recipient keypair or passphrase WITHOUT touching the inner tar / tar.gz bytes. Operators rotating a compromised keypair, migrating to a new HSM, or refreshing passphrases on a HIPAA-posture repository previously had to `readBundle` → write to a stage dir → `writeBundle` under the new key — three byte-walks of the bundle payload, two filesystem touches, transient plaintext on disk. rewrapBundle does it as unwrap + rewrap in memory: zero disk plaintext, one round-trip through the wrap layer, the gzipped tar archive bytes inside the envelope are never inflated. Cross-kind rotation (recipient ↔ passphrase) is refused — that's a separate migration the operator configures with explicit cryptoStrategy switch.",
951
+ "sections": [
952
+ {
953
+ "heading": "Added",
954
+ "items": [
955
+ {
956
+ "title": "`storage.rewrapBundle(bundleId, opts?)` — in-place envelope rotation",
957
+ "body": "Unwraps the bundle under the old key (storage's configured recipient/passphrase OR `opts.oldRecipient` / `opts.oldPassphrase`), re-wraps under the new key (`opts.newRecipient` / `opts.newPassphrase`), writes the rewrapped bytes back to the same storage key. Returns `{ bundleId, oldEnvelopeKind, newEnvelopeKind, bytesRewritten }`. Plaintext bundles refused with `backup/no-envelope-to-rewrap`; cross-kind rotation refused with `backup/no-new-recipient` / `backup/no-new-passphrase`. The inner archive bytes (the gz-compressed tar payload) are never decompressed or re-encoded — rewrap is a wrap-layer-only operation."
958
+ }
959
+ ]
960
+ },
961
+ {
962
+ "heading": "Security",
963
+ "items": [
964
+ {
965
+ "title": "Zero plaintext on disk during rotation",
966
+ "body": "The inner tar bytes flow only through memory — old-envelope unwrap → new-envelope wrap → adapter writeFile. Operators previously rotating via readBundle + writeBundle wrote plaintext archive bytes to a temporary stage directory; rewrapBundle removes that exposure window entirely. Matches the operator-side discipline that backup payloads should never land on disk in plaintext form during steady-state operations."
967
+ }
968
+ ]
969
+ }
970
+ ]
971
+ },
972
+ {
973
+ "version": "0.12.20",
974
+ "date": "2026-05-24",
975
+ "headline": "`bundleAdapterStorage.verifyAllBundles(opts)` — bounded-parallel batch integrity walk with stopOnFirstFailure short-circuit",
976
+ "summary": "Batch wrapper over the v0.12.19 verifyBundle primitive. `storage.verifyAllBundles(opts?)` iterates `listBundles()` + walks each bundle with a bounded-parallel pool. Returns `{ total, ok, failed, results }` where `results` carries every per-bundle verifyBundle output (including bundleId, format, envelopeKind, entryCount, errors). `opts.concurrency` defaults to 4 (gentle on the storage backend); `opts.stopOnFirstFailure` short-circuits the walk when an unhealthy bundle is found (default off — operators want the full health report). `opts.recipient` / `opts.passphrase` forwarded to every per-bundle verify call. Operators wiring a periodic cron job over a backup repository now have a single primitive to call instead of hand-rolling the listBundles loop.",
977
+ "sections": [
978
+ {
979
+ "heading": "Added",
980
+ "items": [
981
+ {
982
+ "title": "`storage.verifyAllBundles(opts?)` — batch integrity walk",
983
+ "body": "Iterates `listBundles()` + calls `verifyBundle(bundleId, opts)` on each. Bounded-parallel fan-out (default 4 workers; `opts.concurrency` raises or lowers); each worker pulls from a shared queue + the warm-up keeps `concurrency` workers in flight until the queue drains. Returns the aggregate `{ total, ok, failed, results }` with `results` sorted by bundleId so the report is stable across runs regardless of completion order. `opts.stopOnFirstFailure` short-circuits the walk when the first unhealthy bundle is found — useful for fast-fail CI gates that don't need to enumerate every failure."
984
+ }
985
+ ]
986
+ }
987
+ ]
988
+ },
989
+ {
990
+ "version": "0.12.19",
991
+ "date": "2026-05-24",
992
+ "headline": "`bundleAdapterStorage.verifyBundle(bundleId)` — bundle integrity check without restore + `b.safeArchive.inspect` tar.gz support",
993
+ "summary": "`storage.verifyBundle(bundleId, opts?)` walks a bundle without restoring it: confirms the payload exists, the envelope (if any) decrypts under the supplied key, and the inner tar / tar.gz walker enumerates every entry without writing to disk. Returns `{ ok, format, envelopeKind, entryCount, errors }` — operators wanting periodic health-check of a backup repository call verifyBundle across `listBundles()` and aggregate `ok === false` results. Composes the bundle's known format directly through the unwrap → gunzip → tar pipeline (skips safeArchive's auto-sniff because the bundle's format is already known from bundleInfo). `b.safeArchive.inspect` separately gains `format: \"tar.gz\"` dispatch — operators with a known tar.gz payload now get entry enumeration without extracting.",
994
+ "sections": [
995
+ {
996
+ "heading": "Added",
997
+ "items": [
998
+ {
999
+ "title": "`storage.verifyBundle(bundleId, opts?)` — integrity check without restore",
1000
+ "body": "Composes bundleInfo for format/envelope detection + the unwrap → gunzip → tar walker chain directly. opts.recipient / opts.passphrase override the storage's configured keys (useful for verifying a bundle under a different key set than the storage was opened with — e.g. verifying that a key rotation candidate can still read a bundle). Wrong key / corrupted payload returns `ok: false` with a typed error code in the errors array rather than throwing. Directory format reports ok=true based on manifest existence + readability (the inspect walker doesn't apply)."
1001
+ },
1002
+ {
1003
+ "title": "`b.safeArchive.inspect` accepts `format: \"tar.gz\"`",
1004
+ "body": "Mirrors the v0.12.9 extract surface: operators handed a `.tar.gz` payload can now enumerate entries without extracting. Composes `b.archive.read.gz` + `.asTar()` internally so the same bomb caps apply (1 GiB output / 100× ratio default) to inspect calls."
1005
+ }
1006
+ ]
1007
+ }
1008
+ ]
1009
+ },
1010
+ {
1011
+ "version": "0.12.18",
1012
+ "date": "2026-05-24",
1013
+ "headline": "`bundleAdapterStorage.listBundles({ withStats })` + `bundleInfo.createdAt` — opt-in mtime + size from `statKey`",
1014
+ "summary": "Two additions on `b.backup.bundleAdapterStorage`. `listBundles({ withStats: true })` fans out `statKey` per bundle and populates `createdAt` (ISO string from mtimeMs) + `size` (bytes) on every entry. Without the opt the default fast path stays a single listKeys call — operators rendering a bundle picker UI choose between O(1) listings and O(N) stat-enriched listings explicitly. `bundleInfo(bundleId)` gains the same `createdAt` field for parity. fsAdapter + objectStoreAdapter both expose `statKey`; legacy adapters without the capability leave the fields null.",
1015
+ "sections": [
1016
+ {
1017
+ "heading": "Added",
1018
+ "items": [
1019
+ {
1020
+ "title": "`storage.listBundles({ withStats: true })` — opt-in per-bundle stat fan-out",
1021
+ "body": "When the adapter exposes `statKey`, populates `createdAt` (ISO string from mtimeMs) + `size` (bytes) per entry. Stat fan-out is O(N) round-trips so the opt is OFF by default — operators wanting cheap one-shot listings stay on `listBundles()`. Format precedence (tar.gz > tar > directory) carries through to which payload key gets stat'd."
1022
+ },
1023
+ {
1024
+ "title": "`storage.bundleInfo(bundleId)` returns `createdAt`",
1025
+ "body": "The bundle introspection primitive now returns `{ bundleId, format, envelopeKind, sizeBytes, createdAt }`. `createdAt` is the ISO string derived from `statKey.mtimeMs` (when the adapter exposes it) — null otherwise. Matches the listBundles+withStats shape so operators can use bundleInfo for single-bundle drill-downs without re-mapping field names."
1026
+ }
1027
+ ]
1028
+ }
1029
+ ]
1030
+ },
1031
+ {
1032
+ "version": "0.12.17",
1033
+ "date": "2026-05-23",
1034
+ "headline": "`bundleAdapterStorage.bundleInfo` + `listBundles.format` — per-bundle introspection for envelope kind + format without restore",
1035
+ "summary": "Two introspection additions on `b.backup.bundleAdapterStorage`. `listBundles()` now returns the inferred `format` (`\"tar\"` / `\"tar.gz\"` / `\"directory\"`) per bundle from the storage key suffix — no byte read. `storage.bundleInfo(bundleId)` returns `{ bundleId, format, envelopeKind, sizeBytes }` where `envelopeKind` is the result of a 5-byte magic probe (`\"recipient\"` / `\"passphrase\"` / `\"none\"`). Operators administering a multi-strategy backup repository can now filter bundles by encryption posture or by format without a full restore cycle.",
1036
+ "sections": [
1037
+ {
1038
+ "heading": "Added",
1039
+ "items": [
1040
+ {
1041
+ "title": "`listBundles()` carries inferred format per bundle",
1042
+ "body": "Each entry now includes `format` alongside `bundleId` / `createdAt` / `size`. Inference is from the storage key suffix the writeBundle path produced — `<bid>/bundle.tar` → tar, `<bid>/bundle.tar.gz` → tar.gz, anything else → directory. Cheap: no byte read, no per-key stat call. Operators rendering a bundle picker UI now sort + filter by format from a single list call."
1043
+ },
1044
+ {
1045
+ "title": "`storage.bundleInfo(bundleId)` — per-bundle introspection",
1046
+ "body": "Returns `{ bundleId, format, envelopeKind, sizeBytes }`. `format` from the storage layout (no byte read). `envelopeKind` from `b.archive.sniffEnvelope` over the bundle payload — `\"recipient\"` (BAWRP / v0.12.10 hybrid PQC), `\"passphrase\"` (BAWPP / v0.12.11 Argon2id), `\"none\"` (plaintext or directory format). `sizeBytes` is the payload byte count for tar / tar.gz; null for directory format (operator's per-file walk applies if exact size matters). Nonexistent bundles refused with `backup/bundle-not-found`."
1047
+ }
1048
+ ]
1049
+ }
1050
+ ]
1051
+ },
1052
+ {
1053
+ "version": "0.12.16",
1054
+ "date": "2026-05-23",
1055
+ "headline": "`b.safeArchive.inspect` auto-unwraps wrap envelopes (parallel to the v0.12.15 extract path)",
1056
+ "summary": "Mirrors the v0.12.15 auto-unwrap support into `b.safeArchive.inspect`. Operators enumerating entries of a sealed archive get a single inspect() call regardless of envelope shape — pass `opts.recipient` or `opts.passphrase` alongside `source` and the orchestrator unwraps inline before walking the inner format. Missing-key opt surfaces a structured `safe-archive/no-recipient-for-wrap` / `safe-archive/no-passphrase-for-wrap` refusal upfront. Carries the v0.12.15 P1 + P2 fixes (close original source before replacing + forward opts.signal to inner buffer adapter) into the inspect path so the same descriptor-leak + abort-propagation contracts hold.",
1057
+ "sections": [
1058
+ {
1059
+ "heading": "Added",
1060
+ "items": [
1061
+ {
1062
+ "title": "`b.safeArchive.inspect` auto-unwraps `BAWRP` + `BAWPP` envelopes",
1063
+ "body": "The orchestrator's `format: \"auto\"` sniffer recognises the wrap magics and routes through `b.archive.unwrap` / `b.archive.unwrapWithPassphrase` inline. After unwrap, the inner bytes are wrapped in a buffer adapter + re-sniffed; the resulting summary carries the INNER `format` (`\"tar\"` / `\"zip\"` / etc.) — operators querying `summary.format` see the carrier format, not `\"wrap-recipient\"`. Entry enumeration walks the inner archive after a single key-derivation pass; no temporary file lands on disk."
1064
+ }
1065
+ ]
1066
+ }
1067
+ ]
1068
+ },
1069
+ {
1070
+ "version": "0.12.15",
1071
+ "date": "2026-05-23",
1072
+ "headline": "`b.safeArchive.extract` auto-unwraps v0.12.10 recipient and v0.12.11 passphrase envelopes inline",
1073
+ "summary": "The safeArchive orchestrator's `format: \"auto\"` sniffer recognises `BAWRP` (v0.12.10 recipient) and `BAWPP` (v0.12.11 passphrase) envelope magics and routes through `b.archive.unwrap` / `b.archive.unwrapWithPassphrase` inline before re-sniffing the inner format. Operators pass `opts.recipient` (or `opts.passphrase`) alongside `source` + `destination` and get a single extract() call regardless of envelope shape. Missing the matching key opt surfaces a structured `safe-archive/no-recipient-for-wrap` / `safe-archive/no-passphrase-for-wrap` refusal upfront rather than a downstream crypto error.",
1074
+ "sections": [
1075
+ {
1076
+ "heading": "Added",
1077
+ "items": [
1078
+ {
1079
+ "title": "`b.safeArchive.extract` auto-unwraps wrap envelopes",
1080
+ "body": "The sniffer at byte 0-4 recognises `BAWRP` (returns `format: \"wrap-recipient\"`) and `BAWPP` (returns `format: \"wrap-passphrase\"`). The extract path collects the sealed adapter into a Buffer, routes through `b.archive.unwrap` (recipient) or `b.archive.unwrapWithPassphrase` (passphrase), wraps the inner bytes in a buffer adapter, re-sniffs the inner format, and dispatches to the appropriate `b.archive.read.*` reader. A wrap-around-tar.gz envelope round-trips through wrap → unwrap → gunzip → untar with no operator intervention beyond passing the key opt."
1081
+ }
1082
+ ]
1083
+ },
1084
+ {
1085
+ "heading": "Security",
1086
+ "items": [
1087
+ {
1088
+ "title": "Missing-key opt refused upfront with structured error",
1089
+ "body": "When the sniffer identifies a wrap envelope but the operator hasn't supplied `opts.recipient` (BAWRP) or `opts.passphrase` (BAWPP), extract refuses with `safe-archive/no-recipient-for-wrap` / `safe-archive/no-passphrase-for-wrap` BEFORE any decryption attempt. Operators wiring extract behind an HTTP boundary get a typed refusal instead of a leaked crypto-level error."
1090
+ }
1091
+ ]
1092
+ }
1093
+ ]
1094
+ },
1095
+ {
1096
+ "version": "0.12.14",
1097
+ "date": "2026-05-23",
1098
+ "headline": "`b.archive.sniffEnvelope(bytes)` — identify recipient vs passphrase vs raw payload without attempting decryption",
1099
+ "summary": "Small helper closing a gap in the archive-wrap surface. `b.archive.sniffEnvelope(bytes)` reads the first 5 bytes of a buffer and returns one of `\"recipient\"` (v0.12.10 BAWRP envelope), `\"passphrase\"` (v0.12.11 BAWPP envelope), or `\"none\"` (raw payload or unrelated bytes). The sniff does NO cryptographic work — no Argon2id round, no decapsulation, no allocation beyond a 5-byte ASCII compare — so it's safe to call on adversarial input. Operators dispatching between unwrap paths get a clean predicate instead of trial-decrypting under multiple key candidates.",
1100
+ "sections": [
1101
+ {
1102
+ "heading": "Added",
1103
+ "items": [
1104
+ {
1105
+ "title": "`b.archive.sniffEnvelope(bytes)` — magic-byte envelope identifier",
1106
+ "body": "Returns `\"recipient\"` (BAWRP / v0.12.10 hybrid PQC envelope), `\"passphrase\"` (BAWPP / v0.12.11 Argon2id + XChaCha20 envelope), or `\"none\"` (raw archive bytes / unrelated payload). Accepts Buffer + Uint8Array; non-buffer / null / undefined / empty-buffer inputs return `\"none\"` upfront. Operators wire the result into a switch that dispatches to the matching unwrap primitive — no trial decryption, no per-key candidate attempts."
1107
+ }
1108
+ ]
1109
+ }
1110
+ ]
1111
+ },
1112
+ {
1113
+ "version": "0.12.13",
1114
+ "date": "2026-05-23",
1115
+ "headline": "`b.backup.bundleAdapterStorage.objectStoreAdapter` — wraps any `b.objectStore` backend (local / SigV4 / GCS / Azure-Blob) into the backup-adapter contract; closes the v0.11.2 \"any custom backend\" promise",
1116
+ "summary": "`b.backup.bundleAdapterStorage.objectStoreAdapter(client, opts)` adapts a `b.objectStore`-shaped client into the `{ writeFile, readFile, listKeys, deleteKey, hasKey }` adapter contract that `bundleAdapterStorage` consumes. Operators wire any of the four shipped object-store backends (`protocol: \"local\"` / `\"sigv4\"` for S3+MinIO / `\"gcs\"` / `\"azure-blob\"`) through the same recipient / passphrase wrap layers shipped in v0.12.10 and v0.12.11 — the bundle bytes hit the object-store `put` as an opaque envelope. `opts.prefix` namespaces every key under a fixed root inside the bucket so operators sharing a bucket across deployments keep listings scoped. Closes the deferral surfaced in v0.11.2 JSDoc and the v0.12.10 release-notes follow-up list: \"S3 or any custom backend\" is now wired with no operator-supplied adapter glue.",
1117
+ "sections": [
1118
+ {
1119
+ "heading": "Added",
1120
+ "items": [
1121
+ {
1122
+ "title": "`b.backup.bundleAdapterStorage.objectStoreAdapter(client, opts?)` — object-store-backed bundle storage",
1123
+ "body": "Adapts any `b.objectStore.buildBackend({ protocol })` client (local / sigv4 / gcs / azure-blob) into the `{ writeFile, readFile, listKeys, deleteKey, hasKey }` adapter contract. NOT_FOUND errors from the underlying client translate to `backup/no-key` for the readFile path and to idempotent return-without-throw for deleteKey (matching the fsAdapter contract). hasKey routes through `client.head(key)` — NOT_FOUND → false; any other error propagates so operators can distinguish network failure from missing-key. Composes transparently with v0.12.10 `cryptoStrategy: \"recipient\"` and v0.12.11 `cryptoStrategy: \"passphrase\"` — the wrap envelope is the bytes hitting the object-store put."
1124
+ },
1125
+ {
1126
+ "title": "`opts.prefix` — per-deployment key namespacing inside the bucket",
1127
+ "body": "Operators sharing one bucket across multiple deployments (per-environment / per-tenant / per-region) pass distinct prefixes so listings stay scoped. The prefix gets a trailing slash inserted automatically; traversal segments (`..`) and NUL bytes refused upfront with `backup/bad-arg` so a misconfigured prefix can't escape the operator's intended scope. listKeys strips the prefix on return so the adapter surface looks identical to the fsAdapter — operators switching backends don't see key-shape drift."
1128
+ }
1129
+ ]
1130
+ },
1131
+ {
1132
+ "heading": "Security",
1133
+ "items": [
1134
+ {
1135
+ "title": "Key path validation — traversal + NUL byte refusal at every adapter call",
1136
+ "body": "Every `_scopedKey(key)` invocation refuses keys containing `..` traversal segments or NUL bytes upfront with `backup/bad-key` so a misconfigured bundleId or an attacker-controlled value never reaches the underlying `client.put(...)` / `client.get(...)`. Matches the same defensive posture the fsAdapter carries against operator-supplied key shapes."
1137
+ }
1138
+ ]
1139
+ }
1140
+ ]
1141
+ },
1142
+ {
1143
+ "version": "0.12.12",
1144
+ "date": "2026-05-23",
1145
+ "headline": "`b.ai.disclosure.chatbot` + `b.ai.disclosure.deepfake` + `b.ai.disclosure.emotion` — EU AI Act Art. 50 transparency obligations (calendar-locked 2026-08-02) with US-CA AB-853 + China CAC GenAI cross-walk",
1146
+ "summary": "EU AI Act Art. 50 transparency primitives land ahead of the 2026-08-02 enforcement deadline. `b.ai.disclosure.chatbot(session, opts)` emits the Art. 50(1) first-contact \"you are interacting with an AI system\" disclosure with placement control (`first-message` / `always` / `on-request`). `b.ai.disclosure.deepfake(content, { contentType, placement, jurisdiction })` emits the Art. 50(4) synthetic-content label + machine-readable metadata payload for image / audio / video / text. `b.ai.disclosure.emotion({ systemType })` emits the Art. 50(3) emotion-recognition / biometric-categorisation notice. Each primitive emits a tamper-evident `ai-act/*-disclosure-applied` audit event so the compliance trail backs the user-facing notice. Cross-jurisdiction cross-walk lives in `opts.jurisdiction`: `\"eu\"` (default), `\"us-ca\"` adds AB-853 §22949.91 to the cross-walk array, `\"cn\"` adds CAC GenAI Measures Art. 12. The deepfake primitive returns a `schema: \"c2pa-v1.4-ready\"` metadata field that the v0.12.21 `b.contentCredentials` C2PA adapter will consume when it lands — this patch ships the label markup + schema; the C2PA manifest emission is the next composition.",
1147
+ "sections": [
1148
+ {
1149
+ "heading": "Added",
1150
+ "items": [
1151
+ {
1152
+ "title": "`b.ai.disclosure.chatbot(session, opts)` — Art. 50(1) first-contact disclosure",
1153
+ "body": "Operators interacting with natural persons via an AI system get a primitive that emits the \"you are interacting with an AI system\" notice + audits the emission. `placement` opts: `\"first-message\"` (default — emit on first contact only, tracked via `session.aiDisclosureEmitted`), `\"always\"` (every response), `\"on-request\"` (operator wires their own trigger). Returns `{ text, language, jurisdiction, placement, shouldEmit, article, regulation }` — `shouldEmit` is the operator-consumable boolean for response-wire-up logic."
1154
+ },
1155
+ {
1156
+ "title": "`b.ai.disclosure.deepfake(content, opts)` — Art. 50(4) synthetic-content label",
1157
+ "body": "Operators emitting model-generated or model-manipulated content get a primitive that returns both the visible label markup AND the machine-readable metadata payload. `contentType: \"image\" | \"audio\" | \"video\" | \"text\"` is required; `placement: \"label\" | \"metadata\" | \"both\"` (default `\"both\"`) controls what the primitive populates. The metadata payload includes `schema: \"c2pa-v1.4-ready\"` — the v0.12.21 `b.contentCredentials` C2PA adapter will consume this schema field when it lands. `crossWalk` array carries `[\"eu-ai-act/Art. 50(4)\"]` plus the per-jurisdiction reference (AB-853 §22949.91 / CAC GenAI Art. 12)."
1158
+ },
1159
+ {
1160
+ "title": "`b.ai.disclosure.emotion(opts)` — Art. 50(3) emotion-recognition / biometric-categorisation notice",
1161
+ "body": "Operators deploying emotion-recognition or biometric-categorisation systems get the consent-flow notice primitive. `systemType: \"emotion\" | \"biometric-categorisation\"` (default `\"emotion\"`) selects which Art. 50(3) sub-obligation applies. Returns the notice payload + emits an `ai-act/emotion-disclosure-applied` audit event."
1162
+ },
1163
+ {
1164
+ "title": "Cross-jurisdiction cross-walk: EU + US-CA + China in a single primitive",
1165
+ "body": "The `opts.jurisdiction` opt accepts `\"eu\"` (default — Regulation (EU) 2024/1689), `\"us-ca\"` (California AB-853 effective 2026), or `\"cn\"` (China CAC GenAI Measures). The chatbot + deepfake primitives both honour the cross-walk: the deepfake response's `crossWalk` array carries every jurisdiction-specific legal reference the same emission satisfies, so operators serving multi-region traffic emit one notice + audit one event + reference all applicable regimes."
1166
+ }
1167
+ ]
1168
+ },
1169
+ {
1170
+ "heading": "Security",
1171
+ "items": [
1172
+ {
1173
+ "title": "Drop-silent audit emission preserves the disclosure path under audit-bus failure",
1174
+ "body": "If `opts.audit` is supplied but its `safeEmit` throws (network bus down, audit-sign chain malformed), the disclosure primitive still returns the user-facing notice payload. The Art. 50 obligation is the user-facing notice itself; the audit emission is a parallel best-effort chain-of-custody record. Refusing the disclosure to defend the audit chain would fail the wrong direction — the regulatory contract is satisfied by emitting the notice. Matches the framework's `audit.safeEmit` drop-silent contract for hot-path observability sinks."
1175
+ }
1176
+ ]
1177
+ },
1178
+ {
1179
+ "heading": "Migration",
1180
+ "items": [
1181
+ {
1182
+ "title": "C2PA manifest emission lands in v0.12.21",
1183
+ "body": "The deepfake primitive's metadata payload includes a `schema: \"c2pa-v1.4-ready\"` field that the v0.12.21 `b.contentCredentials` adapter will consume. Operators emitting image / audio / video for v0.12.12-0.12.20 get the label markup + structured metadata; the actual C2PA manifest (signed JUMBF assertion chain) is the next composition layer."
1184
+ }
1185
+ ]
1186
+ }
1187
+ ]
1188
+ },
1189
+ {
1190
+ "version": "0.12.11",
1191
+ "date": "2026-05-23",
1192
+ "headline": "`b.archive.wrapWithPassphrase` + `b.archive.unwrapWithPassphrase` — Argon2id + XChaCha20-Poly1305 archive envelope + `b.backup` `cryptoStrategy: \"passphrase\"` with HIPAA / PCI-DSS 128-bit entropy floor",
1193
+ "summary": "Passphrase wrap lands as the second `b.archive` envelope strategy alongside v0.12.10's recipient wrap. `b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` produces a `BAWPP`-prefixed envelope under Argon2id (RFC 9106; framework-default 64 MiB / 3 iterations / 4 parallelism) key derivation with XChaCha20-Poly1305 AEAD; each envelope carries its own fresh salt in the wire format (5-byte magic + 1-byte version + 1-byte saltLen + salt + 24-byte nonce + ciphertext+tag) so KDF parameters can rotate in future minors without per-envelope version bumps. `b.archive.unwrapWithPassphrase(sealed, { passphrase })` verifies the `BAWPP` header before any Argon2id compute so non-envelope inputs fail with `archive-wrap/bad-magic` rather than burning the KDF on bad bytes. `b.backup.bundleAdapterStorage({ cryptoStrategy: \"passphrase\", passphrase })` composes the wrap layer transparently — bundle bytes hitting the adapter's `writeFile` are an opaque passphrase-derived envelope. Default `passphraseMinEntropyBits: 80` matches OWASP strong-password guidance; HIPAA + PCI-DSS postures raise the floor to 128 bits automatically (matching the framework's existing crypto-grade-password discipline for sealed-storage). The recipient strategy from v0.12.10 + passphrase strategy from v0.12.11 + plaintext strategy from v0.12.7 cover the operator's posture matrix: HIPAA / PCI-DSS pick recipient or passphrase; non-regulated deployments may stay on `\"none\"` when the storage layer is itself the protective boundary.",
1194
+ "sections": [
1195
+ {
1196
+ "heading": "Added",
1197
+ "items": [
1198
+ {
1199
+ "title": "`b.archive.wrapWithPassphrase(bytes, { passphrase, minEntropyBits })` — Argon2id-derived archive envelope",
1200
+ "body": "Composes `b.backupCrypto.encryptWithFreshSalt(bytes, passphrase)` (Argon2id KDF + XChaCha20-Poly1305 AEAD, fresh per-envelope salt) and prepends a 7-byte `BAWPP` envelope header (5-byte magic + 1-byte version + 1-byte saltLen) so format sniffers can identify passphrase wrap output without trial KDF work. Entropy estimate uses observed-alphabet bit-count (the standard NIST/OWASP character-class approximation). `minEntropyBits` defaults to 80; the gate refuses upfront with `archive-wrap/weak-passphrase` when the estimate falls short."
1201
+ },
1202
+ {
1203
+ "title": "`b.archive.unwrapWithPassphrase(sealed, { passphrase })` — inverse with magic-check upfront",
1204
+ "body": "Verifies the 7-byte `BAWPP` header (magic + version + saltLen) before any cryptographic work so non-envelope inputs (raw archives, recipient-wrap envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` / `archive-wrap/truncated-envelope` rather than wasting Argon2id compute. Routes through `b.backupCrypto.decryptWithPassphrase(encrypted, passphrase, saltHex)` so the framework's locked Argon2id parameters apply."
1205
+ },
1206
+ {
1207
+ "title": "`b.backup.bundleAdapterStorage({ cryptoStrategy: \"passphrase\", passphrase })` — Argon2id-keyed bundle storage",
1208
+ "body": "Composes `b.archive.wrapWithPassphrase` transparently — every `writeBundle` payload is wrapped before `adapter.writeFile`; every `readBundle` payload is unwrapped after `adapter.readFile`. The `passphraseMinEntropyBits` opt defaults to 80 (OWASP strong-password floor); HIPAA + PCI-DSS postures raise the floor to 128 bits automatically. Passphrase + directory format combination refused upfront (same contract as recipient + directory). Wire-format envelope on disk is opaque ciphertext — no information leakage about archive contents through the storage adapter."
1209
+ },
1210
+ {
1211
+ "title": "HIPAA + PCI-DSS postures raise entropy floor to 128 bits under passphrase strategy",
1212
+ "body": "`bundleAdapterStorage({ posture: \"hipaa\", cryptoStrategy: \"passphrase\", passphrase })` enforces `passphraseMinEntropyBits >= 128` regardless of the operator-supplied opt. The 128-bit floor matches the framework's existing crypto-grade-password discipline for sealed-storage cells. Operators sourcing passphrases from a CSPRNG (`b.crypto.generateBytes(16).toString(\"base64url\")` → ~128 bits) pass without issue; operators typing dictionary phrases trip the gate."
1213
+ }
1214
+ ]
1215
+ },
1216
+ {
1217
+ "heading": "Security",
1218
+ "items": [
1219
+ {
1220
+ "title": "Magic-check before KDF work — non-envelope inputs can't burn Argon2id compute",
1221
+ "body": "Adversarial inputs that look like passphrase envelopes but aren't (random bytes, recipient envelopes, raw archives) fail at byte 0-4 (magic check) rather than after a 64 MiB Argon2id round. Operators handing user-supplied bundles to readBundle on a server with concurrent load get bounded refusal latency rather than worst-case KDF compute under a chosen-bytes attack."
1222
+ }
1223
+ ]
1224
+ }
1225
+ ]
1226
+ },
1227
+ {
1228
+ "version": "0.12.10",
1229
+ "date": "2026-05-23",
1230
+ "headline": "`b.archive.wrap` + `b.archive.unwrap` — recipient-encrypted archive envelopes (Flavor 1) + `b.backup` `cryptoStrategy: \"recipient\"` + HIPAA/PCI-DSS posture refusal",
1231
+ "summary": "Flavor 1 lands as the whole-archive recipient-wrap substrate. `b.archive.wrap(bytes, { recipient })` produces a sealed envelope under the framework's hybrid PQC seal (ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305) prefixed with a 6-byte `BAWRP` archive-wrap header so format sniffers can identify wrap envelopes without trial decryption. `b.archive.unwrap(sealed, { recipient })` is the inverse with magic-check upfront so non-envelope inputs throw `archive-wrap/bad-magic` rather than a crypto-level error. Recipient strategies: static keypair (`{ publicKey, ecPublicKey }`) and peer-cert (`{ peerCertDer, peerKemPubkey }`); the tenant strategy lands in v0.12.11 alongside the backup-crypto refactor + per-tenant key resolution. `b.backup.bundleAdapterStorage({ cryptoStrategy: \"recipient\", recipient })` composes the wrap/unwrap layer transparently: the bytes hitting the adapter's `writeFile` are a `BAWRP`-prefixed envelope, never the raw tar / tar.gz / directory bundle. HIPAA + PCI-DSS postures refuse `cryptoStrategy: \"none\"` upfront with `backup/posture-requires-encryption` — the storage adapter cannot itself satisfy the encryption-at-rest requirement; the recipient envelope is the framework-side gate. Flavor 2 (per-entry ZIP wrap with the 0xBADC extra-field marker) and the backup-crypto refactor into `lib/_crypto-base.js` ship in v0.12.11.",
1232
+ "sections": [
1233
+ {
1234
+ "heading": "Added",
1235
+ "items": [
1236
+ {
1237
+ "title": "`b.archive.wrap(bytes, { recipient })` — recipient-encrypted archive envelope",
1238
+ "body": "Composes `b.crypto.encrypt` (or `b.crypto.encryptEnvelopeAsCertPeer` for the peer-cert strategy) under the framework's hybrid PQC seal. The output is a Buffer carrying a 6-byte `BAWRP` archive-wrap header (5-byte magic + 1-byte version) followed by the base64-encoded envelope bytes. Recipient strategies: `{ publicKey, ecPublicKey }` for the static-keypair path (ML-KEM-1024 PEM + P-384 ECDH PEM); `{ peerCertDer, peerKemPubkey }` for the peer-cert path (extracts the P-384 half from the cert per `b.crypto.encryptEnvelopeAsCertPeer`). `\"tenant\"` returns `archive-wrap/tenant-strategy-deferred` upfront — that strategy lands in v0.12.11 with the per-tenant key resolution."
1239
+ },
1240
+ {
1241
+ "title": "`b.archive.unwrap(sealed, { recipient })` — inverse with upfront magic check",
1242
+ "body": "Verifies the 6-byte `BAWRP` header before any cryptographic work so non-envelope inputs (raw archives, other-magic envelopes, truncated buffers) fail with `archive-wrap/bad-magic` / `archive-wrap/bad-version` rather than a downstream `crypto/*` error. Routes through `b.crypto.decrypt(envelope, recipient, { raw: true })` so binary archive payloads (gzip, ZIP, tar) round-trip losslessly — `raw: true` is the contract that preserves bytes vs the default utf-8 decoding."
1243
+ },
1244
+ {
1245
+ "title": "`b.backup.bundleAdapterStorage({ cryptoStrategy: \"recipient\", recipient })` — opt-in envelope storage",
1246
+ "body": "`cryptoStrategy: \"none\"` (default, v0.12.7-9 behaviour) writes plaintext bundle bytes to the adapter — safe for storage layers that are themselves the protective boundary (S3 SSE, disk-encrypted hosts). `cryptoStrategy: \"recipient\"` requires `opts.recipient` and wraps every `writeBundle` payload through `b.archive.wrap` before `adapter.writeFile`; `readBundle` unwraps after `adapter.readFile`. The wrap layer sits OUTSIDE the gz / tar layers so the bundle on disk is opaque ciphertext under the operator-controlled recipient key. Passphrase strategy is deferred to v0.12.11 alongside the `_crypto-base.js` refactor."
1247
+ },
1248
+ {
1249
+ "title": "HIPAA + PCI-DSS posture refuses plaintext bundles",
1250
+ "body": "`bundleAdapterStorage({ posture: \"hipaa\" })` (or `\"pci-dss\"`) refuses `cryptoStrategy: \"none\"` upfront with `backup/posture-requires-encryption` — adapter-storage's plaintext default cannot itself satisfy encryption-at-rest requirements. Operators under these postures pass `cryptoStrategy: \"recipient\"` + a recipient key. The refusal message includes the posture name + the strategy that fails so audit-trail operators see exactly which gate blocked the call."
1251
+ }
1252
+ ]
1253
+ },
1254
+ {
1255
+ "heading": "Security",
1256
+ "items": [
1257
+ {
1258
+ "title": "Wrap envelope is the framework's hybrid PQC seal — ML-KEM-1024 + P-384 ECDH + SHAKE256 + XChaCha20-Poly1305",
1259
+ "body": "Defence-in-depth posture: a CRQC against ML-KEM-1024 alone still has to defeat the classical P-384 ECDH leg; a future ECDH break alone still has to defeat ML-KEM-1024. The 4-byte envelope header (magic + KEM ID + cipher ID + KDF ID) is bound as AEAD AAD so a header-substitution attack fails Poly1305 verification. `b.archive.wrap` prepends a separate 6-byte archive-wrap header BEFORE the base64 envelope so format sniffing can identify wrap output without trial decryption — non-envelope inputs are refused at byte 0-4 (magic check) instead of after fruitless decapsulation work."
1260
+ }
1261
+ ]
1262
+ },
1263
+ {
1264
+ "heading": "Detectors",
1265
+ "items": [
1266
+ {
1267
+ "title": "`backup-adapter-storage-without-posture-check` — postures that mandate encryption must propagate to `cryptoStrategy`",
1268
+ "body": "When a primitive that wires `b.backup.bundleAdapterStorage` carries a `posture:` opt drawn from the HIPAA / PCI-DSS / etc. set, the same code path must propagate `cryptoStrategy: \"recipient\"` (or refuse before reaching writeBundle). The detector matches `bundleAdapterStorage({ ... posture: ... })` invocations in `lib/` and requires a matching `cryptoStrategy` opt; missing it surfaces during the codebase-patterns gate so a future caller can't silently drop the contract."
1269
+ }
1270
+ ]
1271
+ },
1272
+ {
1273
+ "heading": "Migration",
1274
+ "items": [
1275
+ {
1276
+ "title": "Flavor 2 — per-entry ZIP recipient wrap with 0xBADC extra-field",
1277
+ "body": "Per-entry encryption inside the carrier ZIP (method=STORE with the encrypted bytes as the stored payload + a 0xBADC user-defined-range extra-field marker carrying the recipient hint). Inspect-without-decrypt is the operator value: entry list + name-safety gating happens BEFORE any key resolution. Lands in v0.12.11 alongside the backup-crypto refactor."
1278
+ },
1279
+ {
1280
+ "title": "`lib/_crypto-base.js` refactor — backup-crypto, Flavor 1, Flavor 2 share substrate",
1281
+ "body": "The legacy per-file Argon2id + XChaCha20-Poly1305 path in `lib/backup/crypto.js` gets factored into a private `_crypto-base.js` helper so all three encryption flavors compose the same primitive set. No operator-visible API change; closes the each-feature-rolls-its-own-crypto smell."
1282
+ },
1283
+ {
1284
+ "title": "`cryptoStrategy: \"passphrase\"` + tenant strategy",
1285
+ "body": "Passphrase strategy on `bundleAdapterStorage` (Argon2id-derived key + XChaCha20-Poly1305) and the `\"tenant\"` recipient string (composes `b.vault.derivedKey({ tenant, purpose: \"archive-wrap\" })`) both ship in v0.12.11. The v0.12.10 surface is the recipient substrate; v0.12.11 lights up the per-tenant + passphrase strategies that consume it."
1286
+ }
1287
+ ]
1288
+ }
1289
+ ]
1290
+ },
1291
+ {
1292
+ "version": "0.12.9",
1293
+ "date": "2026-05-23",
1294
+ "headline": "`b.archive.gz` + `b.archive.read.gz` — gzip composition with `b.safeDecompress` bomb caps + `b.backup` `tar.gz` bundle format + `sha-to-tag verify` fetches `origin/main`",
1295
+ "summary": "gzip lands as the composition layer over the archive family. `b.archive.gz(bytes)` produces an RFC 1952 gzip stream with the same `toBuffer()` / `toAdapter(adapter)` / `digest()` shape every archive builder ships, and `b.archive.read.gz(adapter, opts)` reads it back through `b.safeDecompress` so a malicious `tar.gz` fails the gzip-layer bomb cap (1 GiB output / 100× ratio defaults) before the tar walker ever sees a decompressed byte. The reader exposes `toBuffer()` / `asTar(opts)` / `asZip(opts)` so operators can hand the decompressed bytes directly to a downstream archive reader without a round-trip through disk. `b.archive.tar().toGzip(adapter, opts)` is the write-side convenience for the most common combination. `b.backup.bundleAdapterStorage({ format: \"tar.gz\" })` adds gzip compression on the wire — bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); the readback path detects the format from the storage key suffix and composes `b.safeDecompress` automatically. The `sha-to-tag verify` workflow now explicitly fetches `origin/main` before walking the first-parent history, fixing a stale-ref bug that silently failed v0.12.6 through v0.12.8 tag verifications (the publish workflow itself was unaffected; the gate is independent).",
1296
+ "sections": [
1297
+ {
1298
+ "heading": "Added",
1299
+ "items": [
1300
+ {
1301
+ "title": "`b.archive.gz(bytes)` — standalone gzip write builder",
1302
+ "body": "RFC 1952 gzip envelope with the standard archive-builder shape. `toBuffer()` returns the compressed bytes; `toAdapter(adapter)` writes through any writable adapter (fs / object-store / http) that exposes `.write(bytes)` + optional `.close()`; `digest()` returns a SHA3-512 hex hash of the compressed payload for operator integrity logs. `opts.level` accepts 0-9 (zlib default 6). Composes cleanly under `b.archive.tar().toGzip(adapter)` / `b.archive.zip()` for tar.gz / zip.gz convenience."
1303
+ },
1304
+ {
1305
+ "title": "`b.archive.read.gz(adapter, opts)` — gunzip reader with `b.safeDecompress` bomb caps",
1306
+ "body": "Every decompression routes through `b.safeDecompress({ algorithm: \"gzip\", maxOutputBytes, maxRatio })` so a hostile gzip stream fails the bomb gate before any downstream parsing happens. Defaults: `maxDecompressedBytes` = 1 GiB, `maxExpansionRatio` = 100×. The reader exposes three downstream entry points: `toBuffer()` returns the raw decompressed bytes; `asTar(opts)` returns a `b.archive.read.tar` reader over the decompressed payload; `asZip(opts)` returns a `b.archive.read.zip` reader. `fromGzip` is the documented alias the spec uses (operators may reach for either). Refuses non-gzip input upfront via the `0x1f 0x8b` magic check (`archive-gz/bad-magic`)."
1307
+ },
1308
+ {
1309
+ "title": "`b.archive.tar().toGzip(adapter, opts)` — tar.gz write convenience",
1310
+ "body": "Pipes the tar builder's `toBuffer()` through `b.archive.gz()` and writes the resulting gzip envelope to a writable adapter. Equivalent to `b.archive.gz(t.toBuffer()).toAdapter(adapter)` but lets the operator stay in the tar-builder fluent chain when composing under fs / object-store / http adapters."
1311
+ },
1312
+ {
1313
+ "title": "`b.backup.bundleAdapterStorage({ format: \"tar.gz\" })` — compressed-on-the-wire bundles",
1314
+ "body": "Adds gzip compression to the v0.12.8 tar bundle format. Bundle sizes drop ~3-5× on text-heavy backups (databases, JSON exports, mail spools); binary-heavy backups (compressed databases, encrypted archives) see ~1.0-1.1×. Read paths auto-detect via the `<bundleId>/bundle.tar.gz` storage key suffix and route through `b.safeDecompress` on readback. The v0.12.8 `maxBundleBytes` cap continues to gate against pathological projected-uncompressed sizes; `tar.gz` does not bypass it."
1315
+ },
1316
+ {
1317
+ "title": "`b.safeArchive.extract({ format: \"tar.gz\" })` — explicit tar.gz dispatch",
1318
+ "body": "Operators handed a `.tar.gz` upload pass `format: \"tar.gz\"` explicitly; the orchestrator composes `b.archive.read.gz` → `.asTar()` and feeds the standard tar bomb-policy + entry-type-policy + guardProfile through. Defer-with-condition: auto-sniff for tar.gz (peek inside the gzip envelope for ustar magic at offset 257 of the decompressed prefix) lands when operator demand surfaces; today operators with `auto` mode on a `.tar.gz` payload get `format-unsupported gzip` with the explicit-format hint in the error message."
1319
+ }
1320
+ ]
1321
+ },
1322
+ {
1323
+ "heading": "Security",
1324
+ "items": [
1325
+ {
1326
+ "title": "Bomb caps ride at the gz layer, not the tar/zip layer",
1327
+ "body": "The decompression gate is enforced BEFORE the downstream archive reader sees any bytes — a hostile `tar.gz` that would decompress to 10 GiB of zero-filled tar entries fails the 1 GiB `maxDecompressedBytes` default cap during gunzip, never reaching the tar walker. Operators with legitimately large compressed archives pass `maxDecompressedBytes` higher; the framework refuses without an explicit opt-in. RFC 1952 §2.3.1 magic enforcement prevents content-type confusion (gzip-pretending-to-be-something-else inputs)."
1328
+ }
1329
+ ]
1330
+ },
1331
+ {
1332
+ "heading": "Fixed",
1333
+ "items": [
1334
+ {
1335
+ "title": "`sha-to-tag verify` workflow fetches `origin/main` before first-parent walk",
1336
+ "body": "The release-tag integrity gate runs on every `v*` tag push and verifies the tag's commit SHA appears on `main`'s first-parent history. `actions/checkout` was being asked for full history of the tag ref alone — `origin/main` wasn't fetched as a side effect, so `git rev-list --first-parent origin/main | grep -qx \"$SHA\"` walked a stale (or absent) ref and falsely refused. The check now explicitly fetches `origin/main` after checkout so the walk sees the current squash-merge HEAD. Affected releases (v0.12.6 / v0.12.7 / v0.12.8) had publish workflows that completed normally — `sha-to-tag verify` is an independent gate that was silently failing alongside successful publishes; nothing about the published artifacts was wrong."
1337
+ }
1338
+ ]
1339
+ },
1340
+ {
1341
+ "heading": "Detectors",
1342
+ "items": [
1343
+ {
1344
+ "title": "`archive-gz-without-safedecompress` — direct `node:zlib` gunzip in `lib/` must compose `b.safeDecompress`",
1345
+ "body": "Mirrors the v0.11.5 must-compose pattern: any `lib/` call to `zlib.gunzipSync` / `zlib.createGunzip` / `gunzip` outside `lib/archive-gz.js` (which IS the canonical gunzip site, with `b.safeDecompress` wired in) must carry an `allow:archive-gz-without-safedecompress` marker explaining why the bomb gate is bypassed. The detector locks the contract so v0.13+ work that touches a gzip-handling primitive can't quietly drop the cap."
1346
+ }
1347
+ ]
1348
+ }
1349
+ ]
1350
+ },
1351
+ {
1352
+ "version": "0.12.8",
1353
+ "date": "2026-05-23",
1354
+ "headline": "`b.archive.tar` + `b.archive.read.tar` — POSIX pax tar format end-to-end + `b.guardArchive.tarEntryPolicy` + `b.backup` tar bundle default",
1355
+ "summary": "Tar lands as the second format in the archive family. `b.archive.tar()` builds POSIX pax archives (ustar magic + pax extended headers for >100-char names, >8 GiB sizes, nanosecond mtime); `b.archive.read.tar(adapter)` walks the 512-byte block sequence with the same bomb-cap + path-traversal + entry-type defenses that ZIP read shipped at v0.12.7. Tar's natively-streamable shape means `b.archive.adapters.trustedStream(readable)` is a first-class extract path here (no CD-walk required since tar has no central directory; sequential header-by-header is the canonical adversarial-safe path). `b.guardArchive.tarEntryPolicy` ships as the tar-specific entry-shape policy beyond `entryTypePolicy` — handles typeflag 0/5 (regular/directory) by default, refuses 1/2 (hardlink/symlink) unless `allowDangerous` is set with the realpath-on-link-target dual-check, and refuses 3/4/6/7 (char-device/block-device/FIFO/contiguous-file) unconditionally. `b.backup.bundleAdapterStorage({ format: \"tar\" })` becomes the default for new bundles — directory-tree format stays available via `format: \"directory\"` for back-compat with v0.12.7 bundles. `b.backup.migrate(from, to)` one-shot helper converts v0.12.7 directory bundles to v0.12.8 tar bundles transparently. `b.safeArchive.extract({ source, destination, format: \"auto\" })` now sniffs ustar magic at offset 257 inside the first 512-byte block and dispatches to the tar reader automatically. CVE coverage extends to the tar class: CVE-2026-23745 / 2026-24842 (node-tar symlink+hardlink path resolution), CVE-2025-4517 PATH_MAX TOCTOU (the v0.12.7 dual-check carries through), CVE-2025-11001/11002 (symlink TOCTOU on extract), CVE-2024-12905 / 2025-48387 (tar-fs traversal), CVE-2025-4138/4330 (Python tarfile data filter bypass).",
1356
+ "sections": [
1357
+ {
1358
+ "heading": "Added",
1359
+ "items": [
1360
+ {
1361
+ "title": "`b.archive.tar()` — POSIX pax write builder",
1362
+ "body": "Mirrors `b.archive.zip()`'s contract: `addFile(name, content, opts?)` + `addDirectory(name, opts?)` + `toBuffer()` + `toStream(writable)` + `toAdapter(adapter)` + `digest()`. Emits ustar-magic 512-byte header blocks with the standard 11-field prefix (name / mode / uid / gid / size / mtime / chksum / typeflag / linkname / magic / version / uname / gname / devmajor / devminor / prefix). Names >100 chars + sizes >8 GiB + mtime with nanosecond precision get a pax extended header (typeflag=x) preceding the entry; the extended header records (per POSIX.1-2001 §4.18) carry the `path` / `size` / `mtime` / `atime` / `ctime` fields that overflow ustar's fixed widths. Determinism opts: `{ fixedMtime: 0, ignoreOrder: false }` for reproducible builds (matches the ZIP write side)."
1363
+ },
1364
+ {
1365
+ "title": "`b.archive.read.tar(adapter, opts)` — sequential + random-access tar reader",
1366
+ "body": "Walks 512-byte header blocks in order. `inspect()` enumerates entries without decompressing; `extract({ destination })` decompresses entry-by-entry with the same bomb-cap + path-traversal + entry-type defenses as ZIP read. Trusted-stream adapters are first-class here — tar has no central directory, so sequential header-by-header walk IS the canonical adversarial-safe path (`b.archive.adapters.trustedStream(readable)` and `b.archive.adapters.fs/buffer/objectStore/http` all flow through the same reader). Per-entry path safety routes through `b.guardFilename.verifyExtractionPath` (the v0.12.7 dual-check). Refuses to overwrite pre-existing destination files (carries the v0.12.7 atomic-rollback contract)."
1367
+ },
1368
+ {
1369
+ "title": "`b.guardArchive.tarEntryPolicy(opts)` — tar-specific entry-type policy",
1370
+ "body": "Defaults: typeflag 0 (regular file) + 5 (directory) extract; typeflag 1 (hardlink) + 2 (symlink) refused unless `allowDangerous: { symlinks: true, hardlinks: true }` is set; typeflag 3 (char-device) + 4 (block-device) + 6 (FIFO) + 7 (contiguous-file) refused unconditionally. When `allowDangerous` is set, link target is routed through `b.guardFilename.verifyExtractionPath` against the extraction root — the realpath-on-link-target check defends the CVE-2026-23745 / 24842 node-tar class where the safety check and creation logic diverged on path resolution. Pax extended-header (x) + global-header (g) entries consumed by the reader (merged into the following entry's metadata); operators never see them as standalone entries."
1371
+ },
1372
+ {
1373
+ "title": "`b.backup.bundleAdapterStorage({ format: \"tar\" })` — tar bundle becomes default",
1374
+ "body": "New bundles ship as a single tar archive instead of a directory tree. Restore via `b.archive.read.tar` (with the operator-supplied adapter routing the bytes). `format: \"directory\"` opts back into the v0.12.7 layout for operators with existing bundles. `format: \"tar\"` is the new default; `b.backup.diskStorage` stays back-compat at the legacy directory-tree format."
1375
+ },
1376
+ {
1377
+ "title": "`b.backup.migrate(opts)` — directory → tar bundle migration",
1378
+ "body": "One-shot helper that walks an operator's directory-tree-format bundle (v0.12.7 layout) and writes the same content as a tar-format bundle via the v0.12.8 bundleAdapterStorage. Idempotent: re-running on an already-migrated bundle is a no-op. Source bundle stays in place until the migrate succeeds; operators with explicit transition windows pass `{ deleteSourceOnSuccess: true }` to opt into the inline replace."
1379
+ },
1380
+ {
1381
+ "title": "`b.safeArchive.extract({ format: \"auto\" })` recognizes tar",
1382
+ "body": "Format auto-sniff now dispatches `ustar` magic at offset 257 inside the first 512-byte header block to the tar reader. ZIP magic + tar magic + GZIP magic (v0.12.9) live in the same sniff path; operators with mixed-format pipelines pass `format: \"auto\"` once + the orchestrator picks the right reader."
1383
+ }
1384
+ ]
1385
+ },
1386
+ {
1387
+ "heading": "Security",
1388
+ "items": [
1389
+ {
1390
+ "title": "Symlink + hardlink path resolution (CVE-2026-23745 / CVE-2026-24842 node-tar class)",
1391
+ "body": "node-tar < 7.5.7 / ≤ 7.5.2 shipped a divergence between its hardlink safety check (which used one path resolution) and its hardlink creation logic (which used another). When `allowDangerous: { hardlinks: true }` is set, blamejs routes the link target through `b.guardFilename.verifyExtractionPath` — the SAME primitive that the eventual `link()` call resolves against — so check + create agree by construction. Symlink targets same shape."
1392
+ },
1393
+ {
1394
+ "title": "Path traversal (CVE-2024-12905 / CVE-2025-48387 tar-fs + CVE-2025-4138 / 4330 Python tarfile data filter bypass)",
1395
+ "body": "Every entry name passes through `b.guardFilename.verifyExtractionPath` — the v0.12.7 dual-check that refuses pre-resolve names > PATH_MAX (4096 bytes) AND verifies the string-normalize + `fs.realpath` resolutions agree on the same final path. Defends the CVE-2025-4517 / 4138 / 4330 class where the operator's path resolution and the kernel's diverge silently past PATH_MAX."
1396
+ },
1397
+ {
1398
+ "title": "Symlink TOCTOU on extract (CVE-2025-11001 / CVE-2025-11002 7-Zip class)",
1399
+ "body": "When `allowDangerous: { symlinks: true }` opts symlinks in, the reader resolves the link target via `verifyExtractionPath` against the extraction root BEFORE calling `fs.symlink` — so the resolved target is inside the trust boundary by construction. The v0.12.7 atomic-rollback contract carries through: any single entry failure aborts the whole extract + cleans up only newly-created files (pre-existing destination files refused at the pre-write check)."
1400
+ }
1401
+ ]
1402
+ },
1403
+ {
1404
+ "heading": "Detectors",
1405
+ "items": [
1406
+ {
1407
+ "title": "`tar-extract-allow-dangerous-without-link-target-check`",
1408
+ "body": "Flags any `b.archive.read.tar(adapter).extract({ allowDangerous: ... })` call site in `lib/` that doesn't route the link target through `b.guardFilename.verifyExtractionPath` against the extraction root. Forces the dual-check discipline at every allow-dangerous opt-in — operators with hardlink / symlink extract needs see the realpath check at the call site."
1409
+ },
1410
+ {
1411
+ "title": "`tar-entry-typeflag-without-policy`",
1412
+ "body": "Flags `lib/archive-tar.js` extract code paths that switch on typeflag without composing `b.guardArchive.tarEntryPolicy` for the type-allowlist decision. Locks the shape: every typeflag dispatch goes through the policy, never inline."
1413
+ },
1414
+ {
1415
+ "title": "`backup-migrate-without-source-preserve`",
1416
+ "body": "Flags `b.backup.migrate(opts)` call sites that pass `deleteSourceOnSuccess: true` without an operator-stated justification comment. Default is preserve-source; deletes need an explicit reason."
1417
+ }
1418
+ ]
1419
+ }
1420
+ ],
1421
+ "references": [
1422
+ {
1423
+ "label": "POSIX.1-2001 pax extended format (IEEE 1003.1)",
1424
+ "url": "https://pubs.opengroup.org/onlinepubs/9699919799/utilities/pax.html"
1425
+ },
1426
+ {
1427
+ "label": "CVE-2026-23745 — node-tar symlink+hardlink path resolution",
1428
+ "url": "https://www.sentinelone.com/vulnerability-database/cve-2026-23745/"
1429
+ },
1430
+ {
1431
+ "label": "CVE-2026-24842 — node-tar hardlink path resolution",
1432
+ "url": "https://github.com/advisories/GHSA-34x7-hfp2-rc4v"
1433
+ },
1434
+ {
1435
+ "label": "CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)",
1436
+ "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-4517"
1437
+ },
1438
+ {
1439
+ "label": "CVE-2025-4138 / CVE-2025-4330 — Python tarfile data filter",
1440
+ "url": "https://github.com/0xDTC/CVE-2025-4138-4517-POC"
1441
+ },
1442
+ {
1443
+ "label": "CVE-2025-11001 / CVE-2025-11002 — 7-Zip symlink TOCTOU on extract",
1444
+ "url": "https://www.sentinelone.com/vulnerability-database/cve-2025-11001/"
1445
+ },
1446
+ {
1447
+ "label": "CVE-2024-12905 / CVE-2025-48387 — node-tar-fs path traversal",
1448
+ "url": "https://vulert.com/vuln-db/debian-11-node-tar-fs-193050"
1449
+ }
1450
+ ]
1451
+ },
1452
+ {
1453
+ "version": "0.12.7",
1454
+ "date": "2026-05-23",
1455
+ "headline": "`b.archive.read` — random-access ZIP reader + adapter substrate + `b.safeArchive.extract` orchestrator",
1456
+ "summary": "The framework's ZIP primitive grows a read side. `b.archive.read.zip(adapter, opts)` walks the central directory, validates LFH/CD coherence (defeats the malformed-zip / Zip Slip CD-skew class), bounds decompression with operator-declared bomb caps (per-entry size, total bytes, expansion ratio, entry count), and refuses path-traversal + symlink-shaped entries before any byte hits the destination. The adapter contract (`{ size, range(offset, length) }` for random-access; `{ readable }` for the trusted-stream fallback) unifies how operators feed bytes in — local files, `b.objectStore` buckets, HTTP Range fetches, and in-memory buffers all compose the same reader. `b.safeArchive.extract({ source, destination, ... })` ships as the one-liner orchestrator that combines read + guardArchive + path-safety + bomb caps + extract for the common `untar a hostile archive into a quarantine directory` shape. `b.guardArchive` gains `inspect(adapter)` (entry-list enumeration that doesn't decompress), `zipBombPolicy(...)` and `entryTypePolicy(...)` policy-object builders so operators can declare their cap set once + reuse it. `b.guardFilename.verifyExtractionPath(name, root, opts?)` adds the dual-check (string-normalize + `fs.realpath`-agreement) that defends the CVE-2025-4517 PATH_MAX TOCTOU class — refuses paths > 4096 bytes BEFORE the kernel's realpath truncation hits, then verifies the string and fs resolution agree on the same final path. `b.backup` gains `bundleAdapterStorage(adapter, opts)` — the first non-disk transport backend; substrate for the v0.12.8 tar bundle format + v0.12.11 objectStoreStorage. Closes the no-MVP gap from v0.5.15 where `b.archive` shipped write-only and the operator-facing JSDoc explicitly punted reading + extraction to yauzl / `unzip`.",
1457
+ "sections": [
1458
+ {
1459
+ "heading": "Added",
1460
+ "items": [
1461
+ {
1462
+ "title": "`b.archive.read.zip(adapter, opts)` — random-access ZIP reader",
1463
+ "body": "Walks the end-of-central-directory record, validates every CD entry against its LFH (offset / size / CRC / method / name agreement), and emits an iterator of `{ name, size, compressedSize, crc, method, mtime, isEncrypted, externalAttrs, extraFields }` entries. `inspect()` returns the entry list without decompressing — operators wire `b.guardArchive` against the inspect output before paying a single decompress cycle. `extract({ destination, ... })` decompresses entry-by-entry via `node:zlib` raw inflate, routes every path through `b.guardFilename.verifyExtractionPath`, and enforces zip-bomb caps as a streaming abort (the partial extract is fs.rm-ed before the error throws). Composes the existing `lib/archive.js` write-side CRC + signature constants — no duplicated wire-format knowledge."
1464
+ },
1465
+ {
1466
+ "title": "Adapter contract — `b.archive.adapters.{fs,objectStore,http,buffer,trustedStream}`",
1467
+ "body": "One shape for source bytes: `{ size: <number>, range(offset, length): Promise<Buffer> }` for random-access, or `{ readable: Readable }` for the explicit trust-stream fallback. `fs(path)` opens a file descriptor + range-reads. `objectStore(client, key)` composes the v0.4.23 `b.objectStore` Range-GET path. `http(url, opts)` composes `b.httpClient` with `Range: bytes=N-M` headers + 206 verification. `buffer(buf)` slices a Buffer in-memory. `trustedStream(readable)` accepts a Node Readable for the rare case the operator can vouch for the source. The same contract feeds `b.safeArchive.extract` and `b.backup.bundleAdapterStorage`."
1468
+ },
1469
+ {
1470
+ "title": "`b.safeArchive.extract({ source, destination, ... })` — one-liner safe extraction",
1471
+ "body": "Combines `b.archive.read` + `b.guardArchive` inspect + `b.guardFilename.verifyExtractionPath` + bomb caps + post-extract destination-rebase verification. Refuses the entire archive when any single entry trips a policy (atomic — no half-extracted state on the destination). Operators who want fine-grained control reach for the lower-level primitives directly; `b.safeArchive.extract` covers the 90%-case `extract this hostile-shaped archive into a quarantine directory` workflow. Returns `{ entries: [...], destinationRoot, bytesExtracted, auditTrail }` on success."
1472
+ },
1473
+ {
1474
+ "title": "`b.guardArchive.inspect(adapter, opts)` + `zipBombPolicy(...)` + `entryTypePolicy(...)`",
1475
+ "body": "`inspect(adapter)` is the bridge between the read primitive and the existing `validateEntries` gate — runs the read primitive's inspect phase, hands the entry list to `validateEntries`, and returns the merged `{ entries, issues, decisions }` so the caller decides whether to proceed. `zipBombPolicy({ maxEntries, maxEntryDecompressedBytes, maxTotalDecompressedBytes, maxExpansionRatio })` and `entryTypePolicy({ symlinks, hardlinks, devices, fifos, sockets })` are policy-object builders so operators declare the cap set once + reuse across call sites. Each policy carries its own `audit.posture` annotation that propagates through `b.agent.postureChain`."
1476
+ },
1477
+ {
1478
+ "title": "`b.guardFilename.verifyExtractionPath(name, root, opts?)` — dual-check path safety",
1479
+ "body": "Companion to the existing `b.guardArchive.checkExtractionPath` (string-only check the gate keeps portable). `verifyExtractionPath` couples to `fs.realpath` deliberately: refuses paths whose pre-resolve string already exceeds 4096 bytes (defends the CVE-2025-4517 PATH_MAX TOCTOU class — `os.path.realpath`-style truncation can't reach the kernel before our refuse fires), then verifies the string-normalized result and the `fs.realpath`-resolved result agree on the same final path. Disagreement throws `guard-filename/extraction-path-toctou`. The string-check stays the canonical portable gate; this primitive is the deeper fs-coupled check the framework's read primitive wires in by default."
1480
+ },
1481
+ {
1482
+ "title": "`b.backup.bundleAdapterStorage(adapter, opts)` — adapter-driven storage backend",
1483
+ "body": "First non-disk backend for `b.backup`. Walks the bundle directory file-by-file and writes through the v0.12.7 adapter contract — `fs` adapter behaves identically to `diskStorage` (which stays for back-compat), `buffer` adapter emits the bundle into an in-memory representation, custom adapters can route to anything that satisfies the contract. Substrate for the v0.12.8 tar bundle format (which folds the directory tree into a single tar stream) and the v0.12.11 `objectStoreStorage` (which composes `b.archive.adapters.objectStore` for S3 / MinIO / Azure / GCS-backed backups). Backup manifest layout unchanged — restore code keeps working byte-for-byte against bundles produced by either backend."
1484
+ }
1485
+ ]
1486
+ },
1487
+ {
1488
+ "heading": "Security",
1489
+ "items": [
1490
+ {
1491
+ "title": "Zip Slip class (CVE-2025-3445 / 11569 / 23084 / 27210 / 11001 / 11002 / 26960 / 4517 / 4138 / 4330 + 2024 jszip / mholt/archiver / Python tarfile / node-tar / 7-zip)",
1492
+ "body": "Every archive-read entry's name passes through `b.guardFilename.verifyExtractionPath` before any decompression. Path-traversal segments (`..`, leading `/` or `\\`, drive-letter prefixes, null bytes, overlong UTF-8) are refused; Windows reserved names + NTFS ADS suffixes are refused; the realpath-agreement check defends the CVE-2025-4517 PATH_MAX TOCTOU class. Symlink and hardlink entries are refused unconditionally under the default `entryTypePolicy`; operators with a legitimate need opt into `allowSymlinks: true` / `allowHardlinks: true` and get the entries routed through an additional `b.guardArchive` realpath-on-target check."
1493
+ },
1494
+ {
1495
+ "title": "Decompression-bomb class (CVE-2025-0725, OWASP zip-bomb top-cases)",
1496
+ "body": "`b.archive.read.zip.extract` enforces four caps in parallel: `maxEntries` (entry-count), `maxEntryDecompressedBytes` (per-entry size), `maxTotalDecompressedBytes` (aggregate across the archive), and `maxExpansionRatio` (compressed → decompressed ratio cap, default 100:1). Each cap aborts the extract as soon as the bound is exceeded; the destination directory is `fs.rm`-ed before the error throws so a partial extract never lingers on disk. The `b.safeDecompress` primitive (v0.11.5) is the underlying inflate gate — same defense surface, same audit-trail."
1497
+ },
1498
+ {
1499
+ "title": "LFH/CD skew + malformed-ZIP DoS",
1500
+ "body": "The CD walk verifies every entry's local-file-header against the central-directory record (offset / size / CRC / method / name agreement). Mismatches throw `archive/cd-skew` before any byte decompresses. Defends the malformed-zip class where a hostile producer points CD entries at LFH locations that don't match the CD claim — the prior write-only path had no exposure to this class; the new read path closes it."
1501
+ }
1502
+ ]
1503
+ },
1504
+ {
1505
+ "heading": "Detectors",
1506
+ "items": [
1507
+ {
1508
+ "title": "`archive-read-without-bomb-caps`",
1509
+ "body": "Flags `b.archive.read.zip(adapter)` call sites in `lib/` that don't pass an explicit `bombPolicy` or `maxTotalDecompressedBytes`. Forces the cap-declaration discipline at call sites — operators see the bomb-cap surface every time they reach for the read primitive."
1510
+ },
1511
+ {
1512
+ "title": "`archive-extract-without-guard`",
1513
+ "body": "Flags `b.archive.read.zip(...).extract({ destination, ... })` call sites that don't compose `b.safeArchive.extract` (the orchestrator) AND don't pass an explicit `b.guardArchive.inspect` precheck. Per-file lib/ surface forces operators to either use the orchestrator OR explicitly opt into per-step composition with a written justification."
1514
+ },
1515
+ {
1516
+ "title": "`archive-adapter-without-error-path`",
1517
+ "body": "Flags adapter implementations (any export shape matching `{ size, range }` / `{ readable }`) that don't propagate AbortSignal or refuse to throw on partial-read truncation. Forces the cancellation-discipline so a slow / hostile source can't block extraction indefinitely."
1518
+ },
1519
+ {
1520
+ "title": "`safe-archive-extract-bypass`",
1521
+ "body": "Flags any composition in `lib/` that builds the safeArchive pipeline (`read.zip(...).extract({ destination, ... })` + bomb caps + guard) inline instead of calling `b.safeArchive.extract`. The orchestrator owns the audit-emission shape; bypassing it means audit gaps."
1522
+ }
1523
+ ]
1524
+ }
1525
+ ],
1526
+ "references": [
1527
+ {
1528
+ "label": "APPNOTE.TXT (PKWARE ZIP File Format Specification)",
1529
+ "url": "https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT"
1530
+ },
1531
+ {
1532
+ "label": "CVE-2025-3445 — mholt/archiver Zip Slip",
1533
+ "url": "https://github.com/advisories/GHSA-7vpp-9cxj-q8gv"
1534
+ },
1535
+ {
1536
+ "label": "CVE-2025-4517 — Python tarfile PATH_MAX bypass (CVSS 9.4)",
1537
+ "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-4517"
1538
+ },
1539
+ {
1540
+ "label": "CVE-2025-11001 / CVE-2025-11002 — 7-Zip directory-traversal RCE",
1541
+ "url": "https://www.sentinelone.com/vulnerability-database/cve-2025-11001/"
1542
+ },
1543
+ {
1544
+ "label": "CVE-2025-11569 — cross-zip directory traversal",
1545
+ "url": "https://security.snyk.io/vuln/SNYK-JS-CROSSZIP-6105396"
1546
+ },
1547
+ {
1548
+ "label": "CVE-2026-23745 / CVE-2026-24842 — node-tar symlink + hardlink bypass",
1549
+ "url": "https://github.com/advisories/GHSA-34x7-hfp2-rc4v"
1550
+ },
1551
+ {
1552
+ "label": "OWASP Zip Slip + zip-bomb reference",
1553
+ "url": "https://snyk.io/research/zip-slip-vulnerability"
1554
+ },
1555
+ {
1556
+ "label": "USENIX WOOT'19 — A better zip bomb (Fifield)",
1557
+ "url": "https://www.usenix.org/conference/woot19/presentation/fifield"
1558
+ }
1559
+ ]
1560
+ },
1561
+ {
1562
+ "version": "0.12.6",
1563
+ "date": "2026-05-22",
1564
+ "headline": "`b.observability.otlpExporter` adds OTLP/protobuf-HTTP encoding (`opts.encoding: \"protobuf\"`)",
1565
+ "summary": "The OTLP trace exporter now speaks `application/x-protobuf` end-to-end. Operators with high-volume telemetry opt into binary encoding via `opts.encoding: \"protobuf\"` (`\"http/protobuf\"` is accepted as a spec-name alias). The protobuf wire format encodes the same `ExportTraceServiceRequest` envelope as the existing JSON path — `ResourceSpans` → `ScopeSpans` → `Span` → `Status` / `Event` / `KeyValue` / `AnyValue` per the opentelemetry-proto repo — but emits 30-50% smaller bodies than the JSON shape on real-world workloads and avoids the JSON-parse cost on the collector side. Default stays `\"json\"`; collectors that don't speak protobuf keep working unchanged. Composes the existing `lib/protobuf-encoder.js` infrastructure.",
1566
+ "sections": [
1567
+ {
1568
+ "heading": "Added",
1569
+ "items": [
1570
+ {
1571
+ "title": "`opts.encoding: \"json\" | \"protobuf\"` on `b.observability.otlpExporter.create`",
1572
+ "body": "When `\"protobuf\"` (or the spec-name alias `\"http/protobuf\"`), the exporter encodes batches as binary OTLP `ExportTraceServiceRequest` bytes and POSTs with `Content-Type: application/x-protobuf`. The retry / queue / drop-counter / audit machinery is shared with the JSON path so operators get the same operational primitives across both encodings. Default stays `\"json\"` — existing collectors keep working without configuration changes."
1573
+ },
1574
+ {
1575
+ "title": "Full OTLP trace schema encoded via `lib/protobuf-encoder.js`",
1576
+ "body": "ResourceSpans / ScopeSpans / Span / Event / Status / KeyValue / AnyValue / ArrayValue are emitted per the opentelemetry-proto repo's field numbers + wire types. `trace_id` and `span_id` round-trip as fixed-length bytes (16 + 8 octets respectively). `start_time_unix_nano` / `end_time_unix_nano` use `fixed64` for the nanosecond precision the JSON path's number type lossily encoded. SpanKind enum mapping covers unspecified / internal / server / client / producer / consumer."
1577
+ },
1578
+ {
1579
+ "title": "`pb.int64` / `pb.sint64` — signed-integer varint shapes on `lib/protobuf-encoder.js`",
1580
+ "body": "Negative integer attribute values in OTLP `AnyValue` (e.g. retry-after offsets, signed metric deltas) emit as proto3 `int64` — wire-type 0 varint, 10-byte two's-complement reinterpret per the spec. `pb.sint64` adds ZigZag-encoded varint for cases where small negatives dominate. Both accept Number / BigInt / digit-string inputs with explicit `[-2^63, 2^63 - 1]` range validation."
1581
+ },
1582
+ {
1583
+ "title": "`pb.fixed64` accepts string-form uint64 values",
1584
+ "body": "OTLP/JSON encodes uint64 as a JSON string (per the proto3 JSON mapping) — the framework's tracer emits `start_time_unix_nano` / `end_time_unix_nano` as digit-string BigInt-to-string conversions so the JSON path stays lossless. `pb.fixed64` now accepts that same digit-string shape on the protobuf path so a single timestamp representation flows through both encodings without a separate coercion step. Refuses non-digit strings and silently-rounded Numbers above `Number.MAX_SAFE_INTEGER`."
1585
+ }
1586
+ ]
1587
+ },
1588
+ {
1589
+ "heading": "Security",
1590
+ "items": [
1591
+ {
1592
+ "title": "AnyValue recursion capped at 100 levels (CVE-2024-7254 / CVE-2025-4565 class)",
1593
+ "body": "Both protobufjs (CVE-2024-7254) and protobuf-python (CVE-2025-4565) shipped DoS-via-unbounded-nested-group decoding. The OTLP `AnyValue` type permits a nested `ArrayValue { repeated AnyValue values = 1 }` that an adversarial collector-response could exploit during a future receive path. The encoder caps `_anyValueToProto` recursion at 100 levels — beyond which it emits an empty AnyValue rather than continuing to descend. Today's framework only EMITS (never receives) OTLP — but the cap is in the right place when the receive path lands."
1594
+ }
1595
+ ]
1596
+ }
1597
+ ],
1598
+ "references": [
1599
+ {
1600
+ "label": "OTLP §3 — Body encodings (JSON + protobuf)",
1601
+ "url": "https://opentelemetry.io/docs/specs/otlp/"
1602
+ },
1603
+ {
1604
+ "label": "opentelemetry-proto repo — trace/v1/trace.proto",
1605
+ "url": "https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/trace/v1/trace.proto"
1606
+ },
1607
+ {
1608
+ "label": "CVE-2024-7254 (protobufjs unbounded nesting DoS)",
1609
+ "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-7254"
1610
+ },
1611
+ {
1612
+ "label": "CVE-2025-4565 (protobuf-python unbounded nesting DoS)",
1613
+ "url": "https://nvd.nist.gov/vuln/detail/CVE-2025-4565"
1614
+ }
1615
+ ]
1616
+ },
1617
+ {
1618
+ "version": "0.12.5",
1619
+ "date": "2026-05-22",
1620
+ "headline": "`b.metrics` content-negotiates OpenMetrics 1.0 + auto-attaches trace exemplars on request histograms",
1621
+ "summary": "The `/metrics` scrape endpoint now serves `application/openmetrics-text; version=1.0.0; charset=utf-8` when the scraper requests it via the `Accept` header (Prometheus 2.x strict mode, OpenObservability tooling). Legacy scrapers still get `text/plain; version=0.0.4` — no operator with the default Prometheus client sees a content-type change. Separately, the framework's request-duration histogram middleware now auto-attaches the active sampled trace's `trace_id` + `span_id` as the OpenMetrics §6.2 exemplar on every bucket sample, so Grafana / Tempo / Jaeger scrapers can pivot from a slow-bucket histogram to the exact trace that produced the sample. The wiring is composition-only — `b.middleware.tracePropagate` populates `req.trace.{traceId,parentId,sampled}`, the metrics middleware reads it, no operator opt-in needed.",
1622
+ "sections": [
1623
+ {
1624
+ "heading": "Added",
1625
+ "items": [
1626
+ {
1627
+ "title": "`Accept` content-negotiation on `b.metrics.expositionHandler()`",
1628
+ "body": "When the scraper's `Accept` header includes `application/openmetrics-text`, the handler renders the OpenMetrics 1.0 wire format (`# UNIT` lines, `_total` suffix on counters, `# EOF` terminator, exemplar shape) and serves `application/openmetrics-text; version=1.0.0; charset=utf-8`. Otherwise serves Prometheus 0.0.4 `text/plain` as before. Operators relying on the legacy Prometheus content-type see no change."
1629
+ },
1630
+ {
1631
+ "title": "Auto-attached trace exemplars on request-duration histograms",
1632
+ "body": "When `b.middleware.spanHttpServer` populates `req.span.{traceId, spanId, sampled}` on the inbound request and the span is sampled, the framework's built-in `requestDuration` histogram middleware attaches `{ labels: { trace_id, span_id }, value: <duration>, timestamp: <unix-sec> }` as the OpenMetrics §6.2 exemplar on the corresponding bucket. The exemplar's `span_id` is the server-handling span, not the upstream `traceparent`'s parent-id, so the metric-to-trace pivot in Grafana / Tempo / Jaeger lands on the work the metric measured. Operators wiring `tracePropagate` without `spanHttpServer` fall back to `req.trace.spanId` when populated; the framework never invents a span_id from the upstream parent."
1633
+ }
1634
+ ]
1635
+ },
1636
+ {
1637
+ "heading": "Fixed",
1638
+ "items": [
1639
+ {
1640
+ "title": "Accept-header weighted negotiation (Codex P1)",
1641
+ "body": "The first pass treated any `Accept` header containing `application/openmetrics-text` as an unconditional OpenMetrics request — clients sending `Accept: text/plain;q=1.0, application/openmetrics-text;q=0.5` got OpenMetrics back instead of their preferred Prometheus 0.0.4. Fix: parse Accept via `b.requestHelpers.parseQualityList` and compare q-values for `application/openmetrics-text` vs `text/plain` (wildcards `*/*`, `application/*`, `text/*` honored). Defaults to Prometheus when both q-values are equal or zero (backward compatibility with the legacy default content-type)."
1642
+ },
1643
+ {
1644
+ "title": "Exemplar span_id sources the active server span, not the upstream parent (Codex P2)",
1645
+ "body": "The first pass used `req.trace.parentId` for the exemplar's `span_id` label — but `parentId` is the upstream caller's span (or empty for root requests), not the server-handling span. Fix: prefer `req.span.spanId` (set by `b.middleware.spanHttpServer`), falling back to `req.trace.spanId` for operators wiring `tracePropagate` without `spanHttpServer`. Never synthesises a span_id from `parentId`."
1646
+ }
1647
+ ]
1648
+ }
1649
+ ],
1650
+ "references": [
1651
+ {
1652
+ "label": "OpenMetrics 1.0 §1.2 (content negotiation)",
1653
+ "url": "https://prometheus.io/docs/specs/om/open_metrics_spec/"
1654
+ },
1655
+ {
1656
+ "label": "OpenMetrics 1.0 §6.2 (exemplars)",
1657
+ "url": "https://prometheus.io/docs/specs/om/open_metrics_spec/"
1658
+ },
1659
+ {
1660
+ "label": "W3C Trace Context (traceparent header)",
1661
+ "url": "https://www.w3.org/TR/trace-context/"
1662
+ }
1663
+ ]
1664
+ },
1665
+ {
1666
+ "version": "0.12.4",
1667
+ "date": "2026-05-22",
1668
+ "headline": "`SECURITY.md` Watch list — remove stale \"framework doesn't ship CMS / S/MIME\" entry",
1669
+ "summary": "The Watch list bullet claiming `framework does not ship a CMS / S/MIME / PKCS#7 surface today` has been wrong since v0.10.13 — `b.cms.encodeSignedData` / `decode` / `encodeEnvelopedData` / `parseSignedData` shipped then, and `b.mail.crypto.smime.sign` / `verify` / `verifyAll` / `checkCert` shipped under the mail-stack. The Watch list is for CVE classes the framework deliberately doesn't ship a primitive for; CMS no longer fits that shape. Entry removed.",
1670
+ "sections": [
1671
+ {
1672
+ "heading": "Fixed",
1673
+ "items": [
1674
+ {
1675
+ "title": "Watch list no longer claims CMS / S/MIME are unshipped",
1676
+ "body": "`b.cms` exposes RFC 5652 ContentInfo / SignedData / EnvelopedData encode + decode with PQC signer support (ML-DSA-65 per RFC 9909 §5, ML-DSA-87 per RFC 9909 §6, SLH-DSA-SHAKE-256f per RFC 9881). `b.mail.crypto.smime` builds on it for RFC 8551 S/MIME signed + enveloped mail with `checkCert` for X.509 chain validation. The SECURITY.md Watch list entry that pointed operators to external CMS libraries is gone; operators on regulated mail interop reach for the in-framework primitives instead."
1677
+ }
1678
+ ]
1679
+ }
1680
+ ],
1681
+ "references": []
1682
+ },
1683
+ {
1684
+ "version": "0.12.3",
1685
+ "date": "2026-05-22",
1686
+ "headline": "README \"What ships in the box\" backfill — mail-stack listeners + JSCalendar + new postures",
1687
+ "summary": "The README's \"Communication\" + \"Compliance regimes\" bullets lagged behind the v0.11.24-v0.12.1 ship cadence. Backfilled: `b.mail.send.deliver` (turnkey outbound delivery chain), the four mail-server listeners (mx / submission / imap / jmap), the JMAP EmailSubmission/set reference handler, mail-crypto (CMS + PGP+WKD), the mail-stack agent, `b.calendar` (RFC 8984 JSCalendar substrate with full BY*+BYSETPOS+multi-rule expansion), and the 16 newly-promoted postures from v0.12.1 (`42-cfr-part-2` / `hti-1` / `uscdi-v4` / `irs-1075` / `nist-800-172-r3` / `tlp-2.0` / `soci-au` / `ffiec-cat-2` / `cri-profile-v2.0` / `m-22-09` / `m-22-18` / `nist-800-53-r5-privacy` / `nist-ai-600-1-genai` / `nist-csf-2.0` / `sb-53` / `nyc-ll144-2024`).",
1688
+ "sections": [
1689
+ {
1690
+ "heading": "Changed",
1691
+ "items": [
1692
+ {
1693
+ "title": "Communication section names every mail-stack listener + delivery chain + crypto primitive",
1694
+ "body": "New entries: `b.mail.send.deliver` (MX → MTA-STS → DANE → REQUIRETLS → SMTP → DSN chain), four `b.mail.server.*` listeners, JMAP EmailSubmission reference handler, `b.mail.crypto.cms` + `b.mail.crypto.pgp`, `b.mail.agent` + `b.mailStore`, and `b.calendar` (JSCalendar / iCalendar bridge for JMAP Calendars interop)."
1695
+ },
1696
+ {
1697
+ "title": "Compliance regimes section lists the 16 v0.12.1 backfilled postures",
1698
+ "body": "New rows organise the additions under three sub-bullets: AI governance adds `nyc-ll144-2024` / `sb-53` / `nist-ai-rmf-1.0` / `nist-ai-600-1-genai` alongside the existing AI-act / NYC-LL144 / Colorado / Illinois entries; a new \"Federal / sectoral\" row covers `42-cfr-part-2` / `hti-1` / `uscdi-v4` / `irs-1075` / `nist-csf-2.0` / `nist-800-53-r5-privacy` / `nist-800-172-r3` / `m-22-09` / `m-22-18` / `ffiec-cat-2` / `cri-profile-v2.0`; a new \"Critical infrastructure / info-sharing\" row covers `soci-au` / `tlp-2.0`."
1699
+ }
1700
+ ]
1701
+ }
1702
+ ],
1703
+ "references": []
1704
+ },
1705
+ {
1706
+ "version": "0.12.2",
1707
+ "date": "2026-05-22",
1708
+ "headline": "Release-process docs point at `scripts/release.js` (the orchestrator shipped in v0.12.0)",
1709
+ "summary": "`CONTRIBUTING.md` (maintainer section) and `examples/wiki/DEPLOY.md` (\"Tag-driven releases\") described the old multi-step manual release flow — version bump → commit → push → tag → push tag — without mentioning the v0.12.0 orchestrator. Both docs now point at `node scripts/release.js` as the canonical release mechanism, list the eight idempotent subcommands, and call out the two pre-requisites the script enforces (release-notes JSON + signed-commit config).",
1710
+ "sections": [
1711
+ {
1712
+ "heading": "Changed",
1713
+ "items": [
1714
+ {
1715
+ "title": "`CONTRIBUTING.md` maintainer section names the orchestrator",
1716
+ "body": "The release-process bullet now reads `node scripts/release.js — eight idempotent subcommands (prepare → smoke → commit → push → watch → merge → tag → publish) plus all for a one-shot`. The existing DEPLOY.md link stays as a pointer for the wiki-container side of the same flow."
1717
+ },
1718
+ {
1719
+ "title": "`examples/wiki/DEPLOY.md` Tag-driven releases section rewritten",
1720
+ "body": "Replaces the four-bullet manual flow with the orchestrator surface, including the `all` / `all --minor` one-shot, the per-phase subcommands, and the pre-requisites the script enforces (release-notes JSON present, SSH signing config in place). The downstream wiki-image deploy step on the host (pin `docker-compose.prod.yml` + `docker compose pull && up -d`) is unchanged."
1721
+ }
1722
+ ]
1723
+ },
1724
+ {
1725
+ "heading": "Fixed",
1726
+ "items": [
1727
+ {
1728
+ "title": "`scripts/release.js` signature verification uses `git verify-commit` as the canonical truth",
1729
+ "body": "The v0.12.0 orchestrator's commit-signature gate parsed `git log -1 --pretty=%h %G? %GS` looking for `G` in the second column. On some platforms the `%G?` format token's `?` character can be eaten by argument resolution, returning empty stdout even when the signature is Good. The fix runs `git verify-commit HEAD` (whose exit code is the canonical signal `required_signatures` GH ruleset enforces) as the primary check; the `%G?` parse stays as a human-readable confirmation but no longer gates the script. Surfaced via dogfooding the orchestrator on this very release."
1730
+ },
1731
+ {
1732
+ "title": "`scripts/release.js` Docker bind-mount path handles Windows host paths with spaces",
1733
+ "body": "The `push` phase's gitleaks step bind-mounted the repo root via `-v <path>:/repo`. The previous path transform produced `/C:/Users/...` on Windows, which Docker's `-v src:dst[:mode]` parser interpreted as having three colon-separated fields. Fix: transform `C:\\Users\\...` to `//c/Users/...` (lowercased drive letter, double-slash prefix — matches Git Bash's `$(pwd)` form Docker Desktop accepts). POSIX hosts pass through unchanged. Operators with Windows paths containing spaces, parentheses, or special characters can now run `node scripts/release.js push` without manual mount fiddling."
1734
+ }
1735
+ ]
1736
+ },
1737
+ {
1738
+ "heading": "Added",
1739
+ "items": [
1740
+ {
1741
+ "title": "`scripts/release.js regen` — re-run artifact regeneration mid-flow",
1742
+ "body": "Edits to `release-notes/v<next>.json` after `prepare` (e.g. addressing a Codex finding, fixing a leak-vocabulary refusal) previously required running `node scripts/generate-changelog-entry.js --rebuild` + `scripts/refresh-api-snapshot.js` + `scripts/check-api-snapshot.js` + `scripts/check-changelog-extract.js` manually. The new `regen` subcommand wraps all four into a single idempotent step. Safe to run any time from any branch. The `prepare` phase calls the same shared helper internally so behaviour stays consistent."
1743
+ }
1744
+ ]
1745
+ }
1746
+ ],
1747
+ "references": []
1748
+ },
1749
+ {
1750
+ "version": "0.12.1",
1751
+ "date": "2026-05-22",
1752
+ "headline": "`b.compliance` posture catalog coverage — 65 missing entries backfilled + drift detector",
1753
+ "summary": "Two posture-catalog drifts surfaced during audit: 16 postures had `POSTURE_DEFAULTS` configuration wired but weren't in `KNOWN_POSTURES`, so `b.compliance.set(\"42-cfr-part-2\")` (and 15 others) refused with `bad-posture` despite the cascade defaults existing in the codebase. Separately, 49 `KNOWN_POSTURES` entries had no `REGIME_MAP` record, so `b.compliance.describe(posture)` returned null and admin UI / generated audit reports rendering `\"running under <name> (<citation>)\"` got empty strings. All 65 entries are now backfilled. New codebase-patterns detector enforces `KNOWN_POSTURES ⊇ POSTURE_DEFAULTS` and `REGIME_MAP ⊇ KNOWN_POSTURES` so the same drift class can't reappear.",
1754
+ "sections": [
1755
+ {
1756
+ "heading": "Added",
1757
+ "items": [
1758
+ {
1759
+ "title": "Sixteen postures promoted into `KNOWN_POSTURES`",
1760
+ "body": "`42-cfr-part-2` (Confidentiality of Substance Use Disorder Patient Records), `hti-1` (ONC HTI-1 health-IT certification), `uscdi-v4` (US Core Data for Interoperability), `irs-1075` (Tax Information Security Guidelines), `nist-800-172-r3` (Enhanced CUI Security), `tlp-2.0` (FIRST Traffic Light Protocol), `soci-au` (Australia SOCI Act), `ffiec-cat-2` (FFIEC Cybersecurity Assessment Tool 2.0), `cri-profile-v2.0` (Cyber Risk Institute Profile), `m-22-09` (OMB Zero Trust Strategy), `m-22-18` (OMB Supply Chain SSDF Attestation), `nist-800-53-r5-privacy` (NIST 800-53 Privacy overlay), `nist-ai-600-1-genai` (NIST GenAI Profile), `nist-csf-2.0` (Cybersecurity Framework 2.0), `sb-53` (California Transparency in Frontier AI Act), `nyc-ll144-2024` (NYC AEDT bias audits). Operators pinning these via `b.compliance.set()` now work end-to-end."
1761
+ },
1762
+ {
1763
+ "title": "Forty-nine `REGIME_MAP` records backfilled",
1764
+ "body": "Every `KNOWN_POSTURES` entry now has a `{ name, citation, jurisdiction, domain }` record. Spans US state privacy (vcdpa / co-cpa / ctdpa / ucpa / tdpsa / or-cpa / mt-cdpa / ia-icdpa / in-indpa / de-dpdpa / modpa / wmhmda / bipa / ccpa / nydfs-500), EU regulation (dora / nis2 / cra / ai-act / dsa), international (lgpd-br / pipl-cn / appi-jp / pdpa-sg / pipeda-ca / uk-gdpr / irap / bsi-c5 / ens-es), cybersecurity frameworks (nist-800-53 / nist-csf-2.0 / cis-controls-v8 / cwe-top-25-2024), AI (nist-ai-rmf-1.0 / iso-42001-2023 / iso-23894-2023 / owasp-llm-top-10-2025), supply-chain (slsa-v1.0-build-l3 / cyclonedx-v1.6 / spdx-v3.0 / vex-csaf-2.1 / nist-800-218-ssdf), CMMC levels, and sectoral standards (hipaa-security-rule / hitrust-csf-v11.4 / nerc-cip-007-6 / psd2-rts-sca / swift-cscf-v2026 / iec-62443-3-3 / nist-800-82-r3 / nist-800-63b-rev4 / fda-21cfr11 / fda-annex-11 / sec-1.05 / sox-404 / soc2-cc1.3 / cfpb-1033 / fapi-2.0 / staterramp / uk-g-cloud / hipaa-2026 / quebec-25 / 5 US state student-privacy postures / tcpa-10dlc / iab-tcf-v2.3 / iab-mspa)."
1765
+ }
1766
+ ]
1767
+ },
1768
+ {
1769
+ "heading": "Detectors",
1770
+ "items": [
1771
+ {
1772
+ "title": "Compliance posture coverage gate",
1773
+ "body": "`testCompliancePostureCoverage` enforces two invariants on every release: (1) every `POSTURE_DEFAULTS` key is in `KNOWN_POSTURES` so `b.compliance.set()` accepts it; (2) every `KNOWN_POSTURES` entry has a `REGIME_MAP` record so `b.compliance.describe()` resolves. Each violation reports the specific posture-name + file:line of the bad entry. Future operators adding a posture see the gate fire if either invariant breaks."
1774
+ }
1775
+ ]
1776
+ }
1777
+ ],
1778
+ "references": []
1779
+ },
1780
+ {
1781
+ "version": "0.12.0",
1782
+ "date": "2026-05-22",
1783
+ "headline": "`scripts/release.js` — orchestrated release flow with idempotent subcommands",
1784
+ "summary": "A single script automates the framework's release-flow mechanics. Eight subcommands run in sequence (`prepare` → `smoke` → `commit` → `push` → `watch` → `merge` → `tag` → `publish`), each idempotent so an operator can stop and resume at any phase. The script reads `release-notes/v<next>.json` to drive the commit body + PR body so the same operator-facing content lands in CHANGELOG + commit + PR. The judgment-requiring parts (writing release-notes content, reviewing Codex P1/P2 findings, choosing minor vs patch) stay manual — the script flags + stops on those, never silently chooses for the operator. Minor bump because this is an additive operator-facing surface (a new top-level script + workflow).",
1785
+ "sections": [
1786
+ {
1787
+ "heading": "Added",
1788
+ "items": [
1789
+ {
1790
+ "title": "`node scripts/release.js prepare [--minor]`",
1791
+ "body": "Bumps `package.json` (patch by default, `--minor` for a minor bump), regenerates `CHANGELOG.md` from `release-notes/v<next>.json`, refreshes `api-snapshot.json`, runs `eslint` + `codebase-patterns` + `validate-source-comment-blocks` + `check-api-snapshot` + `check-changelog-extract`. Refuses if the release-notes JSON is missing — prints a stub template to stdout so the operator fills in headline + summary + sections before re-running."
1792
+ },
1793
+ {
1794
+ "title": "`node scripts/release.js smoke`",
1795
+ "body": "Runs `SMOKE_PARALLEL=64 node test/smoke.js`. Auto-detects wiki changes via `git diff --name-only` and runs the wiki e2e suite when `examples/wiki/**` was touched; skips otherwise."
1796
+ },
1797
+ {
1798
+ "title": "`node scripts/release.js commit`",
1799
+ "body": "Creates the `release/v<next>` branch, composes the commit body from the release-notes JSON (headline + summary + sections summarised as bullets), and creates a signed commit. Verifies the signature shows `G` (Good + trusted); refuses with a pointer to the SSH-signing setup section of the deploy docs when it shows `U` (Untrusted) or `N` (Unsigned)."
1800
+ },
1801
+ {
1802
+ "title": "`node scripts/release.js push`",
1803
+ "body": "Runs gitleaks against the whole git history. Pushes the release branch. Opens the PR with title `<version> — <headline>` and a body that includes the release-notes summary + a Test plan checklist. Mounts the working directory via the platform-appropriate Docker bind path (handles Windows Git Bash's `/$(pwd)` quirk)."
1804
+ },
1805
+ {
1806
+ "title": "`node scripts/release.js watch`",
1807
+ "body": "Runs `gh pr checks --watch` then enumerates open review threads via GraphQL. When any Codex (or human) thread is unresolved, prints the per-thread author + first line + exits non-zero so the operator addresses them in a new commit + re-runs watch. When all threads are resolved + CI is clean, the next step (`merge`) becomes the obvious continuation."
1808
+ },
1809
+ {
1810
+ "title": "`node scripts/release.js merge`",
1811
+ "body": "Refuses unless the PR is `mergeStateStatus=CLEAN` + `mergeable=MERGEABLE` + zero unresolved review threads. Squash-merges + deletes the release branch. Pulls main."
1812
+ },
1813
+ {
1814
+ "title": "`node scripts/release.js tag`",
1815
+ "body": "Creates the signed annotated tag `v<version>` + pushes it. Verifies the tag signature reports `Good`. Refuses if the tag already exists locally."
1816
+ },
1817
+ {
1818
+ "title": "`node scripts/release.js publish`",
1819
+ "body": "Watches the npm-publish + release-container workflows triggered by the tag push. Cross-checks `npm view @blamejs/core version` against the expected version; warns if they don't match (workflow may still be in flight or have failed)."
1820
+ },
1821
+ {
1822
+ "title": "`node scripts/release.js all [--minor]`",
1823
+ "body": "Runs all eight subcommands in sequence. Pauses on the watch phase if any review thread is unresolved (operator addresses + re-runs `all` from `watch` onward)."
1824
+ },
1825
+ {
1826
+ "title": "`node scripts/release.js status` + `help`",
1827
+ "body": "`status` reports the current branch, working-tree cleanliness, package version, presence of `release-notes/v<version>.json`, and any open PR for the current release branch. `help` prints the subcommand banner. Both are read-only — safe to run anytime."
1828
+ }
1829
+ ]
1830
+ },
1831
+ {
1832
+ "heading": "Changed",
1833
+ "items": [
1834
+ {
1835
+ "title": "Minor bump (additive surface)",
1836
+ "body": "First minor bump since v0.11.0. The release script is a new top-level operator surface — additive, no existing API breaks. Operators following the previous multi-step release flow keep working unchanged; the script is opt-in."
1837
+ }
1838
+ ]
1839
+ }
1840
+ ],
1841
+ "references": []
1842
+ }
1843
+ ]
1844
+ }