@xemahq/edition-tooling 0.0.0-stage → 0.1.1

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 (219) hide show
  1. package/LICENSE +175 -0
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +47 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/edition.d.ts +4 -0
  7. package/dist/commands/edition.d.ts.map +1 -0
  8. package/dist/commands/edition.js +58 -0
  9. package/dist/commands/edition.js.map +1 -0
  10. package/dist/edition/verify-image-evidence.bundle.mjs +72104 -0
  11. package/dist/edition/verify-image-evidence.bundle.provenance.json +4119 -0
  12. package/dist/lib/exit-codes.d.ts +6 -0
  13. package/dist/lib/exit-codes.d.ts.map +1 -0
  14. package/dist/lib/exit-codes.js +21 -0
  15. package/dist/lib/exit-codes.js.map +1 -0
  16. package/dist/runtime/edition/run-entry.d.ts +10 -0
  17. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  18. package/dist/runtime/edition/run-entry.js +49 -0
  19. package/dist/runtime/edition/run-entry.js.map +1 -0
  20. package/dist/runtime/edition/verb-map.d.ts +70 -0
  21. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  22. package/dist/runtime/edition/verb-map.js +166 -0
  23. package/dist/runtime/edition/verb-map.js.map +1 -0
  24. package/edition/README.md +291 -0
  25. package/edition/adapter-bundled-versions.mjs +128 -0
  26. package/edition/adapter-host-versions.mjs +76 -0
  27. package/edition/assemble-producer-input.mjs +1423 -0
  28. package/edition/assemble-third-party-input.mjs +348 -0
  29. package/edition/authored-source-check.mjs +141 -0
  30. package/edition/authority-binding.mjs +23 -0
  31. package/edition/authorization-base.mjs +357 -0
  32. package/edition/biome-content.mjs +327 -0
  33. package/edition/build-biome-index.mjs +180 -0
  34. package/edition/build-ledger-completeness.mjs +67 -0
  35. package/edition/build-ledger.mjs +107 -0
  36. package/edition/build-platform-service-index.mjs +292 -0
  37. package/edition/build-runtime-image-source-index.mjs +1197 -0
  38. package/edition/built-verifier.mjs +55 -0
  39. package/edition/candidate-edition.mjs +735 -0
  40. package/edition/canonical-evidence-json.mjs +82 -0
  41. package/edition/canonical-repository-url.mjs +61 -0
  42. package/edition/catalogue-edition.mjs +49 -0
  43. package/edition/catalogue-evidence.mjs +228 -0
  44. package/edition/catalogue-fetch.mjs +124 -0
  45. package/edition/catalogue-lock-field.mjs +41 -0
  46. package/edition/catalogue-pin.mjs +71 -0
  47. package/edition/catalogue.mjs +237 -0
  48. package/edition/chart-set-source-commit.mjs +97 -0
  49. package/edition/check-committed-image-evidence.mjs +412 -0
  50. package/edition/checkout-indexed-source-repositories.mjs +283 -0
  51. package/edition/collected-refusals.mjs +59 -0
  52. package/edition/comment-blanker.mjs +75 -0
  53. package/edition/connector-source-authority.mjs +60 -0
  54. package/edition/contract-authority.mjs +122 -0
  55. package/edition/cosign-certificate-constraints.mjs +50 -0
  56. package/edition/cross-repo-build-inputs.mjs +158 -0
  57. package/edition/customer-definition.mjs +72 -0
  58. package/edition/customer-edition-verbs.mjs +610 -0
  59. package/edition/customer-flags.mjs +15 -0
  60. package/edition/definition-checkout.mjs +96 -0
  61. package/edition/develop-build-record.mjs +122 -0
  62. package/edition/develop-catalogue-free-resolve.mjs +282 -0
  63. package/edition/develop-catalogue-free-vendor.mjs +118 -0
  64. package/edition/develop-channel-evidence.mjs +23 -0
  65. package/edition/develop-channel-record.mjs +49 -0
  66. package/edition/develop-channel-satellites.mjs +65 -0
  67. package/edition/develop-chart-roster.mjs +96 -0
  68. package/edition/develop-chart-set.mjs +147 -0
  69. package/edition/develop-current-resolution.mjs +29 -0
  70. package/edition/develop-edition-invariants.mjs +142 -0
  71. package/edition/develop-edition-resolve.mjs +250 -0
  72. package/edition/develop-edition.mjs +332 -0
  73. package/edition/develop-engine-image.mjs +97 -0
  74. package/edition/develop-freeze.mjs +304 -0
  75. package/edition/develop-git-bundles.mjs +296 -0
  76. package/edition/develop-graph.mjs +34 -0
  77. package/edition/develop-image-coordinate.mjs +85 -0
  78. package/edition/develop-image-probe.mjs +122 -0
  79. package/edition/develop-image-record.mjs +260 -0
  80. package/edition/develop-resolve-report.mjs +159 -0
  81. package/edition/develop-select-records.mjs +90 -0
  82. package/edition/develop-selection-line.mjs +29 -0
  83. package/edition/develop-selection-pins.mjs +36 -0
  84. package/edition/develop-snapshot-inputs.mjs +112 -0
  85. package/edition/develop-store-root.mjs +20 -0
  86. package/edition/dispatched-runtime-components.mjs +172 -0
  87. package/edition/dist-check.mjs +1174 -0
  88. package/edition/distribution-evidence-set.mjs +1064 -0
  89. package/edition/distribution-paths.mjs +191 -0
  90. package/edition/edition-artifact.mjs +529 -0
  91. package/edition/edition-biome-manifests.mjs +85 -0
  92. package/edition/edition-ceiling.mjs +182 -0
  93. package/edition/edition-channel.mjs +27 -0
  94. package/edition/edition-chart-set.mjs +104 -0
  95. package/edition/edition-config.mjs +374 -0
  96. package/edition/edition-constants.mjs +110 -0
  97. package/edition/edition-contract-parse.mjs +179 -0
  98. package/edition/edition-contract.json +100 -0
  99. package/edition/edition-image-seal.mjs +115 -0
  100. package/edition/edition-kind.mjs +10 -0
  101. package/edition/edition-list.mjs +35 -0
  102. package/edition/edition-locks.mjs +78 -0
  103. package/edition/edition-pin-verbs.mjs +183 -0
  104. package/edition/edition-pin.mjs +17 -0
  105. package/edition/edition-pinned-inputs.mjs +116 -0
  106. package/edition/edition-protocol.mjs +285 -0
  107. package/edition/edition-publisher.mjs +21 -0
  108. package/edition/edition-seal.mjs +257 -0
  109. package/edition/edition-source-pins.mjs +211 -0
  110. package/edition/edition-unpin.mjs +16 -0
  111. package/edition/edition-workspace.mjs +205 -0
  112. package/edition/evidence-checkout.mjs +439 -0
  113. package/edition/evidence-grammars.mjs +18 -0
  114. package/edition/evidence-input-mode.mjs +136 -0
  115. package/edition/evidence-trust-pin.mjs +392 -0
  116. package/edition/evidence-trust-policy.mjs +374 -0
  117. package/edition/external-source-walk.mjs +486 -0
  118. package/edition/first-party-source-ids.mjs +62 -0
  119. package/edition/fleet-coordinate-finality.mjs +363 -0
  120. package/edition/fulcio-certificate-extensions.mjs +278 -0
  121. package/edition/git-json.mjs +54 -0
  122. package/edition/git-source-auth.mjs +131 -0
  123. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  124. package/edition/image-evidence-replay-state.mjs +242 -0
  125. package/edition/image-evidence.mjs +1328 -0
  126. package/edition/image-lock-service-rows.mjs +26 -0
  127. package/edition/image-subject-identity.mjs +19 -0
  128. package/edition/images-from-record.mjs +216 -0
  129. package/edition/indexes-at-pins.mjs +13 -0
  130. package/edition/infra-images-lock.mjs +105 -0
  131. package/edition/kernel-capability-domain.mjs +30 -0
  132. package/edition/kernel-contract-authority.mjs +526 -0
  133. package/edition/kernel-contract-source-path-set.mjs +96 -0
  134. package/edition/kernel-package.mjs +237 -0
  135. package/edition/kernel-pin-source.mjs +63 -0
  136. package/edition/kernel-protocol-choice.mjs +239 -0
  137. package/edition/kernel-signer-kind.mjs +23 -0
  138. package/edition/lib.mjs +3322 -0
  139. package/edition/locked-bundle-signer.mjs +297 -0
  140. package/edition/map-with-concurrency.mjs +19 -0
  141. package/edition/no-follow-file.mjs +94 -0
  142. package/edition/oci-evidence-verbs.mjs +421 -0
  143. package/edition/oci-image-source-evidence.mjs +377 -0
  144. package/edition/operation-grant-admission.mjs +285 -0
  145. package/edition/operation-requirements.mjs +282 -0
  146. package/edition/oras-process.mjs +35 -0
  147. package/edition/oras.mjs +664 -0
  148. package/edition/pin-source.mjs +242 -0
  149. package/edition/pinned-cosign.mjs +86 -0
  150. package/edition/plan-image-evidence-refresh.mjs +428 -0
  151. package/edition/platform-lock-readability.mjs +903 -0
  152. package/edition/platform-service-class.mjs +85 -0
  153. package/edition/platform-service-ownership.mjs +91 -0
  154. package/edition/platform-service-source-authority.mjs +105 -0
  155. package/edition/positive-closure.mjs +162 -0
  156. package/edition/previous-image-lock.mjs +25 -0
  157. package/edition/produce-distribution-completeness-evidence.mjs +317 -0
  158. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  159. package/edition/produce-image-delivery-entitlement-evidence.mjs +302 -0
  160. package/edition/produce-image-source-evidence.mjs +788 -0
  161. package/edition/producer-evidence-bundle.mjs +201 -0
  162. package/edition/producer-evidence-index-file.mjs +4 -0
  163. package/edition/record-refused.mjs +11 -0
  164. package/edition/registry-client-factory.mjs +21 -0
  165. package/edition/registry-credentials.mjs +225 -0
  166. package/edition/release-catalogue.mjs +48 -0
  167. package/edition/release-channel-guard.mjs +108 -0
  168. package/edition/release-edition-artifact.mjs +152 -0
  169. package/edition/release-edition.mjs +54 -0
  170. package/edition/remedy-command.mjs +89 -0
  171. package/edition/repin-evidence-trust.mjs +109 -0
  172. package/edition/required-resource-provider.mjs +58 -0
  173. package/edition/resolve-chart-set.mjs +376 -0
  174. package/edition/resolve-customer-edition.mjs +374 -0
  175. package/edition/resolve-distribution.mjs +2642 -0
  176. package/edition/resolve-edition.mjs +192 -0
  177. package/edition/resolve-images.mjs +2344 -0
  178. package/edition/resolve-infra-images.mjs +88 -0
  179. package/edition/seal-image-digests.mjs +954 -0
  180. package/edition/sealed-distribution-lock.mjs +22 -0
  181. package/edition/sealed-evidence-accept.mjs +82 -0
  182. package/edition/shallow-checkout.mjs +18 -0
  183. package/edition/sigstore-bundle-media-type.mjs +4 -0
  184. package/edition/source-authority.mjs +713 -0
  185. package/edition/source-closure.mjs +115 -0
  186. package/edition/store-publisher-trust.mjs +40 -0
  187. package/edition/strict-json.mjs +121 -0
  188. package/edition/string-order.mjs +5 -0
  189. package/edition/third-party-admission.mjs +118 -0
  190. package/edition/third-party-bundle-registry.mjs +349 -0
  191. package/edition/third-party-certificate-extensions.mjs +58 -0
  192. package/edition/third-party-image-pins.mjs +41 -0
  193. package/edition/third-party-pins-trusted.mjs +24 -0
  194. package/edition/third-party-producer-evidence.mjs +76 -0
  195. package/edition/third-party-source-check.mjs +28 -0
  196. package/edition/third-party-source-context.mjs +41 -0
  197. package/edition/third-party-source-evidence.mjs +326 -0
  198. package/edition/third-party-source-flags.mjs +148 -0
  199. package/edition/third-party-source-pins.mjs +270 -0
  200. package/edition/third-party-source-uniqueness.mjs +54 -0
  201. package/edition/third-party-source-walk.mjs +118 -0
  202. package/edition/third-party-source-writer.mjs +228 -0
  203. package/edition/third-party-subjects.mjs +128 -0
  204. package/edition/trust-policy-arming-verb.mjs +60 -0
  205. package/edition/trust-policy-arming.mjs +144 -0
  206. package/edition/trust-policy-image-ownership.mjs +85 -0
  207. package/edition/trusted-executable.mjs +166 -0
  208. package/edition/verifier-binding.mjs +45 -0
  209. package/edition/verifier-digest-pin.mjs +17 -0
  210. package/edition/verifier-files.mjs +8 -0
  211. package/edition/verifier-kernel-schemas.mjs +22 -0
  212. package/edition/verifier-provenance.mjs +64 -0
  213. package/edition/verify-catalogue-evidence.mjs +247 -0
  214. package/edition/verify-image-evidence.mjs +493 -0
  215. package/edition/verify-image-platforms.mjs +523 -0
  216. package/edition/why.mjs +56 -0
  217. package/edition/workspace-sections.mjs +287 -0
  218. package/package.json +83 -4
  219. package/README.md +0 -3
@@ -0,0 +1,363 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // fleet-coordinate-finality — has this coordinate FINISHED MOVING?
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ---------------
6
+ // A seal pins what a MUTABLE TAG currently resolves to. `imageRef:contentHash`
7
+ // is a build-input key, not a content address: two builds of one input set wear
8
+ // the same tag, and the arm64 lane REPUBLISHES that tag by design —
9
+ // `imagetools create -t :<contentHash>` writes a NEW index at the SAME
10
+ // coordinate. So sealing a coordinate that is still awaiting its arm64
11
+ // supplement pins a manifest the tag will stop serving.
12
+ //
13
+ // THE REGRESSION THIS WAS WRITTEN FOR, measured — and how it is now graded
14
+ // -----------------------------------------------------------------------
15
+ // `xema-internal` was sealed at 09:25 on 2026-09-18 while 40 of its coordinates
16
+ // were still amd64-only, awaiting the supplement. The arm64 backfill — the
17
+ // CORRECT repair — then moved those digests, and 63 of its 66 seals named a
18
+ // manifest their tag no longer served.
19
+ //
20
+ // Its own per-edition verification PASSED, and passed CORRECTLY: `xema-internal`
21
+ // declares `requiredPlatforms: ["linux/amd64"]`, and amd64-only satisfies that.
22
+ // No rule the sealer could evaluate was violated.
23
+ //
24
+ // WHY A PER-EDITION CHECK CANNOT CATCH IT — the whole point of this module
25
+ // -----------------------------------------------------------------------
26
+ // `appliance-base` and `appliance-lean` declare `linux/arm64`, and they share 63
27
+ // of those coordinates with `xema-internal` by identical `imageRef` AND
28
+ // `contentHash`. So `xema-internal`'s stability depended on a platform
29
+ // requirement declared in an edition it cannot see.
30
+ //
31
+ // > PLATFORM VERIFICATION IS PER-EDITION. FINALITY IS A FLEET PROPERTY.
32
+ //
33
+ // OWNER RULING 2026-09-29 NARROWS WHAT IS REFUSED. Finality is graded against
34
+ // the sealing edition's OWN requiredPlatforms. The xema-internal shape above —
35
+ // an amd64-only edition sealing while another edition's arm64 is pending — is
36
+ // now ALLOWED and RECORDED (`pendingElsewhere`), the residual accepted: its
37
+ // sealed digest stays resolvable and serves every platform it requires. A
38
+ // coordinate pending a platform the sealing edition itself declares is still
39
+ // refused. The fleet map is still built from every edition, because the
40
+ // recorded residual must name who is waiting.
41
+ //
42
+ // A per-edition sealer cannot express that even in principle. This module is the
43
+ // fleet-scoped half, and it lives HERE — beside the sealer, in the repository
44
+ // that holds every edition's lock — because that is where the data and the edit
45
+ // both are. An aggregator gate would grade this repository at a RECORDED
46
+ // GITLINK: it would fire late, in a repository nobody was editing, about a
47
+ // change made somewhere else.
48
+ //
49
+ // IT RESOLVES NOTHING ITSELF
50
+ // --------------------------
51
+ // `verifyDistributionPlatforms` already fetches each index, hash-checks the
52
+ // bytes, walks every child manifest BY DIGEST and reads each image CONFIG for
53
+ // its real platform — and it already returns `publishedPlatforms`, the FULL set
54
+ // the coordinate serves rather than only the subset this edition required. That
55
+ // return value is the input here. There is no second registry walk and no
56
+ // second opinion about what a coordinate serves.
57
+ //
58
+ // WHAT IT DOES NOT COVER, said out loud rather than discovered
59
+ // -----------------------------------------------------------
60
+ // The fleet is the EDITIONS ROOT IT IS GIVEN. Run against a customer's own root
61
+ // it grades that customer's editions and cannot see the vendor's, because they
62
+ // are not there to read. That is why every verdict prints its corpus — the
63
+ // number of editions and coordinates graded — so the scope of an answer is
64
+ // visible in the answer rather than assumed from the fact that it passed.
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+
67
+ import { existsSync, readFileSync } from 'node:fs';
68
+ import { join } from 'node:path';
69
+
70
+ import { discoverEditionDirectories } from './distribution-paths.mjs';
71
+ import { platformIdentity } from './verify-image-platforms.mjs';
72
+
73
+ export const IMAGE_LOCK_FILENAME = 'images.lock.json';
74
+
75
+ /**
76
+ * The coordinate a subject names, in the ONE spelling the verifier uses.
77
+ *
78
+ * `verifyDistributionPlatforms` builds `${imageRef}:${contentHash}` and returns
79
+ * it as `coordinate` on every verified subject. This function must produce the
80
+ * same string for the lock-side map, or the two halves would silently fail to
81
+ * join and every lookup would miss — which is a refusal that grades nothing
82
+ * while reporting success. The join is asserted rather than trusted: see the
83
+ * unknown-coordinate throw in {@link assertCoordinatesFinal}.
84
+ */
85
+ export function coordinateOf(subject) {
86
+ return `${subject.imageRef}:${subject.contentHash}`;
87
+ }
88
+
89
+ /**
90
+ * Pure: every platform any edition requires at each coordinate, and WHO
91
+ * requires it.
92
+ *
93
+ * `editions` is `[{ distributionId, subjects }]` — already read, so the
94
+ * decision table is testable without a filesystem or a registry.
95
+ *
96
+ * The returned shape is `Map<coordinate, Map<platformIdentity, string[]>>`,
97
+ * where the array is the sorted edition ids requiring that platform there. The
98
+ * REQUIRERS are carried rather than only the platforms because a refusal that
99
+ * cannot name who is still waiting sends the reader to look for the reason
100
+ * themselves, and the reason is in another edition's declaration.
101
+ */
102
+ export function collectFleetPlatformRequirements({ editions, isThirdPartySubject = () => false }) {
103
+ if (!Array.isArray(editions) || editions.length === 0) {
104
+ throw new Error(
105
+ 'fleet-coordinate-finality: no edition was supplied; a fleet requirement map ' +
106
+ 'built over zero editions would make every coordinate look final, which is ' +
107
+ 'the refusal passing while grading nothing',
108
+ );
109
+ }
110
+ const byCoordinate = new Map();
111
+ for (const edition of editions) {
112
+ const { distributionId, subjects } = edition;
113
+ if (typeof distributionId !== 'string' || distributionId.length === 0) {
114
+ throw new Error(
115
+ 'fleet-coordinate-finality: an edition was supplied with no distributionId',
116
+ );
117
+ }
118
+ if (!Array.isArray(subjects) || subjects.length === 0) {
119
+ throw new Error(
120
+ `fleet-coordinate-finality: "${distributionId}" has an image lock declaring no ` +
121
+ 'subjects. An edition that contributes no coordinate is indistinguishable ' +
122
+ 'from one this pass failed to read, so it is refused rather than skipped.',
123
+ );
124
+ }
125
+ for (const subject of subjects) {
126
+ if (
127
+ !Array.isArray(subject.requiredPlatforms) ||
128
+ subject.requiredPlatforms.length === 0
129
+ ) {
130
+ throw new Error(
131
+ `fleet-coordinate-finality: "${distributionId}" subject ${subject.kind}:${subject.id} ` +
132
+ 'declares no requiredPlatforms. It would contribute nothing to the fleet map ' +
133
+ 'and silently weaken the refusal for every edition sharing its coordinate.',
134
+ );
135
+ }
136
+ // A third-party subject is read by its pinned digest, not at `<imageRef>:<contentHash>` (its contentHash is
137
+ // publisher-attested, not a tag), and no fleet lane supplements it: it contributes no first-party coordinate.
138
+ if (isThirdPartySubject(subject)) {continue;}
139
+ const coordinate = coordinateOf(subject);
140
+ let platforms = byCoordinate.get(coordinate);
141
+ if (!platforms) {
142
+ platforms = new Map();
143
+ byCoordinate.set(coordinate, platforms);
144
+ }
145
+ for (const platform of subject.requiredPlatforms) {
146
+ const identity = platformIdentity(platform);
147
+ let requirers = platforms.get(identity);
148
+ if (!requirers) {
149
+ requirers = new Set();
150
+ platforms.set(identity, requirers);
151
+ }
152
+ requirers.add(distributionId);
153
+ }
154
+ }
155
+ }
156
+ // Freeze the requirer sets into sorted arrays so a message built from this map
157
+ // is deterministic, and so a caller cannot mutate the map it was handed.
158
+ return new Map(
159
+ [...byCoordinate].map(([coordinate, platforms]) => [
160
+ coordinate,
161
+ new Map(
162
+ [...platforms].map(([identity, requirers]) => [
163
+ identity,
164
+ [...requirers].sort(),
165
+ ]),
166
+ ),
167
+ ]),
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Read every edition's image lock under `editionsRoot`, in BOTH tiers.
173
+ *
174
+ * A pending-merge edition is a real edition that will ship its coordinates, so
175
+ * a platform IT requires is just as much a reason a coordinate can still move.
176
+ * Measured 2026-09-19: the two pending editions carry a declaration and no lock,
177
+ * so today this widening admits nothing — which is the point at which to add it,
178
+ * rather than after a pending edition has acquired one.
179
+ *
180
+ * TWO ABSENCES THAT ARE NOT THE SAME, and conflating them is how this check
181
+ * would quietly stop working:
182
+ *
183
+ * * NO LOCK FILE — the edition makes no coordinate claim at all. It
184
+ * contributes nothing, legitimately, and is COUNTED and reported so the
185
+ * corpus line shows it was seen and skipped.
186
+ * * A LOCK THAT CANNOT BE READ — refused. An unreadable lock contributes no
187
+ * requirements either, and the difference between "requires nothing" and
188
+ * "we failed to find out" is the whole value of the refusal.
189
+ *
190
+ * `JSON.parse` rather than the kernel's `parseImageLock` is deliberate. Only
191
+ * three fields are read — `imageRef`, `contentHash`, `requiredPlatforms` — and
192
+ * requiring a BUILT KERNEL to grade the OTHER editions would make this refusal
193
+ * unrunnable in exactly the checkouts that have no kernel to build. The edition
194
+ * being sealed is still parsed by the kernel on the path that seals it.
195
+ */
196
+ export function readFleetImageLocks(editionsRoot) {
197
+ const editions = [];
198
+ const withoutLock = [];
199
+ for (const { id, directory } of discoverEditionDirectories(editionsRoot)) {
200
+ const lockPath = join(directory, IMAGE_LOCK_FILENAME);
201
+ if (!existsSync(lockPath)) {
202
+ withoutLock.push(id);
203
+ continue;
204
+ }
205
+ let raw;
206
+ try {
207
+ raw = JSON.parse(readFileSync(lockPath, 'utf8'));
208
+ } catch (error) {
209
+ throw new Error(
210
+ `fleet-coordinate-finality: cannot read ${lockPath}: ${error.message}. ` +
211
+ 'Refusing to grade coordinate finality over a fleet this pass could not ' +
212
+ 'fully read — an unreadable lock declares no requirement, which would make ' +
213
+ 'a coordinate look final because it was never asked about.',
214
+ );
215
+ }
216
+ editions.push({ distributionId: id, subjects: raw.subjects });
217
+ }
218
+ if (editions.length === 0) {
219
+ throw new Error(
220
+ `fleet-coordinate-finality: found no image lock under ${editionsRoot}; an empty ` +
221
+ 'scan is not a result',
222
+ );
223
+ }
224
+ return { editions, withoutLock: withoutLock.sort() };
225
+ }
226
+
227
+ /**
228
+ * Refuse to seal a coordinate THIS edition still awaits a platform at; record —
229
+ * and allow — one only ANOTHER edition still awaits.
230
+ *
231
+ * Owner ruling 2026-09-29: finality is TARGET-PLATFORM aware. A coordinate
232
+ * pending only a platform this edition does not declare is the accepted
233
+ * residual, returned as `pendingElsewhere` so the caller prints it. A later
234
+ * supplement for that other edition may move the tag's digest; this edition's
235
+ * seal keeps pinning the manifest it verified, which still resolves by digest
236
+ * and still carries every platform this edition requires.
237
+ *
238
+ * `verified` is `verifyDistributionPlatforms(...).subjects`, each carrying
239
+ * `coordinate` and `publishedPlatforms` — the platforms the registry ACTUALLY
240
+ * serves there, which is strictly wider than the subset this edition required.
241
+ *
242
+ * THE SUBJECT SET IS THE WRITES, NOT THE LOCK. This grades the subjects about to
243
+ * RECEIVE a digest, because that is the moment the regression is introduced. An
244
+ * already-sealed subject is past it: re-refusing one would make the daily
245
+ * reconciler permanently red with no action available to clear it, and a gate
246
+ * that cannot be cleared is one people learn to bypass.
247
+ *
248
+ * THIRD-PARTY SUBJECTS ARE EXCLUDED, NAMED. `thirdPartyKeys` (`kind:id`) are the pinned third-party subjects of this
249
+ * edition: each is read by its pinned manifest digest (an immutable coordinate no fleet lane supplements), so it has no
250
+ * first-party fleet coordinate to grade. They are returned as `thirdParty` so the caller prints them.
251
+ *
252
+ * Throws with `error.notFinal === true` — a PROPERTY, never a message match, so
253
+ * the reconciler's skip cannot silently widen the day this wording changes. Same
254
+ * discipline as `error.status` on the not-published-yet path.
255
+ */
256
+ export function assertCoordinatesFinal({
257
+ distributionId,
258
+ writes: plannedWrites,
259
+ verified,
260
+ fleetRequirements,
261
+ thirdPartyKeys = new Set(),
262
+ }) {
263
+ const graded = [];
264
+ const pendingElsewhere = [];
265
+ if (!Array.isArray(plannedWrites)) {
266
+ return { distributionId, graded, coordinateCount: 0, pendingElsewhere, thirdParty: [] };
267
+ }
268
+ const thirdParty = plannedWrites.filter((write) => thirdPartyKeys.has(write.key)).map((write) => write.key);
269
+ const writes = plannedWrites.filter((write) => !thirdPartyKeys.has(write.key));
270
+ if (writes.length === 0) {
271
+ return { distributionId, graded, coordinateCount: 0, pendingElsewhere, thirdParty };
272
+ }
273
+ const byKey = new Map(
274
+ verified.map((entry) => [`${entry.subjectKind}:${entry.subjectId}`, entry]),
275
+ );
276
+ const blockers = [];
277
+ for (const write of writes) {
278
+ const entry = byKey.get(write.key);
279
+ if (!entry) {
280
+ throw new Error(
281
+ `fleet-coordinate-finality: ${write.key} is planned for sealing but has no ` +
282
+ 'verification result to read published platforms from; refusing to grade ' +
283
+ 'finality for a subject the verifier did not cover',
284
+ );
285
+ }
286
+ const required = fleetRequirements.get(entry.coordinate);
287
+ if (!required) {
288
+ // NON-VACUITY GUARD. The edition being sealed is itself one of the
289
+ // editions read into the map, so every coordinate it seals MUST be in
290
+ // there. A miss means the two halves are keyed differently and every
291
+ // lookup would return nothing — a refusal that passes while grading
292
+ // nothing, which is this tree's most-repeated defect.
293
+ throw new Error(
294
+ `fleet-coordinate-finality: coordinate ${entry.coordinate} (${write.key}) is absent ` +
295
+ 'from the fleet requirement map, which cannot happen while the edition being ' +
296
+ 'sealed is itself in that map. The lock-side and verifier-side coordinate ' +
297
+ 'spellings have diverged; refusing rather than passing over a lookup that ' +
298
+ 'silently misses.',
299
+ );
300
+ }
301
+ const served = new Set(entry.publishedPlatforms ?? []);
302
+ const unmet = [...required]
303
+ .filter(([identity]) => !served.has(identity))
304
+ .map(([identity, requirers]) => ({ platform: identity, requiredBy: requirers }));
305
+ graded.push({ key: write.key, coordinate: entry.coordinate });
306
+ // TARGET-PLATFORM AWARE (owner ruling 2026-09-29). Finality is graded
307
+ // against THIS edition's own requiredPlatforms: a platform only ANOTHER
308
+ // edition awaits here is the accepted residual — allowed, and RECORDED so
309
+ // the caller can print it. A platform this edition itself declares and the
310
+ // registry does not serve is still refused.
311
+ const own = unmet.filter((u) => u.requiredBy.includes(distributionId));
312
+ const foreign = unmet.filter((u) => !u.requiredBy.includes(distributionId));
313
+ if (foreign.length > 0) {
314
+ pendingElsewhere.push({
315
+ key: write.key,
316
+ coordinate: entry.coordinate,
317
+ unmet: foreign,
318
+ served: [...served].sort(),
319
+ });
320
+ }
321
+ if (own.length > 0) {
322
+ blockers.push({ key: write.key, coordinate: entry.coordinate, unmet: own, served: [...served].sort() });
323
+ }
324
+ }
325
+ if (blockers.length > 0) {
326
+ const detail = blockers
327
+ .slice(0, 6)
328
+ .map(
329
+ (b) =>
330
+ ` ${b.key} ${b.coordinate}\n` +
331
+ b.unmet
332
+ .map(
333
+ (u) =>
334
+ ` awaits ${u.platform}, required by ${u.requiredBy.join(', ')} ` +
335
+ `(serves: ${b.served.join(', ') || 'nothing'})`,
336
+ )
337
+ .join('\n'),
338
+ )
339
+ .join('\n');
340
+ const error = new Error(
341
+ `fleet-coordinate-finality: "${distributionId}" cannot be sealed — ${blockers.length} of ` +
342
+ `${writes.length} coordinate(s) are NOT FINAL for a platform THIS edition requires: ` +
343
+ 'the registry does not yet serve it there, so the coordinate will be supplemented ' +
344
+ '— and the arm64 lane supplements by REPUBLISHING the tag (`imagetools create -t ' +
345
+ ':<contentHash>`), which would move the very digest this seal is about to pin.\n' +
346
+ `${detail}${blockers.length > 6 ? `\n ... and ${blockers.length - 6} more` : ''}\n` +
347
+ 'Complete the supplement for this edition\'s own platforms, then seal.',
348
+ );
349
+ error.notFinal = true;
350
+ error.blockers = blockers;
351
+ throw error;
352
+ }
353
+ return { distributionId, graded, coordinateCount: graded.length, pendingElsewhere, thirdParty };
354
+ }
355
+
356
+ /**
357
+ * Is this failure "another edition can still supplement this coordinate"?
358
+ *
359
+ * A PROPERTY, never a message match — see {@link assertCoordinatesFinal}.
360
+ */
361
+ export function isNotFinalYet(error) {
362
+ return Boolean(error) && error.notFinal === true;
363
+ }
@@ -0,0 +1,278 @@
1
+ // The Fulcio v2 certificate extensions cosign has no verification flag for, read from the
2
+ // leaf certificate of a sigstore bundle with a small DER walker (Node's X509Certificate does
3
+ // not expose arbitrary extensions).
4
+ //
5
+ // 1.3.6.1.4.1.57264.1.9 Build Signer URI (the workflow whose job ran: GitHub's job_workflow_ref)
6
+ // 1.3.6.1.4.1.57264.1.11 Runner Environment
7
+ // 1.3.6.1.4.1.57264.1.15 Source Repository Identifier
8
+ // 1.3.6.1.4.1.57264.1.17 Source Repository Owner Identifier
9
+ // 1.3.6.1.4.1.57264.1.18 Build Config URI (the top-level workflow of the run: GitHub's workflow_ref)
10
+ //
11
+ // The three source/runner extensions are always read; the two build URIs only when asked for
12
+ // (`readFulcioBuildIdentity`), so a reader of the three needs nothing more of a certificate.
13
+ //
14
+ // Each is the v2 form: the extension's OCTET STRING contains a DER UTF8String. The legacy raw
15
+ // forms (.1 to .6, which put the bare bytes in the OCTET STRING) are never read. An absent
16
+ // extension, a repeated one or one that is not a UTF8String is refused naming its OID.
17
+ //
18
+ // The certificate is the one cosign verified: the bytes come from the bundle that was verified
19
+ // (`verificationMaterial.certificate.rawBytes`, else the first
20
+ // `verificationMaterial.x509CertificateChain.certificates[].rawBytes`), never from a second fetch.
21
+
22
+ const TAG_SEQUENCE = 0x30;
23
+ const TAG_OID = 0x06;
24
+ const TAG_BOOLEAN = 0x01;
25
+ const TAG_OCTET_STRING = 0x04;
26
+ const TAG_UTF8_STRING = 0x0c;
27
+ const TAG_CONTEXT_0 = 0xa0;
28
+ const TAG_CONTEXT_1 = 0xa1;
29
+ const TAG_CONTEXT_2 = 0xa2;
30
+ const TAG_CONTEXT_3 = 0xa3;
31
+
32
+ export const FulcioExtensionOid = Object.freeze({
33
+ RunnerEnvironment: '1.3.6.1.4.1.57264.1.11',
34
+ SourceRepositoryIdentifier: '1.3.6.1.4.1.57264.1.15',
35
+ SourceRepositoryOwnerIdentifier: '1.3.6.1.4.1.57264.1.17',
36
+ });
37
+
38
+ /** The Fulcio build identity extensions, read only by `readFulcioBuildIdentity`. */
39
+ export const FulcioBuildOid = Object.freeze({
40
+ BuildSignerUri: '1.3.6.1.4.1.57264.1.9',
41
+ BuildConfigUri: '1.3.6.1.4.1.57264.1.18',
42
+ });
43
+
44
+ /** The Runner Environment (.11) values Fulcio issues for GitHub Actions: the closed domain of a runner declaration. */
45
+ export const RunnerEnvironment = Object.freeze({
46
+ GithubHosted: 'github-hosted',
47
+ SelfHosted: 'self-hosted',
48
+ });
49
+
50
+ /**
51
+ * THE ONE parser of a declared runner environment (the pin's `runnerEnvironment`, the writer's and the publisher's
52
+ * `--runner-environment`). Absent (`undefined`) is the default and returns `undefined`; the only value a declaration
53
+ * may state is `self-hosted`, so the default has no second spelling. Anything else throws naming `what`.
54
+ *
55
+ * @returns {'self-hosted'|undefined}
56
+ */
57
+ export function parseDeclaredRunnerEnvironment(value, what) {
58
+ if (value === undefined || value === RunnerEnvironment.SelfHosted) {
59
+ return value;
60
+ }
61
+ throw new Error(
62
+ `${what} must be "${RunnerEnvironment.SelfHosted}" when present (absent means ${RunnerEnvironment.GithubHosted}); got ${JSON.stringify(value)}`,
63
+ );
64
+ }
65
+
66
+ const OID_FIELDS = Object.freeze([
67
+ ['runnerEnvironment', FulcioExtensionOid.RunnerEnvironment],
68
+ ['sourceRepositoryIdentifier', FulcioExtensionOid.SourceRepositoryIdentifier],
69
+ ['sourceRepositoryOwnerIdentifier', FulcioExtensionOid.SourceRepositoryOwnerIdentifier],
70
+ ]);
71
+ const BUILD_OID_FIELDS = Object.freeze([
72
+ ['buildSignerUri', FulcioBuildOid.BuildSignerUri],
73
+ ['buildConfigUri', FulcioBuildOid.BuildConfigUri],
74
+ ]);
75
+
76
+ /** One DER element at `offset`: its tag, content bounds and the offset after it. Indefinite lengths are refused (DER). */
77
+ function readElement(bytes, offset, end, what) {
78
+ if (offset + 2 > end) {
79
+ throw new Error(`certificate DER is truncated at ${what}`);
80
+ }
81
+ const tag = bytes[offset];
82
+ if ((tag & 0x1f) === 0x1f) {
83
+ throw new Error(`certificate DER has a multi-byte tag at ${what}`);
84
+ }
85
+ let length = bytes[offset + 1];
86
+ let contentStart = offset + 2;
87
+ if (length === 0x80) {
88
+ throw new Error(`certificate DER has an indefinite length at ${what}`);
89
+ }
90
+ if (length > 0x80) {
91
+ const count = length & 0x7f;
92
+ if (count > 4 || contentStart + count > end) {
93
+ throw new Error(`certificate DER has an invalid length at ${what}`);
94
+ }
95
+ length = 0;
96
+ for (let index = 0; index < count; index += 1) {
97
+ length = length * 256 + bytes[contentStart + index];
98
+ }
99
+ contentStart += count;
100
+ }
101
+ const contentEnd = contentStart + length;
102
+ if (contentEnd > end) {
103
+ throw new Error(`certificate DER overruns its container at ${what}`);
104
+ }
105
+ return { tag, contentStart, contentEnd };
106
+ }
107
+
108
+ function expectElement(bytes, offset, end, tag, what) {
109
+ const element = readElement(bytes, offset, end, what);
110
+ if (element.tag !== tag) {
111
+ throw new Error(`certificate DER: ${what} has tag 0x${element.tag.toString(16)}, expected 0x${tag.toString(16)}`);
112
+ }
113
+ return element;
114
+ }
115
+
116
+ function decodeOid(bytes, start, end) {
117
+ if (end <= start) {
118
+ throw new Error('certificate DER has an empty OBJECT IDENTIFIER');
119
+ }
120
+ const arcs = [];
121
+ let value = 0;
122
+ for (let index = start; index < end; index += 1) {
123
+ value = value * 128 + (bytes[index] & 0x7f);
124
+ if ((bytes[index] & 0x80) === 0) {
125
+ if (arcs.length === 0) {
126
+ const first = Math.min(Math.floor(value / 40), 2);
127
+ arcs.push(first, value - first * 40);
128
+ } else {
129
+ arcs.push(value);
130
+ }
131
+ value = 0;
132
+ }
133
+ }
134
+ return arcs.join('.');
135
+ }
136
+
137
+ /** The extensions of the certificate: `Map<oid, {critical, value: Buffer}>` where `value` is the OCTET STRING content. */
138
+ function certificateExtensions(der) {
139
+ const certificate = expectElement(der, 0, der.length, TAG_SEQUENCE, 'Certificate');
140
+ if (certificate.contentEnd !== der.length) {
141
+ throw new Error('certificate DER has trailing bytes after the Certificate');
142
+ }
143
+ const tbs = expectElement(der, certificate.contentStart, certificate.contentEnd, TAG_SEQUENCE, 'TBSCertificate');
144
+ let offset = tbs.contentStart;
145
+ let element = readElement(der, offset, tbs.contentEnd, 'TBSCertificate field');
146
+ if (element.tag === TAG_CONTEXT_0) {
147
+ offset = element.contentEnd;
148
+ }
149
+ // serialNumber, signature, issuer, validity, subject, subjectPublicKeyInfo
150
+ for (let field = 0; field < 6; field += 1) {
151
+ element = readElement(der, offset, tbs.contentEnd, 'TBSCertificate field');
152
+ offset = element.contentEnd;
153
+ }
154
+ let extensionsBlock = null;
155
+ while (offset < tbs.contentEnd) {
156
+ element = readElement(der, offset, tbs.contentEnd, 'TBSCertificate trailing field');
157
+ offset = element.contentEnd;
158
+ if (element.tag === TAG_CONTEXT_3) {
159
+ extensionsBlock = element;
160
+ } else if (element.tag !== TAG_CONTEXT_1 && element.tag !== TAG_CONTEXT_2) {
161
+ throw new Error(`certificate DER: unexpected TBSCertificate field tag 0x${element.tag.toString(16)}`);
162
+ }
163
+ }
164
+ if (extensionsBlock === null) {
165
+ throw new Error('certificate has no extensions block');
166
+ }
167
+ const list = expectElement(der, extensionsBlock.contentStart, extensionsBlock.contentEnd, TAG_SEQUENCE, 'Extensions');
168
+ const found = new Map();
169
+ let cursor = list.contentStart;
170
+ while (cursor < list.contentEnd) {
171
+ const extension = expectElement(der, cursor, list.contentEnd, TAG_SEQUENCE, 'Extension');
172
+ cursor = extension.contentEnd;
173
+ const oidElement = expectElement(der, extension.contentStart, extension.contentEnd, TAG_OID, 'Extension OID');
174
+ const oid = decodeOid(der, oidElement.contentStart, oidElement.contentEnd);
175
+ let next = oidElement.contentEnd;
176
+ let critical = false;
177
+ let part = readElement(der, next, extension.contentEnd, 'Extension field');
178
+ if (part.tag === TAG_BOOLEAN) {
179
+ critical = der[part.contentStart] !== 0;
180
+ next = part.contentEnd;
181
+ part = readElement(der, next, extension.contentEnd, 'Extension value');
182
+ }
183
+ if (part.tag !== TAG_OCTET_STRING || part.contentEnd !== extension.contentEnd) {
184
+ throw new Error(`certificate DER: extension ${oid} value is not a lone OCTET STRING`);
185
+ }
186
+ if (found.has(oid)) {
187
+ throw new Error(`certificate carries extension ${oid} more than once`);
188
+ }
189
+ found.set(oid, { critical, value: der.subarray(part.contentStart, part.contentEnd) });
190
+ }
191
+ return found;
192
+ }
193
+
194
+ /** The extension's string: the OCTET STRING content must be exactly one DER UTF8String. */
195
+ function utf8ExtensionValue(extensions, oid) {
196
+ const extension = extensions.get(oid);
197
+ if (extension === undefined) {
198
+ throw new Error(`certificate does not carry Fulcio extension ${oid}`);
199
+ }
200
+ const { value } = extension;
201
+ let element;
202
+ try {
203
+ element = readElement(value, 0, value.length, `extension ${oid}`);
204
+ } catch (error) {
205
+ throw new Error(`Fulcio extension ${oid} is not a DER UTF8String: ${error.message}`);
206
+ }
207
+ if (element.tag !== TAG_UTF8_STRING || element.contentEnd !== value.length) {
208
+ throw new Error(`Fulcio extension ${oid} is not a DER UTF8String`);
209
+ }
210
+ const text = value.subarray(element.contentStart, element.contentEnd);
211
+ if (text.length === 0) {
212
+ throw new Error(`Fulcio extension ${oid} is an empty UTF8String`);
213
+ }
214
+ try {
215
+ return new TextDecoder('utf-8', { fatal: true }).decode(text);
216
+ } catch {
217
+ throw new Error(`Fulcio extension ${oid} is not valid UTF-8`);
218
+ }
219
+ }
220
+
221
+ function decodeBase64Der(rawBytes, what) {
222
+ if (typeof rawBytes !== 'string' || rawBytes.length === 0) {
223
+ throw new Error(`${what} has no rawBytes`);
224
+ }
225
+ return Buffer.from(rawBytes, 'base64');
226
+ }
227
+
228
+ /**
229
+ * The DER bytes of the leaf certificate of a parsed sigstore bundle:
230
+ * `verificationMaterial.certificate.rawBytes`, else the first
231
+ * `verificationMaterial.x509CertificateChain.certificates[].rawBytes`.
232
+ */
233
+ export function leafCertificateDerFromBundle(bundle) {
234
+ const material = bundle?.verificationMaterial;
235
+ if (material === null || typeof material !== 'object') {
236
+ throw new Error('sigstore bundle has no verificationMaterial');
237
+ }
238
+ if (material.certificate !== undefined) {
239
+ return decodeBase64Der(material.certificate?.rawBytes, 'verificationMaterial.certificate');
240
+ }
241
+ const first = material.x509CertificateChain?.certificates?.[0];
242
+ if (first === undefined) {
243
+ throw new Error('sigstore bundle verificationMaterial carries no certificate and no x509CertificateChain');
244
+ }
245
+ return decodeBase64Der(first.rawBytes, 'verificationMaterial.x509CertificateChain.certificates[0]');
246
+ }
247
+
248
+ /** The named UTF8String extensions of a DER certificate, each required. */
249
+ const readFields = (der, fields) => {
250
+ const extensions = certificateExtensions(der);
251
+ return Object.freeze(Object.fromEntries(fields.map(([field, oid]) => [field, utf8ExtensionValue(extensions, oid)])));
252
+ };
253
+
254
+ /**
255
+ * The runner environment, source repository identifier and source repository owner identifier of a
256
+ * DER certificate. Throws naming the OID when one is missing or is not a UTF8String.
257
+ *
258
+ * @param {Buffer} der
259
+ * @returns {{runnerEnvironment: string, sourceRepositoryIdentifier: string, sourceRepositoryOwnerIdentifier: string}}
260
+ */
261
+ export function readFulcioCertificateExtensions(der) {
262
+ return readFields(der, OID_FIELDS);
263
+ }
264
+
265
+ /**
266
+ * The Build Signer URI and Build Config URI of the leaf certificate of a parsed sigstore bundle. Throws naming the OID
267
+ * when one is missing or is not a UTF8String.
268
+ *
269
+ * @returns {{buildSignerUri: string, buildConfigUri: string}}
270
+ */
271
+ export function readFulcioBuildIdentity(bundle) {
272
+ return readFields(leafCertificateDerFromBundle(bundle), BUILD_OID_FIELDS);
273
+ }
274
+
275
+ /** The three extensions of the leaf certificate of a parsed sigstore bundle (the bytes cosign verified). */
276
+ export function readFulcioExtensionsFromBundle(bundle) {
277
+ return readFulcioCertificateExtensions(leafCertificateDerFromBundle(bundle));
278
+ }