@xemahq/edition-tooling 0.0.0-stage → 0.1.0

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,2344 @@
1
+ #!/usr/bin/env node
2
+ // Resolve a distribution into schema-v3 actual runtime image subjects.
3
+ // Services, the host shell, and independently sourced platform images are
4
+ // subjects. Biomes remain selection/ownership units and never become fictitious
5
+ // one-container-per-Biome build outputs.
6
+ //
7
+ // ── "REGENERATE AND DIFF" IS NOT A VALID CHECK ON THIS ARTIFACT ─────────────
8
+ //
9
+ // This resolver emits NO `imageDigest`. The digests in a committed
10
+ // `images.lock.json` are written afterwards, by `seal-image-digests.mjs`, and
11
+ // nothing here reproduces them. So the obvious verification — regenerate the
12
+ // lock and diff it against the committed one — reports drift that is purely the
13
+ // absence of a seal, on every subject, every time.
14
+ //
15
+ // Measured 2026-09-23 on `origin/main`, running this script unmodified against a
16
+ // pristine checkout with two editions left alone as controls:
17
+ //
18
+ // core 54 of 54 subjects carried a digest -> 0 after; `grep -c imageDigest` = 0
19
+ // cloud 66 of 66 -> 0 after
20
+ // minimal / oss (not re-resolved) -> 45 and 63, untouched
21
+ //
22
+ // The field is ABSENT rather than emptied, on `subjects`, `platformServiceBindings`
23
+ // and `webBiomes` alike, and no merge path carries a subset forward. Two
24
+ // consequences, and the second is the one that costs a night:
25
+ //
26
+ // - to verify a lock, compare a fresh resolve against ANOTHER fresh resolve
27
+ // (which cancels both the seal and the wall-clock `resolvedAt`), never
28
+ // against the committed file;
29
+ // - a re-resolve DISARMS the sealer's moved-tag refusal, which fires only when
30
+ // the lock being sealed already carries a digest for that subject. That is
31
+ // not a bypass to reach for — it is why "re-resolve then seal" and "re-seal
32
+ // the committed lock" are different operations with different verdicts, and
33
+ // anyone comparing their outcomes needs to know which one they ran.
34
+
35
+ import { execFileSync } from 'node:child_process';
36
+ import { createHash } from 'node:crypto';
37
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
38
+ import { join, relative, sep } from 'node:path';
39
+ import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
40
+
41
+ import { assertBuildLedgerComplete } from './build-ledger-completeness.mjs';
42
+ import { BUILD_LEDGER_FILE, readBuildLedger } from './build-ledger.mjs';
43
+ import { CROSS_REPO_BUILD_INPUTS_PATH, crossRepoInputKey, resolveCrossRepoVendoredInputs } from './cross-repo-build-inputs.mjs';
44
+ import {
45
+ resolveDispatchedRuntimeComponents,
46
+ UndeclaredSupplementalRuntimeComponentError,
47
+ } from './dispatched-runtime-components.mjs';
48
+ import {
49
+ assertCanonicalDistributionId,
50
+ distributionOutputPath,
51
+ readBoundDistributionFile,
52
+ } from './distribution-paths.mjs';
53
+ import { editionWorkspaceConfig, workspaceSection } from './edition-config.mjs';
54
+ import { editionPinnedInputs } from './edition-pinned-inputs.mjs';
55
+ import { dataRoot, editionsRoot, WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, sourcePath, sourcesRoot } from './edition-workspace.mjs';
56
+ import {
57
+ externalSourceCacheDir,
58
+ materializeGitSource,
59
+ } from './external-source-walk.mjs';
60
+ import { assertDeclaredSourceId, firstPartySourceId } from './first-party-source-ids.mjs';
61
+ import {
62
+ compareStrings,
63
+ hashImageBuildInputs,
64
+ imageInputSourceHashAlgorithm,
65
+ imageLockWriteSchemaVersion,
66
+ loadKernelDistributionSchemas,
67
+ parseResolvedDistributionLock,
68
+ readGitJsonAtCommit,
69
+ serializeJson,
70
+ sha256Hex,
71
+ validateImmutableDistributionLock,
72
+ validateImageLockStructure,
73
+ validateImmutableImageLock,
74
+ } from './lib.mjs';
75
+ import { parsePreviousImageLock } from './previous-image-lock.mjs';
76
+ import { assertReleaseContent } from './release-channel-guard.mjs';
77
+ import { composeEdition } from './resolve-distribution.mjs';
78
+ import { parseStrictJson, readStrictJsonFile } from './strict-json.mjs';
79
+ import { loadThirdPartySourcePins } from './third-party-source-pins.mjs';
80
+ import {
81
+ assertOciBundleSourceOrigin,
82
+ attestedContentHash,
83
+ isThirdPartyOciRow,
84
+ thirdPartyPinForRow,
85
+ thirdPartySubjectCandidates,
86
+ } from './third-party-subjects.mjs';
87
+
88
+ if (isEntryModule(import.meta.url)) {
89
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache]);
90
+ }
91
+
92
+ const firstPartyImageRegistry = () => editionWorkspaceConfig().imageRegistry;
93
+
94
+ /**
95
+ * WHERE A LOCKED BIOME'S SOURCE TREE IS ON DISK — the one answer this plane
96
+ * needs, and the one thing every read in this file used to assume.
97
+ *
98
+ * Every Git read here was `sourcePath(<repo>)`, which encodes an
99
+ * assumption that held while every biome came from a sibling repository in the
100
+ * aggregator's layout: that a `repo` HANDLE IS A PATH under `sourcesRoot()`. A
101
+ * biome drawn from an EXTERNAL source breaks it — its `repo` is that source's
102
+ * own LOGICAL name (`sources[].name`), deliberately not a `repos/<x>` path,
103
+ * because a hosting repository's name is source-management detail and must not
104
+ * become a biome's provenance. `<sourcesRoot()>/<logical name>` is a directory that
105
+ * does not exist, so the content hash could not be computed at all.
106
+ *
107
+ * The fix is ONE locator rather than a special case per read site: a `repo`
108
+ * handle resolves to a checkout DIRECTORY plus the BASE its `path` is relative
109
+ * to. Both kinds answer the same shape — a first-party repo's paths are
110
+ * `sourcesRoot()`-relative, an external source's are relative to the cache the walk
111
+ * materialised it into — so every call site reads the same two fields and none
112
+ * of them knows which kind it has.
113
+ *
114
+ * WHAT THE HASH THEN COVERS IS UNCHANGED, and that is the point of routing the
115
+ * DIRECTORY rather than teaching the hash about sources. `hashImageBuildInputs`
116
+ * reads the service's own source tree, the transitive workspace-package
117
+ * closure, the exact Dockerfile blob the build resolves to, every file the
118
+ * Dockerfile copies out of the context, the filtered pnpm lock graph, the
119
+ * context-directory residue and any vendored cross-repo tree — all out of the
120
+ * Git objects of whatever repository it is handed, at an exact commit. Handing
121
+ * it an external checkout therefore covers that repository's FULL build input
122
+ * by the same construction it already covers a first-party one, with no second
123
+ * definition of what an image is built from.
124
+ *
125
+ * MATERIALISATION IS AT THE LOCKED COMMIT, never at the declared ref. The lock
126
+ * already recorded the exact commit its manifests were read at; re-resolving a
127
+ * ref here would let a hash be computed over a different revision than the one
128
+ * the edition locked, silently and in the direction nothing detects.
129
+ * `materializeGitSource` short-circuits an exact 40-hex ref instead of sending
130
+ * it to `ls-remote`, so passing the commit AS the ref is the pin rather than a
131
+ * re-resolution.
132
+ */
133
+ export function externalSourceTrees(
134
+ lock,
135
+ { cacheRoot = externalSourceCacheDir(), materialize = materializeGitSource } = {},
136
+ ) {
137
+ const byRepo = new Map();
138
+ for (const biome of lock.biomes ?? []) {
139
+ if (biome.source === undefined) {continue;}
140
+ const name = biome.source.name;
141
+ const incumbent = byRepo.get(name);
142
+ if (incumbent) {
143
+ if (incumbent.commit !== biome.source.commit) {
144
+ throw new Error(
145
+ `distribution "${lock.distributionId}" pins two commits for external source ` +
146
+ `"${name}" (${incumbent.commit} and ${biome.source.commit}) — one source is one ` +
147
+ 'revision, so an image built from it cannot be addressed',
148
+ );
149
+ }
150
+ continue;
151
+ }
152
+ byRepo.set(name, { source: biome.source, commit: biome.source.commit });
153
+ }
154
+ const trees = new Map();
155
+ for (const [name, { source, commit }] of byRepo) {
156
+ const { directory } = materialize({ source: { ...source, ref: commit }, cacheRoot });
157
+ trees.set(name, { dir: directory, base: cacheRoot });
158
+ }
159
+ return trees;
160
+ }
161
+
162
+ /**
163
+ * `repo` handle -> `{ dir, base }`.
164
+ *
165
+ * An unknown handle is FIRST-PARTY, which is the only default that can be
166
+ * correct: the external set is CLOSED by the lock's own `sources[]`, so
167
+ * anything outside it came from this fleet's layout. A handle that is neither
168
+ * resolves to a directory that does not exist and fails at the first Git read,
169
+ * naming the path — a loud failure rather than a silent fall back to an empty
170
+ * tree, which would hash to a coordinate nothing ever published.
171
+ */
172
+ export function sourceTreeLocator(externalTrees) {
173
+ return (repo) =>
174
+ externalTrees.get(repo) ?? { dir: sourcePath(repo), base: sourcesRoot() };
175
+ }
176
+
177
+ /** The first-party-only locator — for callers that hold no lock to read. */
178
+ const FIRST_PARTY_TREES = (repo) => ({ dir: sourcePath(repo), base: sourcesRoot() });
179
+ const imageBuildInputHashByIdentity = new Map();
180
+ const sourceManifestHashByIdentity = new Map();
181
+
182
+ /**
183
+ * Memoized image build-input closure hash.
184
+ *
185
+ * Every distribution that ships a service resolves the SAME closure for it, and
186
+ * the closure reads immutable Git objects at an exact commit, so one digest per
187
+ * (service, service path, commit, vendored inputs) is exact — not a cache with
188
+ * an invalidation story.
189
+ */
190
+ function exactImageBuildInputHash(request, algorithm) {
191
+ const identity = serializeJson({ request, algorithm });
192
+ const cached = imageBuildInputHashByIdentity.get(identity);
193
+ if (cached) {return cached;}
194
+ const hash = hashImageBuildInputs(request, algorithm);
195
+ imageBuildInputHashByIdentity.set(identity, hash);
196
+ return hash;
197
+ }
198
+
199
+ function exactSourceManifestHash(
200
+ repoDirectory,
201
+ repoRelativePath,
202
+ sourceCommit,
203
+ ) {
204
+ const identity = serializeJson({
205
+ repoDirectory,
206
+ repoRelativePath,
207
+ sourceCommit,
208
+ });
209
+ const cached = sourceManifestHashByIdentity.get(identity);
210
+ if (cached) {return cached;}
211
+ const hash = sha256Hex(
212
+ serializeJson(
213
+ readGitJsonAtCommit(repoDirectory, repoRelativePath, sourceCommit),
214
+ ),
215
+ );
216
+ sourceManifestHashByIdentity.set(identity, hash);
217
+ return hash;
218
+ }
219
+
220
+ /**
221
+ * Key of one vendored input WITHIN one consumer repository.
222
+ *
223
+ * Both the store and the lookup go through this, so the two sides cannot spell
224
+ * the separator differently — which they did, and every lookup missed.
225
+ */
226
+ function consumerInputKey(consumerRepo, input) {
227
+ return `${consumerRepo}\u0000${crossRepoInputKey(input)}`;
228
+ }
229
+
230
+ /**
231
+ * Compose one image ref: WHERE the artifact lives, and WHAT it is called.
232
+ *
233
+ * `registry` defaults to the vendor namespace because that is where everything
234
+ * this organisation BUILDS is published, and every caller but one wants it. The
235
+ * exception is a biome drawn from an external source, whose coordinate is a
236
+ * DECLARED fact carried on the lock (`biome.source.imageRegistry`) rather than a
237
+ * constant — see the biomes loop in `resolveImageStructure`.
238
+ *
239
+ * `namePrefix` is the second half of that same declared coordinate, and it is
240
+ * what keeps a subject's LOGICAL id separate from its PUBLISHED name. Those two
241
+ * are the same string for every first-party subject — measured 2026-09-15, 67
242
+ * of 67 deploy-manifest entries and 60 of 60 `oci-image` component keys — but
243
+ * that is a property of owning the whole of the first-party registry namespace, not a law. A
244
+ * customer organisation's namespace is flat and carries every product that
245
+ * customer ships, so a bare component name there identifies nothing in
246
+ * particular and the first collision silently overwrites somebody's tags.
247
+ *
248
+ * DEFAULTING TO THE EMPTY STRING IS THE IDENTITY ELEMENT OF CONCATENATION, not
249
+ * a fallback: an unqualified name is what every existing subject already gets,
250
+ * so the absent case reproduces today's behaviour exactly rather than
251
+ * substituting something. Contrast `registry`, whose absence on an external
252
+ * source is REFUSED in `resolveImageStructure` — there the only available
253
+ * default names the wrong ORG, which is a misdirection rather than a preference.
254
+ *
255
+ * Both parameters are deliberately not module-level mutables: two editions are
256
+ * resolved in one process by `dist:check`, and a coordinate held in module scope
257
+ * would leak one edition's customer namespace into the next one's subjects.
258
+ */
259
+ export function imageRefForSubject(
260
+ subjectId,
261
+ registry = firstPartyImageRegistry(),
262
+ namePrefix = '',
263
+ ) {
264
+ return `${registry}/${namePrefix}${subjectId}`;
265
+ }
266
+
267
+ /**
268
+ * The OCI platforms one distribution requires every runtime image to ship.
269
+ *
270
+ * Read from that edition's OWN `xema-distribution.json`
271
+ * and projected onto image-lock platform
272
+ * coordinates by the kernel. This deliberately replaced a global frozen
273
+ * `[{linux, amd64}]` constant: with one constant, every subject of every
274
+ * edition declared amd64 — including `appliance-lean`/`appliance-base`, whose
275
+ * installer (`xema-appliance/os/dgx/install-k3s.sh`, `ansible/site.yml`) refuses
276
+ * any host that is not ARM64. The image plane therefore declared the one
277
+ * architecture the appliance could not run, and nothing in the chain could say
278
+ * so.
279
+ *
280
+ * The kernel owns the vocabulary and the fail-fast: an absent, empty, unknown,
281
+ * duplicated or misordered declaration throws here rather than resolving to a
282
+ * default. There is no default.
283
+ */
284
+ export function resolveDistributionRequiredPlatforms(schemas, id) {
285
+ assertCanonicalDistributionId(id);
286
+ const { requiredPlatforms } = composeEdition(schemas.parseDistribution, id);
287
+ return schemas.resolveRuntimeImagePlatforms(requiredPlatforms);
288
+ }
289
+
290
+ /**
291
+ * The edition's DECLARED `sources[]`,
292
+ *
293
+ * WHY THIS IS A PARAMETER AND NOT A LOCK FIELD. `imageNamePrefix` is producer
294
+ * INPUT: it composes the image NAME and is then spent, because the lock carries
295
+ * the fully-qualified `imageRef`. It cannot travel in the lock to reach the
296
+ * resolver — `DistributionLockSchema` is `.strict()` and learned that key only
297
+ * in `@xemahq/contracts` 0.10.0, so a pinned image resolving `^0.9.x` refuses
298
+ * the whole lock at boot. Persisting it took a customer environment's three lock
299
+ * consumers into CrashLoopBackOff on 2026-09-17.
300
+ *
301
+ * So the producer reads its OWN declaration, and `requiredPlatforms` one
302
+ * function up is the precedent: a declared fact the pure structure resolver
303
+ * needs arrives as an argument the impure caller composes. That keeps
304
+ * `resolveImageStructure` pure over the committed lock and runtime index, which
305
+ * is what lets `dist-check` grade an images lock offline.
306
+ */
307
+ export function resolveDistributionDeclaredSources(schemas, id) {
308
+ assertCanonicalDistributionId(id);
309
+ const { declaredSources } = composeEdition(schemas.parseDistribution, id);
310
+ return declaredSources;
311
+ }
312
+
313
+ function subjectKey(subject) {
314
+ return `${subject.kind}\0${subject.id}`;
315
+ }
316
+
317
+ function bindingKey(binding) {
318
+ return `${binding.subjectKind}\0${binding.subjectId}`;
319
+ }
320
+
321
+ function compareSubject(left, right) {
322
+ return (
323
+ compareStrings(left.kind, right.kind) || compareStrings(left.id, right.id)
324
+ );
325
+ }
326
+
327
+ function compareOwner(left, right) {
328
+ return compareStrings(serializeJson(left), serializeJson(right));
329
+ }
330
+
331
+ function addSubject(subjects, candidate) {
332
+ const key = subjectKey(candidate);
333
+ const existing = subjects.get(key);
334
+ if (!existing) {
335
+ subjects.set(key, {
336
+ ...candidate,
337
+ owners: [...candidate.owners],
338
+ });
339
+ return;
340
+ }
341
+ const withoutOwners = ({ owners: _owners, ...value }) => value;
342
+ if (
343
+ serializeJson(withoutOwners(existing)) !==
344
+ serializeJson(withoutOwners(candidate))
345
+ ) {
346
+ throw new Error(
347
+ `runtime image subject "${candidate.kind}:${candidate.id}" has conflicting source identities`,
348
+ );
349
+ }
350
+ const ownerKeys = new Set(existing.owners.map(serializeJson));
351
+ for (const owner of candidate.owners) {
352
+ if (!ownerKeys.has(serializeJson(owner))) {existing.owners.push(owner);}
353
+ }
354
+ }
355
+
356
+ /**
357
+ * The refusal for a SELECTED platform service whose declared artifact
358
+ * dependency no selected biome produces.
359
+ *
360
+ * The edge is DECLARED, never inferred from a name: a platform service's
361
+ * `runtimeBinding` (platform-service-source-authority.mjs, projected into
362
+ * `runtime-image-source-index.json#platformServiceBindings`) says which image
363
+ * subject carries it — `opencode-image-prepuller` → `runtime-component:
364
+ * opencode-worker`. When that subject is not supplemental it is produced by a
365
+ * biome, and the biome that produces it is the one whose OWN manifest declares
366
+ * it (`services` / `runtimeComponents` in the biome index). So excluding the
367
+ * provider biome while keeping the dependent service is refused here, naming
368
+ * both ends; excluding both leaves no edge and resolves.
369
+ */
370
+ function missingArtifactDependencyMessage(lock, serviceName, binding, biomeIndex) {
371
+ const field =
372
+ binding.subjectKind === 'runtime-component' ? 'runtimeComponents' : 'services';
373
+ const providers = Array.isArray(biomeIndex)
374
+ ? [
375
+ ...new Set(
376
+ biomeIndex
377
+ .filter((entry) => (entry[field] ?? []).includes(binding.subjectId))
378
+ .map((entry) => entry.id),
379
+ ),
380
+ ].sort(compareStrings)
381
+ : [];
382
+ const providerText =
383
+ providers.length > 0
384
+ ? `, which is declared by biome ${providers.map((id) => `"${id}"`).join(' / ')}` +
385
+ ' — and this edition does not select it'
386
+ : ', and no biome this edition selects declares it';
387
+ return (
388
+ `distribution "${lock.distributionId}" ships platform service "${serviceName}", ` +
389
+ `whose declared runtime binding requires image subject ` +
390
+ `"${binding.subjectKind}:${binding.subjectId}"${providerText}. ` +
391
+ `Either keep the providing biome, or exclude "${serviceName}" as well — ` +
392
+ 'refusing rather than resolving an edition whose service has no image.'
393
+ );
394
+ }
395
+
396
+ /**
397
+ * Offline roster derivation. It reads only committed locks/indexes and performs
398
+ * no registry, GitHub, DigitalOcean, or source-worktree access.
399
+ *
400
+ * `requiredPlatforms` is supplied by the caller (see
401
+ * {@link resolveDistributionRequiredPlatforms}) rather than read here, so this
402
+ * function stays a pure projection of its inputs — and so a caller that forgets
403
+ * the distribution's declaration gets a hard error instead of a default.
404
+ *
405
+ * THE ROSTER IS THE ARTIFACT CLOSURE OF THE LOGICAL CLOSURE, and nothing else.
406
+ * `runtime-image-source-index.json` is the FLEET's catalogue — every platform
407
+ * service binding, every supplemental subject — and an edition selects from it.
408
+ * A binding for a platform service this edition does not ship is simply not
409
+ * part of this edition's artifact graph and is dropped; it is never a reason to
410
+ * refuse. What DOES refuse is a SELECTED service whose declared artifact
411
+ * dependency is absent from the closure (see `missingArtifactDependencyMessage`).
412
+ *
413
+ * `options.biomeIndex` is the first-party biome index. It names, in a refusal,
414
+ * which biome declares a missing artifact, and it carries each rostered biome's
415
+ * `dispatchesRuntimeComponents`: the runtime components that biome launches,
416
+ * whose images enter this roster as runtime-component rows although their
417
+ * biomes are not rostered (see `dispatched-runtime-components.mjs`).
418
+ *
419
+ * `options.supplementalSources` is the edition workspace's
420
+ * `runtimeImageSources.supplementalSources`: the declarations the source
421
+ * index's supplemental subjects were built from, carrying each one's
422
+ * `launchedBy`. A subject launched by a biome ships, owned by that biome, in
423
+ * every edition whose roster contains it; one a platform service binds ships
424
+ * with that service. A declared subject with neither is refused by name: it
425
+ * could never enter any edition, and it used to be dropped silently.
426
+ */
427
+ export function resolveImageStructure(
428
+ lock,
429
+ runtimeSourceIndex,
430
+ enums,
431
+ requiredPlatforms,
432
+ declaredSources,
433
+ { biomeIndex, supplementalSources, thirdPartyPins } = {},
434
+ ) {
435
+ if (!Array.isArray(requiredPlatforms) || requiredPlatforms.length === 0) {
436
+ throw new Error(
437
+ `distribution "${lock.distributionId}" resolved no required image platforms — ` +
438
+ `every distribution declares requiredPlatforms in its xema-distribution.json ` +
439
+ `and there is no default; a silent default is the bug this replaced`,
440
+ );
441
+ }
442
+ if (!Array.isArray(biomeIndex)) {
443
+ throw new Error(
444
+ `distribution "${lock.distributionId}" resolved its images without the biome index — the index carries ` +
445
+ 'the runtime components a rostered biome dispatches, so a roster without it can omit images',
446
+ );
447
+ }
448
+ if (!Array.isArray(supplementalSources)) {
449
+ throw new Error(
450
+ `distribution "${lock.distributionId}" resolved its images without the supplemental source declarations — ` +
451
+ 'they say which biome launches a supplemental subject, so a roster without them can omit images',
452
+ );
453
+ }
454
+ const subjects = new Map();
455
+ // Third-party OCI subjects come from their pin and are never built, hashed or read from a checkout, so they are kept
456
+ // apart from every git-backed subject (see `third-party-subjects.mjs`).
457
+ const thirdPartySubjects = new Map();
458
+ const webBiomes = [];
459
+ const platformServiceBindings = [];
460
+ const repositoryPins = new Map(
461
+ runtimeSourceIndex.repositoryPins.map((repository) => [
462
+ repository.repo,
463
+ repository,
464
+ ]),
465
+ );
466
+ const requireRepositoryPin = (repo) => {
467
+ const pin = repositoryPins.get(repo);
468
+ if (!pin) {
469
+ throw new Error(`runtime image source "${repo}" has no repository pin`);
470
+ }
471
+ return pin;
472
+ };
473
+ // A supplemental subject of the runtime-image source index, built at its
474
+ // repository pin, owned by whatever brings it into this edition.
475
+ const addSupplementalSubject = (supplemental, owners) => {
476
+ const repository = requireRepositoryPin(supplemental.repo);
477
+ if (supplemental.sourceCommit !== repository.sourceCommit) {
478
+ throw new Error(
479
+ `runtime subject "${supplemental.kind}:${supplemental.id}" source commit does not match its repository pin`,
480
+ );
481
+ }
482
+ addSubject(subjects, {
483
+ kind: supplemental.kind,
484
+ id: supplemental.id,
485
+ imageRef: supplemental.imageRef,
486
+ repo: supplemental.repo,
487
+ path: supplemental.path,
488
+ sourceCommit: repository.sourceCommit,
489
+ sourceManifestHash: supplemental.sourceManifestHash,
490
+ sourceManifestPath: supplemental.sourceManifestPath,
491
+ requiredPlatforms,
492
+ owners,
493
+ });
494
+ };
495
+
496
+ for (const biome of lock.biomes) {
497
+ const biomeLabel = `distribution "${lock.distributionId}" biome "${biome.id}"`;
498
+ assertOciBundleSourceOrigin(biome, biomeLabel);
499
+ if (isThirdPartyOciRow(biome)) {
500
+ const pin = thirdPartyPinForRow({ row: biome, pins: thirdPartyPins, declaredSources, label: biomeLabel });
501
+ for (const candidate of thirdPartySubjectCandidates({ row: biome, pin, enums, requiredPlatforms, label: biomeLabel })) {
502
+ addSubject(thirdPartySubjects, candidate);
503
+ }
504
+ continue;
505
+ }
506
+ // WHERE THIS BIOME'S IMAGES LIVE, and WHICH COMMIT THEY ARE BUILT FROM.
507
+ // Both answers move together when a biome is drawn from an external source,
508
+ // because both are facts about that source rather than about this fleet.
509
+ //
510
+ // ── THE REGISTRY ──
511
+ //
512
+ // A first-party biome is published to the vendor namespace. A biome drawn
513
+ // from an EXTERNAL source is not, and its coordinate is DECLARED — on the
514
+ // `sources[]` entry as `imageRegistry`, carried into the lock verbatim by
515
+ // `external-source-walk.mjs` and read HERE. This used to refuse
516
+ // unconditionally, including when the coordinate WAS declared, because
517
+ // reading it was not implemented; that was an honest partial and it is now
518
+ // implemented.
519
+ //
520
+ // THE REFUSAL SURVIVES, NARROWED TO THE CASE IT WAS ALWAYS ABOUT: an
521
+ // external source that declares NO registry. The kernel legislates that on
522
+ // the field itself — *"a plane that needs one and finds it absent must
523
+ // REFUSE rather than substitute the first-party registry, which would
524
+ // publish a customer's build into the vendor org."* Absent must never
525
+ // become a fallback to the first-party registry. That substitution is the entire
526
+ // defect; narrowing the refusal must not soften it into a default.
527
+ //
528
+ // ── THE COMMIT ──
529
+ //
530
+ // A first-party biome's commit comes from `runtime-image-source-index.json`
531
+ // via `requireRepositoryPin`. An external biome has no entry there and
532
+ // never will: its `repo` is the source's own LOGICAL name as declared in
533
+ // `sources[]`, not a `repos/<x>` path, so the pin lookup would fail with "has no
534
+ // repository pin" — a true sentence about the wrong plane. Its pin is the
535
+ // commit the WALK resolved and the lock recorded, which is the same fact
536
+ // held by the authority that actually resolved it.
537
+ //
538
+ // ── PLACEMENT ──
539
+ //
540
+ // Both are resolved BEFORE `requireRepositoryPin` and before the
541
+ // `target === 'web'` branch, so services, runtime components and web biomes
542
+ // are covered by one statement rather than leaving the web branch's
543
+ // `continue` as a hole.
544
+ //
545
+ // KNOWN NARROWING, stated rather than discovered: a web biome composes no
546
+ // image ref at all, so an external source shipping ONLY web biomes needs no
547
+ // registry and is nonetheless refused here. That is deliberately left
548
+ // fail-closed — it has no subject today (both externally-sourced biomes in
549
+ // this plane declare `target: "server"`), and loosening a fence for a case
550
+ // that does not exist is how a fence stops meaning anything.
551
+ let imageRegistry = firstPartyImageRegistry();
552
+ // The identity element, not a fallback — see `imageRefForSubject`. A
553
+ // first-party subject is published under its own id, which is exactly what
554
+ // an empty prefix composes.
555
+ let imageNamePrefix = '';
556
+ let sourceCommit;
557
+ if (biome.source === undefined) {
558
+ sourceCommit = requireRepositoryPin(biome.repo).sourceCommit;
559
+ } else {
560
+ if (biome.source.imageRegistry === undefined) {
561
+ throw new Error(
562
+ `distribution "${lock.distributionId}" biome "${biome.id}" was drawn from external ` +
563
+ `source "${biome.source.name}" (${biome.source.url}@${biome.source.commit}), which ` +
564
+ 'declares no imageRegistry, so this plane cannot address its images. Composing ' +
565
+ `them under "${firstPartyImageRegistry()}" would address a build this organisation does not ` +
566
+ 'produce, inside the vendor namespace. Declare `imageRegistry` on that sources[] ' +
567
+ 'entry — refusing rather than substituting the first-party registry.',
568
+ );
569
+ }
570
+ imageRegistry = biome.source.imageRegistry;
571
+ // THE PREFIX COMES FROM THE DECLARATION, NEVER FROM THE LOCK.
572
+ //
573
+ // This read was `biome.source.imageNamePrefix`, which required the lock to
574
+ // carry the key — and a lock carrying it is refused outright by any
575
+ // consumer on `@xemahq/contracts` `^0.9.x`, whose DistributionLockSchema
576
+ // is `.strict()` and learned the key only in 0.10.0.
577
+ //
578
+ // Removing it from the lock WITHOUT moving this read is what composed
579
+ // `<customer-registry>/pai-core-api` instead of `<customer-registry>/<customer>-pai-core-api`:
580
+ // the `?? ''` swallowed the absence, so an unqualified name in a shared
581
+ // organisation namespace resolved silently. That is worse than a 404 —
582
+ // the first collision overwrites another product's tags.
583
+ //
584
+ // A MISSING DECLARED ENTRY IS THEREFORE REFUSED, never defaulted. An entry
585
+ // that declares NO prefix is a different fact and is honoured: the empty
586
+ // prefix is the identity element, composing a subject under its bare id.
587
+ if (!Array.isArray(declaredSources)) {
588
+ throw new Error(
589
+ `distribution "${lock.distributionId}" biome "${biome.id}" was drawn from ` +
590
+ `external source "${biome.source.name}", but this resolve was given no ` +
591
+ 'declared sources[] to read its imageNamePrefix from. That prefix is ' +
592
+ 'producer input and is deliberately not carried in the lock; pass ' +
593
+ '`resolveDistributionDeclaredSources(schemas, id)` — refusing rather ' +
594
+ 'than composing an unprefixed name.',
595
+ );
596
+ }
597
+ const declaredSource = declaredSources.find(
598
+ (candidate) => candidate.name === biome.source.name,
599
+ );
600
+ if (declaredSource === undefined) {
601
+ throw new Error(
602
+ `distribution "${lock.distributionId}" biome "${biome.id}" was drawn from ` +
603
+ `external source "${biome.source.name}", which this distribution's ` +
604
+ `composed sources[] does not declare (declared: ${
605
+ declaredSources.map((candidate) => candidate.name).join(', ') ||
606
+ 'none'
607
+ }). The image name cannot be composed without its declaration — ` +
608
+ 'refusing rather than publishing under an unprefixed name in a shared ' +
609
+ "organisation namespace, where the first collision overwrites another " +
610
+ "product's tags.",
611
+ );
612
+ }
613
+ imageNamePrefix = declaredSource.imageNamePrefix ?? '';
614
+ sourceCommit = biome.source.commit;
615
+ }
616
+ if (biome.target === 'web') {
617
+ webBiomes.push({
618
+ biomeId: biome.id,
619
+ repo: biome.repo,
620
+ path: biome.path,
621
+ sourceCommit,
622
+ sourceManifestHash: biome.sourceManifestHash,
623
+ });
624
+ continue;
625
+ }
626
+ // A worker that runs a sibling service's image builds none of its own: the
627
+ // service's subject is the one sealed coordinate, and the deploy plane
628
+ // reaches it through `workerImages[worker].imageOf`. Minting a subject for
629
+ // the worker would name an image nothing builds.
630
+ const sharedImageWorkers = new Set(Object.keys(biome.workerImages ?? {}));
631
+ for (const service of biome.services ?? []) {
632
+ if (sharedImageWorkers.has(service)) {
633
+ continue;
634
+ }
635
+ addSubject(subjects, {
636
+ kind: enums.RuntimeImageSubjectKind.Service,
637
+ id: service,
638
+ imageRef: imageRefForSubject(service, imageRegistry, imageNamePrefix),
639
+ repo: biome.repo,
640
+ path: biome.path,
641
+ sourceCommit,
642
+ sourceManifestHash: biome.sourceManifestHash,
643
+ requiredPlatforms,
644
+ owners: [
645
+ {
646
+ kind: enums.RuntimeImageOwnerKind.Biome,
647
+ biomeId: biome.id,
648
+ },
649
+ ],
650
+ });
651
+ }
652
+ // Runtime components are FULL delivery subjects — built, content-addressed,
653
+ // signed, mirrored and air-gapped exactly like a service image. They differ
654
+ // only in that nothing schedules them, which the deploy plane reads off the
655
+ // subject KIND. Their source path resolves through the same
656
+ // `ships.apis[].path` SSOT, because a runtime component IS a `ships.apis[]`
657
+ // entry — it just carries `workload: "runtime-component"`.
658
+ for (const component of biome.runtimeComponents ?? []) {
659
+ addSubject(subjects, {
660
+ kind: enums.RuntimeImageSubjectKind.RuntimeComponent,
661
+ id: component,
662
+ imageRef: imageRefForSubject(component, imageRegistry, imageNamePrefix),
663
+ repo: biome.repo,
664
+ path: biome.path,
665
+ sourceCommit,
666
+ sourceManifestHash: biome.sourceManifestHash,
667
+ requiredPlatforms,
668
+ owners: [
669
+ {
670
+ kind: enums.RuntimeImageOwnerKind.Biome,
671
+ biomeId: biome.id,
672
+ },
673
+ ],
674
+ });
675
+ }
676
+ }
677
+
678
+ // Runtime components a rostered biome DISPATCHES enter the roster as
679
+ // runtime-component rows built from the TARGET biome's index entry, at its
680
+ // repository pin, exactly like a rostered biome's own runtime component. The
681
+ // owner is the rostered biome that dispatches it: the selection that brings
682
+ // the image into this edition. The target biome stays unrostered.
683
+ //
684
+ // A rostered biome's edges are read from ITS index entry, which must describe
685
+ // the very biome the lock pins; one from a declared source is refused at
686
+ // distribution resolve if it dispatches, and the first-party index carries
687
+ // none, so it is not looked up here.
688
+ const dispatchers = lock.biomes
689
+ .filter((biome) => biome.source === undefined && !isThirdPartyOciRow(biome) && biome.target === 'server')
690
+ .map((biome) => {
691
+ const entries = biomeIndex.filter(
692
+ (entry) =>
693
+ entry.id === biome.id &&
694
+ entry.target === biome.target &&
695
+ entry.repo === biome.repo &&
696
+ entry.source === undefined,
697
+ );
698
+ if (entries.length !== 1 || entries[0].sourceManifestHash !== biome.sourceManifestHash) {
699
+ throw new Error(
700
+ `distribution "${lock.distributionId}" biome "${biome.id}" (${biome.repo}, ${biome.sourceManifestHash}) ` +
701
+ `has ${entries.length === 1 ? 'a stale' : `${entries.length}`} biome index ${entries.length === 1 ? 'entry' : 'entries'}, ` +
702
+ 'so the runtime components it dispatches cannot be read — re-resolve the edition from the current index',
703
+ );
704
+ }
705
+ return entries[0];
706
+ });
707
+ for (const dispatched of resolveDispatchedRuntimeComponents(
708
+ `distribution "${lock.distributionId}"`,
709
+ dispatchers,
710
+ biomeIndex,
711
+ )) {
712
+ if (dispatched.supplementalRuntimeComponentId !== undefined) {
713
+ // THE REFUSAL: a dispatched supplemental runtime component must be one the
714
+ // runtime image source index declares as a runtime-component subject.
715
+ // Every image lock is produced here, so this is the one place it is checked.
716
+ const declared = runtimeSourceIndex.supplementalSubjects.find(
717
+ (subject) =>
718
+ subject.kind === enums.RuntimeImageSubjectKind.RuntimeComponent &&
719
+ subject.id === dispatched.supplementalRuntimeComponentId,
720
+ );
721
+ if (declared === undefined) {
722
+ throw new UndeclaredSupplementalRuntimeComponentError(
723
+ dispatched.supplementalRuntimeComponentId,
724
+ dispatched.consumers,
725
+ `distribution "${lock.distributionId}"`,
726
+ );
727
+ }
728
+ addSupplementalSubject(
729
+ declared,
730
+ dispatched.consumers.map((biomeId) => ({ kind: enums.RuntimeImageOwnerKind.Biome, biomeId })),
731
+ );
732
+ continue;
733
+ }
734
+ const { target } = dispatched;
735
+ addSubject(subjects, {
736
+ kind: enums.RuntimeImageSubjectKind.RuntimeComponent,
737
+ id: dispatched.image,
738
+ imageRef: imageRefForSubject(dispatched.image, firstPartyImageRegistry(), ''),
739
+ repo: target.repo,
740
+ path: target.path,
741
+ sourceCommit: requireRepositoryPin(target.repo).sourceCommit,
742
+ sourceManifestHash: target.sourceManifestHash,
743
+ requiredPlatforms,
744
+ owners: dispatched.consumers.map((biomeId) => ({
745
+ kind: enums.RuntimeImageOwnerKind.Biome,
746
+ biomeId,
747
+ })),
748
+ });
749
+ }
750
+
751
+ const runtimeBindings = new Map(
752
+ runtimeSourceIndex.platformServiceBindings.map((binding) => [
753
+ binding.platformService,
754
+ binding,
755
+ ]),
756
+ );
757
+ const supplementalSubjects = new Map(
758
+ runtimeSourceIndex.supplementalSubjects.map((subject) => [
759
+ subjectKey(subject),
760
+ subject,
761
+ ]),
762
+ );
763
+
764
+ // Supplemental subjects a BIOME launches (`launchedBy`): sealed, owned by
765
+ // that biome, in every edition whose roster contains it. The catalogue
766
+ // checks run for every edition, rostered launcher or not.
767
+ const boundSubjects = new Set(
768
+ runtimeSourceIndex.platformServiceBindings.map((binding) => bindingKey(binding)),
769
+ );
770
+ // A biome's `supplemental:<id>` dispatch edge also carries the subject: the
771
+ // catalogue check reads the whole first-party index, not this roster, so a
772
+ // subject reached only by a dispatch is not "unreachable" in any edition.
773
+ const dispatchedSubjects = new Set(
774
+ biomeIndex
775
+ .filter((entry) => entry.source === undefined)
776
+ .flatMap((entry) => entry.dispatchesRuntimeComponents ?? [])
777
+ .filter((edge) => edge.supplementalRuntimeComponentId !== undefined)
778
+ .map((edge) => `${enums.RuntimeImageSubjectKind.RuntimeComponent}\0${edge.supplementalRuntimeComponentId}`),
779
+ );
780
+ const launchers = new Map(
781
+ supplementalSources
782
+ .filter((source) => source.launchedBy !== undefined)
783
+ .map((source) => [subjectKey(source), source.launchedBy]),
784
+ );
785
+ for (const key of launchers.keys()) {
786
+ if (!supplementalSubjects.has(key)) {
787
+ throw new Error(
788
+ `supplemental source "${key.replace('\0', ':')}" declares a launcher, but the runtime image source index ` +
789
+ 'has no such supplemental subject — rebuild the index from the current declarations',
790
+ );
791
+ }
792
+ }
793
+ for (const [key, supplemental] of supplementalSubjects) {
794
+ const launcher = launchers.get(key);
795
+ if (launcher === undefined) {
796
+ if (!boundSubjects.has(key) && !dispatchedSubjects.has(key)) {
797
+ throw new Error(
798
+ `supplemental subject "${supplemental.kind}:${supplemental.id}" has neither a launchedBy, a ` +
799
+ 'platform-service binding nor a biome dispatching it, so no edition could ever carry it — declare what launches it ' +
800
+ '(runtimeImageSources.supplementalSources[].launchedBy), bind it to a platform service or dispatch it from a biome',
801
+ );
802
+ }
803
+ continue;
804
+ }
805
+ if (!biomeIndex.some((entry) => entry.id === launcher.biomeId && entry.source === undefined)) {
806
+ throw new Error(
807
+ `supplemental subject "${supplemental.kind}:${supplemental.id}" is launched by biome ` +
808
+ `"${launcher.biomeId}", which the first-party biome index does not carry`,
809
+ );
810
+ }
811
+ if (lock.biomes.some((biome) => biome.id === launcher.biomeId)) {
812
+ addSupplementalSubject(supplemental, [
813
+ { kind: enums.RuntimeImageOwnerKind.Biome, biomeId: launcher.biomeId },
814
+ ]);
815
+ }
816
+ }
817
+
818
+ for (const service of lock.platformServices ?? []) {
819
+ let binding;
820
+ // The discriminator is the DECLARED runtime binding, never "does the lock
821
+ // carry a source". Every platform service carries an exact source identity
822
+ // now (`LockedPlatformService.source` is required at lock schemaVersion 2),
823
+ // so `if (service.source)` became unconditionally true — and it would have
824
+ // minted a repository-ROOT `platform` subject for `xema-kernel-server`,
825
+ // `docs-api`, `xema-host-web` and `opencode-image-prepuller`, hashing the
826
+ // wrong closure for three of them and inventing an image that has never
827
+ // existed for the fourth. A binding names the subject that really carries
828
+ // the service; its absence means the service IS its own repository image.
829
+ const runtimeBinding = runtimeBindings.get(service.name);
830
+ if (!runtimeBinding) {
831
+ if (!service.source) {
832
+ throw new Error(
833
+ `platform service "${service.name}" has no declared runtime image binding ` +
834
+ 'and no exact source identity, so no image subject can be resolved for it',
835
+ );
836
+ }
837
+ const repository = requireRepositoryPin(service.source.repo);
838
+ if (service.source.sourceCommit !== repository.sourceCommit) {
839
+ throw new Error(
840
+ `platform service "${service.name}" source commit does not match its repository pin`,
841
+ );
842
+ }
843
+ binding = {
844
+ platformService: service.name,
845
+ subjectKind: enums.RuntimeImageSubjectKind.Platform,
846
+ subjectId: service.source.imageKey,
847
+ };
848
+ addSubject(subjects, {
849
+ kind: binding.subjectKind,
850
+ id: binding.subjectId,
851
+ imageRef: imageRefForSubject(binding.subjectId),
852
+ repo: service.source.repo,
853
+ path: service.source.repo,
854
+ sourceCommit: repository.sourceCommit,
855
+ sourceManifestHash: service.source.sourceManifestHash,
856
+ sourceManifestPath: service.source.path,
857
+ requiredPlatforms,
858
+ owners: [
859
+ {
860
+ kind: enums.RuntimeImageOwnerKind.PlatformService,
861
+ platformService: service.name,
862
+ },
863
+ ],
864
+ });
865
+ } else {
866
+ binding = runtimeBinding;
867
+ const key = bindingKey(binding);
868
+ const supplemental = supplementalSubjects.get(key);
869
+ if (supplemental) {
870
+ addSupplementalSubject(supplemental, [
871
+ {
872
+ kind: enums.RuntimeImageOwnerKind.PlatformService,
873
+ platformService: service.name,
874
+ },
875
+ ]);
876
+ } else {
877
+ const target = subjects.get(key);
878
+ if (!target) {
879
+ throw new Error(
880
+ missingArtifactDependencyMessage(lock, service.name, binding, biomeIndex),
881
+ );
882
+ }
883
+ addSubject(subjects, {
884
+ ...target,
885
+ owners: [
886
+ {
887
+ kind: enums.RuntimeImageOwnerKind.PlatformService,
888
+ platformService: service.name,
889
+ },
890
+ ],
891
+ });
892
+ }
893
+ }
894
+ platformServiceBindings.push(binding);
895
+ }
896
+
897
+ // A binding for a service this edition does not ship is outside its artifact
898
+ // graph. It was REFUSED here ("runtime image source index contains unselected
899
+ // platform service"), which made every image-bearing platform service
900
+ // unexcludable: `billing-api` could be dropped only because it happens to be
901
+ // independently sourced and carries no binding, while `docs-api`,
902
+ // `xema-host-web` and `opencode-image-prepuller` could not. The index is the
903
+ // fleet catalogue, not this edition's roster; the roster is derived from the
904
+ // lock's `platformServices`, above. A binding naming a service the COMPOSED
905
+ // catalogue does not know at all is still a declaration defect, and is
906
+ // refused by `build-runtime-image-source-index.mjs --check`, which owns the
907
+ // index — not by every edition that happens to omit the service.
908
+
909
+ const orderedSubjects = [...subjects.values()]
910
+ .map((subject) => ({
911
+ ...subject,
912
+ owners: subject.owners.sort(compareOwner),
913
+ }))
914
+ .sort(compareSubject);
915
+ webBiomes.sort((left, right) => compareStrings(left.biomeId, right.biomeId));
916
+ platformServiceBindings.sort((left, right) =>
917
+ compareStrings(left.platformService, right.platformService),
918
+ );
919
+ const orderedThirdPartySubjects = [...thirdPartySubjects.values()]
920
+ .map((subject) => ({ ...subject, owners: subject.owners.sort(compareOwner) }))
921
+ .sort(compareSubject);
922
+ for (const subject of orderedThirdPartySubjects) {
923
+ if (subjects.has(subjectKey(subject))) {
924
+ throw new Error(
925
+ `third-party runtime image subject "${subject.kind}:${subject.id}" has the identity of another subject of distribution "${lock.distributionId}"`,
926
+ );
927
+ }
928
+ }
929
+ return {
930
+ subjects: orderedSubjects,
931
+ thirdPartySubjects: orderedThirdPartySubjects,
932
+ webBiomes,
933
+ platformServiceBindings,
934
+ };
935
+ }
936
+
937
+ /** Every image subject of a structure, first-party and third-party, in the lock's order. */
938
+ export function structureSubjects(structure) {
939
+ return [...structure.subjects, ...structure.thirdPartySubjects].sort(compareSubject);
940
+ }
941
+
942
+ /**
943
+ * THE COMPOSED-PROCESS IMAGE OF ONE BUILD — where a producing repository
944
+ * declares it, and how an edition addresses it.
945
+ *
946
+ * The kernel (`RuntimeImageSubjectKind.ComposedProcess`) fixes WHAT the subject
947
+ * is: one repository at one commit, carrying every platform-scheduled service
948
+ * of that build and `@xemahq/xema-service-nest`'s `xema-composed-process`
949
+ * entrypoint, owned by the biomes whose services it carries. Neither the kernel
950
+ * nor the SDK fixes WHERE a repository builds it, so this plane does, once:
951
+ *
952
+ * - the image directory is the per-class directory
953
+ * `infra/composed-process/<class>/` at the repository root (`<class>` a
954
+ * kernel `ComposedImageClass`; an edition addresses one composed subject
955
+ * per repository, of the kernel's `DEFAULT_COMPOSED_IMAGE_CLASS`). A commit
956
+ * that predates the per-class layout declares it at the legacy root
957
+ * `infra/composed-process/`, which stays readable because committed release
958
+ * locks still pin such commits. Both at once, or a declaration in another
959
+ * class directory, is refused (`composedProcessDeclarationDir`). The
960
+ * directory holds its own `Dockerfile` and an `image-build-inputs.json` whose
961
+ * `workspacePackages` lists every member service's workspace package
962
+ * directory — the Dockerfile-only image shape `hashImageBuildInputs`
963
+ * already reads (`opencode-worker` is the other one). Its tag is therefore
964
+ * an ordinary `xema-image-input-sha256-v2` key, computed by the one
965
+ * implementation every producer uses, with `SERVICE` = the subject id and
966
+ * `SERVICE_PATH` = this directory;
967
+ * - the DECLARATION is that `image-build-inputs.json` existing in that
968
+ * directory at the pinned commit. A repository that declares none (and has
969
+ * no `infra/composed-process/` tree at all) has no composed image, and an
970
+ * edition carries no composed subject for it — which the deploy plane
971
+ * reports as "the producer builds no composed-process image at that
972
+ * commit". That is an honest absence, not a skipped subject: a DECLARED
973
+ * image that has not been built is still emitted, carries no
974
+ * `imageDigest`, and is undeliverable until it is sealed;
975
+ * - the subject id is `<declared source id>-composed-process`. The source
976
+ * id is DECLARED, never derived from where the repository is hosted: an
977
+ * external source's is the edition's `sources[].name` (which the walk
978
+ * also stamps as the biome's `repo`), a first-party repository's is
979
+ * declared in `first-party-source-ids.mjs`. A rename or host move changes
980
+ * `repositoryUrl` — provenance — and no subject id, `imageRef` or
981
+ * `contentHash` (the id is the `SERVICE` input of the tag). It cannot be
982
+ * a constant: two first-party repositories publish into one registry
983
+ * namespace, and the image lock refuses two subjects sharing an
984
+ * `imageRef`. The producing build publishes under this same name;
985
+ * - `imageRef` is composed exactly as the repository's service subjects are
986
+ * (`imageRefForSubject`, with the same registry and declared prefix).
987
+ */
988
+ export const COMPOSED_PROCESS_IMAGE_PATH = 'infra/composed-process';
989
+ export const COMPOSED_PROCESS_DECLARATION_FILE = 'image-build-inputs.json';
990
+ const COMPOSED_PROCESS_ID_SUFFIX = '-composed-process';
991
+
992
+ export function composedProcessSubjectId(sourceId) {
993
+ return `${assertDeclaredSourceId(sourceId, 'composed-process subject')}${COMPOSED_PROCESS_ID_SUFFIX}`;
994
+ }
995
+
996
+ /**
997
+ * The DECLARED source id of every repository this edition builds a service
998
+ * image from, keyed by the subject's `repo` handle — the edition's own
999
+ * `sources[].name` for a source-walked biome, the first-party declaration
1000
+ * otherwise. Never read off a URL.
1001
+ */
1002
+ export function editionSourceIds(lock) {
1003
+ const ids = new Map();
1004
+ for (const biome of lock.biomes ?? []) {
1005
+ if (biome.source === undefined) {continue;}
1006
+ if (biome.repo !== biome.source.name) {
1007
+ throw new Error(
1008
+ `lock biome "${biome.id}" is drawn from source "${biome.source.name}" but its repo ` +
1009
+ `handle is "${biome.repo}" — a source-walked biome's repo IS its declared source id`,
1010
+ );
1011
+ }
1012
+ ids.set(biome.repo, assertDeclaredSourceId(biome.source.name, `lock biome "${biome.id}" source`));
1013
+ }
1014
+ return { get: (repo) => ids.get(repo) ?? firstPartySourceId(repo) };
1015
+ }
1016
+
1017
+ /**
1018
+ * A composed-process declaration the build cannot be read from. Named so a
1019
+ * caller can tell "this image's inputs are unknown" from any other refusal:
1020
+ * the carried set is evidence, and with none there is no owner to derive.
1021
+ */
1022
+ export class ComposedProcessWorkerError extends Error {
1023
+ constructor(message) {
1024
+ super(message);
1025
+ this.name = 'ComposedProcessWorkerError';
1026
+ }
1027
+ }
1028
+
1029
+ export class ComposedProcessInputsError extends Error {
1030
+ constructor(message) {
1031
+ super(message);
1032
+ this.name = 'ComposedProcessInputsError';
1033
+ }
1034
+ }
1035
+
1036
+ /**
1037
+ * Which of a repository's biome services the composed image CARRIES, read from
1038
+ * the composed build's own inputs at the sealed commit.
1039
+ *
1040
+ * `image-build-inputs.json` lists the member package directories the image is
1041
+ * built from (its hash is the subject's `sourceManifestHash`). A service is
1042
+ * carried when its manifest-declared source directory (`subjectServicePath`,
1043
+ * the `components[].artifact.path` mapping) is one of them. Returns one row
1044
+ * per biome: the service ids carried and the ones not.
1045
+ */
1046
+ /** A component's declared `imageClass` validated with the kernel's own schema; absent stays undefined (the default applies). */
1047
+ function declaredImageClass(declared, subject, enums) {
1048
+ if (declared === undefined) {return undefined;}
1049
+ const parsed = enums.ComposedImageClassSchema.safeParse(declared);
1050
+ if (!parsed.success) {
1051
+ throw new ComposedProcessInputsError(
1052
+ `service "${subject.id}" of ${subject.repo} declares components[].imageClass ${JSON.stringify(declared)}, which is not an image class the kernel knows`,
1053
+ );
1054
+ }
1055
+ return parsed.data;
1056
+ }
1057
+
1058
+ function composedProcessCarriage(structure, enums, repo, sourceCommit, locate, declarationDir) {
1059
+ const inputsPath = `${declarationDir}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
1060
+ const { dir } = locate(repo);
1061
+ let members;
1062
+ try {
1063
+ members = readGitJsonAtCommit(dir, inputsPath, sourceCommit)?.workspacePackages;
1064
+ } catch (cause) {
1065
+ throw new ComposedProcessInputsError(
1066
+ `composed-process inputs "${inputsPath}" of "${repo}" are unreadable at ${sourceCommit} ` +
1067
+ `(${cause.message}) — the biomes the image carries cannot be derived`,
1068
+ );
1069
+ }
1070
+ if (!Array.isArray(members) || members.some((member) => typeof member !== 'string')) {
1071
+ throw new ComposedProcessInputsError(
1072
+ `composed-process inputs "${inputsPath}" of "${repo}" at ${sourceCommit} declare no ` +
1073
+ '"workspacePackages" array of directories — the biomes the image carries cannot be derived',
1074
+ );
1075
+ }
1076
+ const carried = new Set(members);
1077
+ const byBiome = new Map();
1078
+ for (const subject of structure.subjects) {
1079
+ if (subject.kind !== enums.RuntimeImageSubjectKind.Service || subject.repo !== repo) {continue;}
1080
+ const { path: servicePath, componentKind, imageClass: declaredClass } = subjectServiceSource(subject, sourceCommit, locate);
1081
+ const imageClass = declaredImageClass(declaredClass, subject, enums);
1082
+ for (const owner of subject.owners) {
1083
+ if (owner.kind !== enums.RuntimeImageOwnerKind.Biome) {continue;}
1084
+ const row = byBiome.get(owner.biomeId) ??
1085
+ { biomeId: owner.biomeId, carried: [], uncarried: [], carriedWorkers: [], packageDirs: {}, imageClasses: {} };
1086
+ if (!carried.has(servicePath)) {
1087
+ row.uncarried.push(subject.id);
1088
+ } else if (componentKind === 'worker') {
1089
+ // A worker is never a member: it runs as its own Deployment.
1090
+ row.carriedWorkers.push(subject.id);
1091
+ } else {
1092
+ row.carried.push(subject.id);
1093
+ // The member's package directory IS the composed build's own input entry.
1094
+ row.packageDirs[subject.id] = servicePath;
1095
+ // The class its manifest declares (`components[].imageClass`); absent stays undefined (the kernel default applies).
1096
+ row.imageClasses[subject.id] = imageClass;
1097
+ }
1098
+ byBiome.set(owner.biomeId, row);
1099
+ }
1100
+ }
1101
+ return [...byBiome.values()]
1102
+ .map((row) => ({
1103
+ ...row,
1104
+ carried: row.carried.sort(compareStrings),
1105
+ uncarried: row.uncarried.sort(compareStrings),
1106
+ carriedWorkers: row.carriedWorkers.sort(compareStrings),
1107
+ }))
1108
+ .sort((a, b) => compareStrings(a.biomeId, b.biomeId));
1109
+ }
1110
+
1111
+ function gitPathExistsAtCommit(dir, sourceCommit, repoPath) {
1112
+ try {
1113
+ execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}:${repoPath}`], {
1114
+ stdio: ['ignore', 'ignore', 'ignore'],
1115
+ });
1116
+ return true;
1117
+ } catch {
1118
+ return false;
1119
+ }
1120
+ }
1121
+
1122
+ /**
1123
+ * The repo-relative directory holding a repository's composed-process
1124
+ * DECLARATION at one commit, or null when it declares none. The default class's
1125
+ * directory and the legacy root are the two places an edition reads; a
1126
+ * declaration it cannot address (both present, another class, an
1127
+ * `infra/composed-process/` tree without a recognised one) is refused, because
1128
+ * a declaration the resolver cannot find must never read as "declares nothing".
1129
+ */
1130
+ function composedProcessDeclarationDir(dir, sourceCommit, enums) {
1131
+ const defaultClass = enums.DEFAULT_COMPOSED_IMAGE_CLASS;
1132
+ const classDir = `${COMPOSED_PROCESS_IMAGE_PATH}/${defaultClass}`;
1133
+ const declares = (candidate) =>
1134
+ gitPathExistsAtCommit(dir, sourceCommit, `${candidate}/${COMPOSED_PROCESS_DECLARATION_FILE}`);
1135
+ const inClassDir = declares(classDir);
1136
+ const atLegacyRoot = declares(COMPOSED_PROCESS_IMAGE_PATH);
1137
+ if (inClassDir && atLegacyRoot) {
1138
+ throw new ComposedProcessInputsError(
1139
+ `ambiguous composed-process declaration at ${sourceCommit}: both "${classDir}/${COMPOSED_PROCESS_DECLARATION_FILE}" ` +
1140
+ `and the legacy "${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}" exist`,
1141
+ );
1142
+ }
1143
+ const otherClasses = enums.ComposedImageClassSchema.options
1144
+ .filter((candidate) => candidate !== defaultClass)
1145
+ .filter((candidate) => declares(`${COMPOSED_PROCESS_IMAGE_PATH}/${candidate}`));
1146
+ if (otherClasses.length > 0) {
1147
+ throw new ComposedProcessInputsError(
1148
+ `composed-process declaration at ${sourceCommit} is in the "${otherClasses.join('", "')}" class ` +
1149
+ `director${otherClasses.length > 1 ? 'ies' : 'y'}, but an edition addresses one composed-process subject per ` +
1150
+ `repository, of the default class "${defaultClass}"; a "${otherClasses[0]}" image has no subject naming yet`,
1151
+ );
1152
+ }
1153
+ if (inClassDir) {return classDir;}
1154
+ if (atLegacyRoot) {return COMPOSED_PROCESS_IMAGE_PATH;}
1155
+ if (gitPathExistsAtCommit(dir, sourceCommit, COMPOSED_PROCESS_IMAGE_PATH)) {
1156
+ throw new ComposedProcessInputsError(
1157
+ `"${COMPOSED_PROCESS_IMAGE_PATH}" exists at ${sourceCommit} but declares no composed-process image the edition can ` +
1158
+ `read: looked at "${classDir}/${COMPOSED_PROCESS_DECLARATION_FILE}" and ` +
1159
+ `"${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}"`,
1160
+ );
1161
+ }
1162
+ return null;
1163
+ }
1164
+
1165
+ /**
1166
+ * Read each repository's composed-process DECLARATION at the commit the
1167
+ * edition builds its services from. Only repositories that contribute a
1168
+ * service subject are asked: a composed image carries services, so a
1169
+ * repository shipping none has nothing to compose.
1170
+ */
1171
+ export function readComposedProcessDeclarations(
1172
+ structure,
1173
+ enums,
1174
+ sourceIds,
1175
+ locate = FIRST_PARTY_TREES,
1176
+ ) {
1177
+ const commits = new Map();
1178
+ for (const subject of structure.subjects) {
1179
+ if (subject.kind !== enums.RuntimeImageSubjectKind.Service) {continue;}
1180
+ if (!subject.owners.some((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome)) {continue;}
1181
+ commits.set(subject.repo, subject.sourceCommit);
1182
+ }
1183
+ const declarations = [];
1184
+ for (const [repo, sourceCommit] of [...commits].sort(([a], [b]) => compareStrings(a, b))) {
1185
+ const { dir, base } = locate(repo);
1186
+ // ABSENCE IS ONLY AN ANSWER ABOUT A COMMIT THAT WAS READ. A repository
1187
+ // missing from this layout, or one lacking the pinned commit, would
1188
+ // otherwise read as "declares nothing", and `dist-check` would grade a lock
1189
+ // that dropped the composed subject as up to date.
1190
+ try {
1191
+ execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}^{commit}`], {
1192
+ stdio: ['ignore', 'ignore', 'ignore'],
1193
+ });
1194
+ } catch {
1195
+ throw new Error(
1196
+ `runtime image source "${repo}" pins commit "${sourceCommit}", which is absent from ` +
1197
+ `${dir} — its composed-process declaration cannot be read; fetch the repository ` +
1198
+ 'before resolving',
1199
+ );
1200
+ }
1201
+ const declarationDir = composedProcessDeclarationDir(dir, sourceCommit, enums);
1202
+ if (declarationDir === null) {continue;} // declares no composed-process image at this commit
1203
+ const declarationPath = `${declarationDir}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
1204
+ const sourceId = sourceIds.get(repo);
1205
+ const repoRelative = relative(base, dir).split(sep).join('/');
1206
+ declarations.push({
1207
+ repo,
1208
+ sourceId,
1209
+ sourceCommit,
1210
+ carriage: composedProcessCarriage(structure, enums, repo, sourceCommit, locate, declarationDir),
1211
+ path: `${repoRelative}/${declarationDir}`,
1212
+ sourceManifestPath: `${repoRelative}/${declarationPath}`,
1213
+ sourceManifestHash: exactSourceManifestHash(dir, declarationPath, sourceCommit),
1214
+ });
1215
+ }
1216
+ return declarations;
1217
+ }
1218
+
1219
+ /**
1220
+ * The image class of one composed artifact, DERIVED from its members' manifests: each member's `components[].imageClass`,
1221
+ * absent meaning the kernel's `DEFAULT_COMPOSED_IMAGE_CLASS`. All members must agree; a mixed artifact is refused naming
1222
+ * the artifact and the members of each class.
1223
+ */
1224
+ export function composedArtifactImageClass(artifactId, memberRows, declaredClassOf, enums) {
1225
+ const byClass = new Map();
1226
+ for (const { service } of memberRows) {
1227
+ const declared = declaredClassOf.get(service);
1228
+ const imageClass = declared ?? enums.DEFAULT_COMPOSED_IMAGE_CLASS;
1229
+ byClass.set(imageClass, [...(byClass.get(imageClass) ?? []), service]);
1230
+ }
1231
+ if (byClass.size > 1) {
1232
+ const detail = [...byClass].sort(([a], [b]) => compareStrings(a, b)).map(([imageClass, services]) => `${imageClass}: ${services.join(', ')}`).join('; ');
1233
+ throw new ComposedProcessInputsError(
1234
+ `composed-process artifact "${artifactId}" mixes image classes across its members (${detail}) — ` +
1235
+ 'an artifact has one class; split the members or align their components[].imageClass',
1236
+ );
1237
+ }
1238
+ return [...byClass.keys()][0];
1239
+ }
1240
+
1241
+ /** The services the edition SELECTS, read off its distribution lock: every service of every non-web biome whose image is its own (a shared-image worker is not one). */
1242
+ export function editionRosterServices(distributionLock) {
1243
+ return distributionLock.biomes
1244
+ .filter((biome) => biome.target !== 'web')
1245
+ .flatMap((biome) => {
1246
+ const shared = new Set(Object.keys(biome.workerImages ?? {}));
1247
+ return (biome.services ?? []).filter((service) => !shared.has(service));
1248
+ });
1249
+ }
1250
+
1251
+ /**
1252
+ * Refuse a build that does not cover every service of the edition roster: each must be a dedicated service subject or a
1253
+ * member of a built composed artifact. A service neither is never dropped from the lock silently, and the carry-forward
1254
+ * (which sees only what was built) is never the one to lose it.
1255
+ */
1256
+ export function assertBuiltCoversEditionRoster({ rosterServices, serviceSubjects, composedBuilt }) {
1257
+ const covered = new Set([
1258
+ ...serviceSubjects.map((subject) => subject.id),
1259
+ ...composedBuilt.flatMap((artifact) => (artifact.members ?? []).map((member) => member.service)),
1260
+ ]);
1261
+ const missing = [...new Set(rosterServices)].filter((service) => !covered.has(service)).sort(compareStrings);
1262
+ if (missing.length > 0) {
1263
+ throw new ComposedProcessInputsError(
1264
+ `the build covers no image for ${missing.map((service) => `"${service}"`).join(', ')} of the edition roster — ` +
1265
+ 'a service of the edition is never dropped from the lock',
1266
+ );
1267
+ }
1268
+ }
1269
+
1270
+ /** The members of a composed subject with the key of each: the member service's own image-input hash (the one function the packager's tag is emitted from). */
1271
+ export function composedMemberKeys({ subject, repoDirectory, sourceCommit, crossRepoInputsByConsumer, buildStepDirectories, hash = exactImageBuildInputHash }) {
1272
+ return subject.members.map((member) => ({
1273
+ ...member,
1274
+ key: hash(
1275
+ {
1276
+ repoDir: repoDirectory,
1277
+ service: member.service,
1278
+ servicePath: member.packageDir,
1279
+ commit: sourceCommit,
1280
+ crossRepoInputs: [
1281
+ ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000`) ?? []),
1282
+ ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000${member.service}`) ?? []),
1283
+ ],
1284
+ buildStepDirectories,
1285
+ },
1286
+ imageInputSourceHashAlgorithm(),
1287
+ ),
1288
+ }));
1289
+ }
1290
+
1291
+ /**
1292
+ * Add ONE `composed-process` subject per declaring repository to an edition's
1293
+ * structure. Pure over its inputs, so the structural check and the source
1294
+ * resolve derive the same subject from the same declarations.
1295
+ *
1296
+ * Owners are the biomes the image CARRIES, derived from the composed build's
1297
+ * inputs at the sealed commit (`declaration.carriage`): every biome at least one
1298
+ * of whose services is a member, never every biome with a service in the
1299
+ * repository. The members leave the structure and are sealed on the subject
1300
+ * as `members[{ service, packageDir }]` — the per-service membership truth: a
1301
+ * service runs from the composed digest if and only if it is a row (owners
1302
+ * never resolve a service). The edition carries the composed subject plus the
1303
+ * services that are no row (the specials, which keep their own subjects even
1304
+ * inside an owner biome), and nothing addresses a member's own image. A worker
1305
+ * is never a row; an own-image worker the composed build carries is refused.
1306
+ */
1307
+ export function withComposedProcessSubjects(
1308
+ structure,
1309
+ declarations,
1310
+ enums,
1311
+ requiredPlatforms,
1312
+ { onNotice = (message) => console.warn(`[dist:images] ${message}`) } = {},
1313
+ ) {
1314
+ if (declarations.length === 0) {return structure;}
1315
+ const subjects = new Map(structure.subjects.map((subject) => [subjectKey(subject), subject]));
1316
+ // A biome's members run from ONE composed image: a biome owning two composed
1317
+ // subjects would leave its pods two images to choose between.
1318
+ const composedOwnerOf = new Map();
1319
+ for (const declaration of declarations) {
1320
+ const services = structure.subjects.filter(
1321
+ (subject) =>
1322
+ subject.kind === enums.RuntimeImageSubjectKind.Service && subject.repo === declaration.repo,
1323
+ );
1324
+ if (!Array.isArray(declaration.carriage)) {
1325
+ throw new ComposedProcessInputsError(
1326
+ `composed-process declaration for "${declaration.repo}" carries no evidence of the ` +
1327
+ 'biomes the image carries — refusing to guess its owners',
1328
+ );
1329
+ }
1330
+ // Owners are the biomes ANY of whose services the image carries. A biome
1331
+ // with only some carried is an owner too; its services the image does not
1332
+ // carry are specials and keep their own subjects (named here). A biome with
1333
+ // none carried is simply not one.
1334
+ const biomeOwners = [];
1335
+ const members = new Set();
1336
+ const packageDirOf = new Map();
1337
+ const imageClassOf = new Map();
1338
+ for (const row of declaration.carriage) {
1339
+ // A worker is never a member. One the composed build carries must run
1340
+ // from a carried sibling's image (`workerImages`, declared in the
1341
+ // manifest), never be dropped into the composed process or fall back.
1342
+ for (const worker of row.carriedWorkers ?? []) {
1343
+ throw new ComposedProcessWorkerError(
1344
+ row.carried.length === 0
1345
+ ? `composed-process "${declaration.repo}" carries worker "${worker}" of biome "${row.biomeId}", ` +
1346
+ 'and that biome has no carried service whose image it could run — a worker is never a ' +
1347
+ 'composed member; carry a service of its biome or take the worker out of the composed inputs'
1348
+ : `composed-process "${declaration.repo}" carries worker "${worker}" of biome "${row.biomeId}" as ` +
1349
+ 'its own oci-image — a worker is never a composed member; declare it `artifact.kind: ' +
1350
+ `shared-oci-image\` of a carried service (${row.carried.join(', ')}) or take it out of the composed inputs`,
1351
+ );
1352
+ }
1353
+ if (row.carried.length === 0) {continue;}
1354
+ if (row.uncarried.length > 0) {
1355
+ onNotice(
1356
+ `composed-process "${declaration.repo}": biome "${row.biomeId}" is partly carried ` +
1357
+ `(carried: ${row.carried.join(', ')}; not carried: ${row.uncarried.join(', ')}) — ` +
1358
+ 'its uncarried services keep their own subjects',
1359
+ );
1360
+ }
1361
+ const other = composedOwnerOf.get(row.biomeId);
1362
+ if (other !== undefined) {
1363
+ throw new Error(
1364
+ `biome "${row.biomeId}" is carried by the composed-process images of both "${other}" and ` +
1365
+ `"${declaration.repo}" — a biome is owned by exactly one composed subject`,
1366
+ );
1367
+ }
1368
+ composedOwnerOf.set(row.biomeId, declaration.repo);
1369
+ biomeOwners.push({ kind: enums.RuntimeImageOwnerKind.Biome, biomeId: row.biomeId });
1370
+ for (const id of row.carried) {members.add(id);}
1371
+ for (const [id, packageDir] of Object.entries(row.packageDirs ?? {})) {packageDirOf.set(id, packageDir);}
1372
+ for (const [id, imageClass] of Object.entries(row.imageClasses ?? {})) {imageClassOf.set(id, imageClass);}
1373
+ }
1374
+ if (biomeOwners.length === 0) {
1375
+ throw new Error(
1376
+ `composed-process declaration for "${declaration.repo}" carries no biome's services in ` +
1377
+ 'this edition — a composed image carries services, so it has no owner',
1378
+ );
1379
+ }
1380
+ for (const subject of services) {
1381
+ if (subject.sourceCommit !== declaration.sourceCommit) {
1382
+ throw new Error(
1383
+ `runtime subject "${subject.kind}:${subject.id}" is resolved at ${subject.sourceCommit}, ` +
1384
+ `but "${declaration.repo}"'s composed-process image is declared at ` +
1385
+ `${declaration.sourceCommit} — one composed image is ONE build`,
1386
+ );
1387
+ }
1388
+ }
1389
+ // The registry and declared prefix the repository's services are published
1390
+ // under: `imageRefForSubject` is `<registry>/<prefix><id>`, so stripping
1391
+ // the service id recovers it exactly. Two services disagreeing would mean
1392
+ // one build published under two coordinates, which nothing can address.
1393
+ const namespaces = new Set(
1394
+ services.map((subject) => {
1395
+ if (!subject.imageRef.endsWith(subject.id)) {
1396
+ throw new Error(
1397
+ `runtime subject "${subject.kind}:${subject.id}" imageRef "${subject.imageRef}" ` +
1398
+ 'does not end with its id, so its namespace cannot be recovered',
1399
+ );
1400
+ }
1401
+ return subject.imageRef.slice(0, -subject.id.length);
1402
+ }),
1403
+ );
1404
+ if (namespaces.size !== 1) {
1405
+ throw new Error(
1406
+ `"${declaration.repo}"'s services publish under ${namespaces.size} namespaces ` +
1407
+ `(${[...namespaces].join(', ')}) — a composed image of one build has one`,
1408
+ );
1409
+ }
1410
+ // A MEMBER reaches its pods through this one subject (every member runs as a
1411
+ // group-of-one of the composed image), so it carries no subject of its own:
1412
+ // no `<imageRef>:<contentHash>` of its own is probed, verified or sealed. A
1413
+ // member is a service of this build the composed build's inputs carry
1414
+ // (`declaration.carriage`), every owner of which is a biome owning the
1415
+ // composed image. Any other service (not carried, or owned by anything but
1416
+ // a biome, e.g. shared with a platform service) keeps its own.
1417
+ const composedOwners = new Set(biomeOwners.map((owner) => owner.biomeId));
1418
+ const memberRows = [];
1419
+ for (const subject of services) {
1420
+ if (
1421
+ members.has(subject.id) &&
1422
+ subject.owners.length > 0 &&
1423
+ subject.owners.every(
1424
+ (owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && composedOwners.has(owner.biomeId),
1425
+ )
1426
+ ) {
1427
+ subjects.delete(subjectKey(subject));
1428
+ const packageDir = packageDirOf.get(subject.id);
1429
+ if (packageDir === undefined) {
1430
+ throw new ComposedProcessInputsError(
1431
+ `composed-process "${declaration.repo}": member "${subject.id}" has no package directory in ` +
1432
+ 'the composed build inputs — its shared-image workers would have nowhere to run from',
1433
+ );
1434
+ }
1435
+ memberRows.push({ service: subject.id, packageDir });
1436
+ }
1437
+ }
1438
+ if (memberRows.length === 0) {
1439
+ throw new ComposedProcessInputsError(
1440
+ `composed-process "${declaration.repo}" has no member: every service its build carries ` +
1441
+ `(${[...members].sort(compareStrings).join(', ')}) is also owned by something other than a biome ` +
1442
+ 'it owns (e.g. a platform service), so each keeps its own subject — a composed image with no ' +
1443
+ 'member runs nothing; take it out of the edition or carry a biome-owned service',
1444
+ );
1445
+ }
1446
+ memberRows.sort((a, b) => compareStrings(a.service, b.service));
1447
+ const id = composedProcessSubjectId(declaration.sourceId);
1448
+ const imageClass = composedArtifactImageClass(id, memberRows, imageClassOf, enums);
1449
+ addSubject(subjects, {
1450
+ kind: enums.RuntimeImageSubjectKind.ComposedProcess,
1451
+ id,
1452
+ imageRef: `${[...namespaces][0]}${id}`,
1453
+ repo: declaration.repo,
1454
+ path: declaration.path,
1455
+ sourceCommit: declaration.sourceCommit,
1456
+ sourceManifestHash: declaration.sourceManifestHash,
1457
+ sourceManifestPath: declaration.sourceManifestPath,
1458
+ requiredPlatforms,
1459
+ owners: biomeOwners,
1460
+ members: memberRows,
1461
+ imageClass,
1462
+ });
1463
+ }
1464
+ return {
1465
+ ...structure,
1466
+ subjects: [...subjects.values()]
1467
+ .map((subject) => ({ ...subject, owners: [...subject.owners].sort(compareOwner) }))
1468
+ .sort(compareSubject),
1469
+ };
1470
+ }
1471
+
1472
+ /**
1473
+ * The edition structure INCLUDING its composed-process subjects — the one
1474
+ * entry point the source resolve and the structural check share, so neither
1475
+ * can carry a subject the other does not. A repository that declares no
1476
+ * composed image at its pinned commit contributes no subject.
1477
+ */
1478
+ export function resolveComposedProcessStructure(
1479
+ lock,
1480
+ structure,
1481
+ enums,
1482
+ requiredPlatforms,
1483
+ { locate, onNotice } = {},
1484
+ ) {
1485
+ return withComposedProcessSubjects(
1486
+ structure,
1487
+ readComposedProcessDeclarations(
1488
+ structure,
1489
+ enums,
1490
+ editionSourceIds(lock),
1491
+ // Materialised only when something will be read through it.
1492
+ locate ?? sourceTreeLocator(externalSourceTrees(lock)),
1493
+ ),
1494
+ enums,
1495
+ requiredPlatforms,
1496
+ onNotice === undefined ? undefined : { onNotice },
1497
+ );
1498
+ }
1499
+
1500
+ /**
1501
+ * The exact commit every image source repository is resolved at.
1502
+ *
1503
+ * The PIN is the authority, not the local checkout. Requiring each submodule's
1504
+ * `HEAD` to equal its pin made regeneration impossible in any working tree that
1505
+ * had moved on — which is every working tree — and bought nothing: the hashes
1506
+ * read immutable Git objects at the pinned commit, so what is checked out
1507
+ * cannot change a digest. What DOES have to hold is that the pinned commit is
1508
+ * present in the local object database, which is asserted here.
1509
+ */
1510
+ function sourceCommitMap(structure, locate = FIRST_PARTY_TREES) {
1511
+ const commitByRepo = new Map();
1512
+ for (const source of [...structure.subjects, ...structure.webBiomes]) {
1513
+ if (!source.sourceCommit) {continue;}
1514
+ const incumbent = commitByRepo.get(source.repo);
1515
+ if (incumbent && incumbent !== source.sourceCommit) {
1516
+ throw new Error(
1517
+ `runtime image roster pins multiple commits for "${source.repo}"`,
1518
+ );
1519
+ }
1520
+ commitByRepo.set(source.repo, source.sourceCommit);
1521
+ }
1522
+ const repositories = [
1523
+ ...new Set([
1524
+ ...structure.subjects.map((subject) => subject.repo),
1525
+ ...structure.webBiomes.map((biome) => biome.repo),
1526
+ ]),
1527
+ ].sort(compareStrings);
1528
+ for (const repo of repositories) {
1529
+ const commit = commitByRepo.get(repo);
1530
+ if (!commit) {
1531
+ throw new Error(`runtime image source "${repo}" has no pinned commit`);
1532
+ }
1533
+ try {
1534
+ execFileSync(
1535
+ 'git',
1536
+ ['-C', locate(repo).dir, 'cat-file', '-e', `${commit}^{commit}`],
1537
+ { stdio: ['ignore', 'ignore', 'pipe'] },
1538
+ );
1539
+ } catch {
1540
+ throw new Error(
1541
+ `runtime image source "${repo}" pins commit "${commit}", which is absent ` +
1542
+ `from the local object database — fetch the repository before resolving`,
1543
+ );
1544
+ }
1545
+ }
1546
+ return commitByRepo;
1547
+ }
1548
+
1549
+ /**
1550
+ * The SERVICE directory an image is built from, repository-relative.
1551
+ *
1552
+ * A biome subject's `path` is the BIOME directory, and a biome can ship several
1553
+ * services — `agent-runtime` ships four. Hashing the biome directory would give
1554
+ * all four the same closure and would move all four when any one of them
1555
+ * changes, so the service directory is read from the manifest SSOT
1556
+ * (`xema.ships.apis[].path`) rather than synthesized. Non-biome subjects
1557
+ * (`sourceManifestPath` present) already carry their own service directory.
1558
+ *
1559
+ * Exported for `assemble-producer-input.mjs`, which must derive the SAME
1560
+ * service directory to hash the SAME build-input closure the lock names.
1561
+ */
1562
+ export function subjectServicePath(subject, sourceCommit, locate = FIRST_PARTY_TREES) {
1563
+ return subjectServiceSource(subject, sourceCommit, locate).path;
1564
+ }
1565
+
1566
+ /**
1567
+ * The repository-relative source directory of a subject and the kind of the
1568
+ * manifest component it is built from (`worker` for a worker component,
1569
+ * `service` otherwise — a legacy `ships.apis[]` entry or a subject with its own
1570
+ * source manifest is not a worker).
1571
+ */
1572
+ function subjectServiceSource(subject, sourceCommit, locate = FIRST_PARTY_TREES) {
1573
+ const { dir: repoDirectory, base } = locate(subject.repo);
1574
+ const toRepoRelative = (absolute) =>
1575
+ relative(repoDirectory, absolute).split(sep).join('/') || '.';
1576
+ if (subject.sourceManifestPath) {
1577
+ return { path: toRepoRelative(join(base, subject.path)), componentKind: 'service', imageClass: undefined };
1578
+ }
1579
+ const biomePath = toRepoRelative(join(base, subject.path));
1580
+ const manifestPath =
1581
+ biomePath === '.' ? 'xema-biome.json' : `${biomePath}/xema-biome.json`;
1582
+ const manifest = readGitJsonAtCommit(
1583
+ repoDirectory,
1584
+ manifestPath,
1585
+ sourceCommit,
1586
+ );
1587
+ const xema = manifest?.xema;
1588
+ const apis = xema?.ships?.apis;
1589
+ const legacyApis = Array.isArray(apis) ? apis : [];
1590
+ // component-contract-v5: a component's source directory is declared by its
1591
+ // own `artifact.path`.
1592
+ //
1593
+ // Both `service` AND `worker` kinds qualify. Restricting this to `service`
1594
+ // was correct only while a runtime component was spelled in the legacy
1595
+ // `ships.apis[]` block; v5 dropped that field, so `opencode-worker` —
1596
+ // `kind: "worker"`, `scheduler: "runtime"` — fell through to the legacy
1597
+ // branch and failed with "ships no apis" even though its manifest declares
1598
+ // everything needed. What identifies the subject is
1599
+ // `protocol.serviceName`, not the component kind.
1600
+ const components = Array.isArray(xema?.components) ? xema.components : [];
1601
+ const v4ServiceComponents = components.filter(
1602
+ (component) =>
1603
+ component !== null &&
1604
+ typeof component === 'object' &&
1605
+ (component.kind === 'service' || component.kind === 'worker'),
1606
+ );
1607
+ if (legacyApis.length > 0 && v4ServiceComponents.length > 0) {
1608
+ throw new Error(
1609
+ `${subject.repo}@${sourceCommit}:${manifestPath} declares services in BOTH ` +
1610
+ `xema.ships.apis[] (legacy) and xema.components[] kind "service" ` +
1611
+ `(component-contract-v4) — a biome must use exactly one shape`,
1612
+ );
1613
+ }
1614
+ let servicePath;
1615
+ let componentKind = 'service';
1616
+ let imageClass;
1617
+ if (v4ServiceComponents.length > 0) {
1618
+ // A SERVICE is identified by `protocol.serviceName`; a WORKER has no
1619
+ // service protocol and is identified by its component `key`. Matching only
1620
+ // on `serviceName` silently found nothing for every worker subject and
1621
+ // reported it as an undeclared service directory.
1622
+ const component = v4ServiceComponents.find(
1623
+ (entry) =>
1624
+ entry?.protocol?.serviceName === subject.id || entry?.key === subject.id,
1625
+ );
1626
+ if (
1627
+ !component ||
1628
+ typeof component?.artifact?.path !== 'string' ||
1629
+ component.artifact.path.length === 0
1630
+ ) {
1631
+ throw new Error(
1632
+ `${subject.repo}@${sourceCommit}:${manifestPath} declares no xema.components[] ` +
1633
+ `kind "service"/"worker" entry identified as "${subject.id}" (by ` +
1634
+ `protocol.serviceName or component key) with an ` +
1635
+ `artifact.path — the service source directory is the manifest's to declare ` +
1636
+ `and must never be synthesized`,
1637
+ );
1638
+ }
1639
+ servicePath = component.artifact.path;
1640
+ componentKind = component.kind;
1641
+ imageClass = component.imageClass;
1642
+ } else {
1643
+ if (legacyApis.length === 0) {
1644
+ throw new Error(
1645
+ `${subject.repo}@${sourceCommit}:${manifestPath} ships no apis, but runtime ` +
1646
+ `subject "${subject.kind}:${subject.id}" claims to be one of them`,
1647
+ );
1648
+ }
1649
+ const api = legacyApis.find((entry) => entry?.name === subject.id);
1650
+ if (!api || typeof api.path !== 'string' || api.path.length === 0) {
1651
+ throw new Error(
1652
+ `${subject.repo}@${sourceCommit}:${manifestPath} declares no ships.apis[] entry ` +
1653
+ `named "${subject.id}" with a "path" — the service source directory is the ` +
1654
+ `manifest's to declare and must never be synthesized`,
1655
+ );
1656
+ }
1657
+ servicePath = api.path;
1658
+ }
1659
+ return { path: toRepoRelative(join(base, subject.path, servicePath)), componentKind, imageClass };
1660
+ }
1661
+
1662
+ function assertSourceManifestParity(structure, commitByRepo, locate = FIRST_PARTY_TREES) {
1663
+ for (const source of structure.subjects) {
1664
+ const { dir: repoDirectory, base } = locate(source.repo);
1665
+ const sourceCommit = commitByRepo.get(source.repo);
1666
+ const manifestPath = source.sourceManifestPath
1667
+ ? relative(repoDirectory, join(base, source.sourceManifestPath))
1668
+ .split(sep)
1669
+ .join('/')
1670
+ : relative(repoDirectory, join(base, source.path, 'xema-biome.json'))
1671
+ .split(sep)
1672
+ .join('/');
1673
+ const actual = exactSourceManifestHash(
1674
+ repoDirectory,
1675
+ manifestPath,
1676
+ sourceCommit,
1677
+ );
1678
+ if (actual !== source.sourceManifestHash) {
1679
+ throw new Error(
1680
+ `runtime subject "${source.kind}:${source.id}" sourceManifestHash does not match ${source.repo}@${sourceCommit}:${manifestPath}`,
1681
+ );
1682
+ }
1683
+ }
1684
+ for (const biome of structure.webBiomes) {
1685
+ const { dir: repoDirectory, base } = locate(biome.repo);
1686
+ const sourceCommit = commitByRepo.get(biome.repo);
1687
+ const manifestPath = relative(
1688
+ repoDirectory,
1689
+ join(base, biome.path, 'xema-biome.json'),
1690
+ )
1691
+ .split(sep)
1692
+ .join('/');
1693
+ const actual = exactSourceManifestHash(
1694
+ repoDirectory,
1695
+ manifestPath,
1696
+ sourceCommit,
1697
+ );
1698
+ if (actual !== biome.sourceManifestHash) {
1699
+ throw new Error(
1700
+ `web biome "${biome.biomeId}" sourceManifestHash does not match ${biome.repo}@${sourceCommit}:${manifestPath}`,
1701
+ );
1702
+ }
1703
+ }
1704
+ }
1705
+
1706
+ /**
1707
+ * AN EXTERNAL SOURCE THAT VENDORS CROSS-REPOSITORY CONTENT IS REFUSED, LOUDLY.
1708
+ *
1709
+ * `runtime-image-source-index.json` declares the vendoring this fleet's own
1710
+ * repositories do, and an external source has no entry there — correctly, since
1711
+ * the index is the vendor's view of the vendor's repositories. Reading that
1712
+ * absence as "this repository vendors nothing" is the silent half: a Dockerfile
1713
+ * that copies a tree in from elsewhere would have those bytes drop out of the
1714
+ * closure, and the hash would name an image whose content it does not describe.
1715
+ *
1716
+ * So the ABSENCE is checked rather than assumed. The producing repository's own
1717
+ * committed record — the same file `vendor-cross-repo-inputs.mjs` reads to emit
1718
+ * the `--vendored` arguments CI hands the tag computation — is read at the
1719
+ * locked commit. Absent means genuinely nothing to vendor, which is the honest
1720
+ * empty. PRESENT means this plane cannot pin the other side (it has no checkout
1721
+ * of a repository the customer vendors from, and no authority to resolve one),
1722
+ * and an undeclared input must fail rather than silently drop out.
1723
+ */
1724
+ function assertExternalSourcesDeclareNoVendoring(lock, locate, commitByRepo) {
1725
+ const external = new Set(
1726
+ (lock.biomes ?? [])
1727
+ .filter((biome) => biome.source !== undefined)
1728
+ .map((biome) => biome.repo),
1729
+ );
1730
+ for (const repo of [...external].sort(compareStrings)) {
1731
+ const { dir } = locate(repo);
1732
+ const commit = commitByRepo.get(repo);
1733
+ if (!commit) {continue;}
1734
+ // Existence is probed with `cat-file -e` rather than by catching the read:
1735
+ // a failed read writes `fatal: path ... does not exist` to stderr, and a
1736
+ // `fatal:` printed during a successful run is a false signal a reader has
1737
+ // to spend time discounting.
1738
+ let present;
1739
+ try {
1740
+ execFileSync('git', ['-C', dir, 'cat-file', '-e', `${commit}:${CROSS_REPO_BUILD_INPUTS_PATH}`], {
1741
+ stdio: ['ignore', 'ignore', 'ignore'],
1742
+ });
1743
+ present = true;
1744
+ } catch {
1745
+ present = false;
1746
+ }
1747
+ if (!present) {continue;} // absent — nothing vendored, which is the honest empty
1748
+ const record = readGitJsonAtCommit(dir, CROSS_REPO_BUILD_INPUTS_PATH, commit);
1749
+ const inputs = Array.isArray(record?.inputs) ? record.inputs : [];
1750
+ if (inputs.length === 0) {continue;}
1751
+ throw new Error(
1752
+ `external source "${repo}" declares ${inputs.length} cross-repository build ` +
1753
+ `input(s) in ${CROSS_REPO_BUILD_INPUTS_PATH} at ${commit}. This plane cannot pin ` +
1754
+ 'the other side of them — it holds no checkout of the repositories they vendor ' +
1755
+ 'from — so the image content hash would omit bytes the build bakes in and name a ' +
1756
+ 'coordinate it does not describe. Refusing rather than hashing an incomplete ' +
1757
+ 'closure.',
1758
+ );
1759
+ }
1760
+ }
1761
+
1762
+ /**
1763
+ * The vendored cross-repository build inputs of THIS roster's images, keyed
1764
+ * `${consumerRepo}\0${consumerService ?? ''}`.
1765
+ *
1766
+ * EVERY COMMIT HERE COMES FROM A REPOSITORY PIN, NEVER FROM ROSTER MEMBERSHIP.
1767
+ *
1768
+ * The consumer commit — the commit whose committed
1769
+ * `tooling/release/cross-repo-build-inputs.json` names the vendored revisions —
1770
+ * is the consumer repository's `repositoryPins` entry. It used to be read out
1771
+ * of the edition's resolved commit map, which knows a repository only if some
1772
+ * SELECTED biome happens to come from it. That made an image's identity depend
1773
+ * on unrelated membership, and made a trimmed edition unaddressable: an edition
1774
+ * shipping no `repos/xema-community` biome refused with "cross-repo build inputs
1775
+ * are declared for repos/xema-community, but this distribution resolves no
1776
+ * commit for it" — although it shipped no xema-community image at all. For a
1777
+ * first-party subject the two are the same commit by construction (a subject's
1778
+ * `sourceCommit` IS its repository pin), so every edition that resolved before
1779
+ * resolves to byte-identical hashes now.
1780
+ *
1781
+ * Only consumers the roster actually BUILDS FROM are resolved: a consumer with
1782
+ * no selected subject contributes no input to any hash here, so reading its
1783
+ * record would be work that grades nothing. The vendored revision itself is the
1784
+ * consumer's own record at its pin (see the reasoning above the call site): the
1785
+ * pin fixes the record, and the record fixes the bytes. A consumer, or a
1786
+ * repository it vendors from, with NO repository pin is refused by name — the
1787
+ * image cannot be addressed without one.
1788
+ */
1789
+ export function resolveCrossRepoInputsForRoster({
1790
+ runtimeSourceIndex,
1791
+ subjects,
1792
+ resolveVendored = resolveCrossRepoVendoredInputs,
1793
+ repoRoot = sourcesRoot(),
1794
+ // The subject kind that builds EVERY input of its repository (the composed
1795
+ // process image carries every service of its build). Undefined: none does.
1796
+ composedProcessKind = undefined,
1797
+ }) {
1798
+ const pins = new Map(
1799
+ runtimeSourceIndex.repositoryPins.map((pin) => [pin.repo, pin]),
1800
+ );
1801
+ const requirePin = (repo, role) => {
1802
+ const pin = pins.get(repo);
1803
+ if (!pin) {
1804
+ throw new Error(
1805
+ `cross-repo build inputs need a repository pin for "${repo}" (${role}), ` +
1806
+ 'and runtime-image-source-index.json declares none — the vendored content ' +
1807
+ 'cannot be pinned, so the image cannot be addressed',
1808
+ );
1809
+ }
1810
+ return pin;
1811
+ };
1812
+ const builds = (input) =>
1813
+ subjects.some(
1814
+ (subject) =>
1815
+ subject.repo === input.consumerRepo &&
1816
+ (input.consumerService === undefined || input.consumerService === null ||
1817
+ input.consumerService === subject.id ||
1818
+ (composedProcessKind !== undefined && subject.kind === composedProcessKind)),
1819
+ );
1820
+ const needed = runtimeSourceIndex.crossRepoBuildInputs.filter(builds);
1821
+ const vendoredCommitByInputKey = new Map();
1822
+ for (const consumerRepo of [
1823
+ ...new Set(needed.map((input) => input.consumerRepo)),
1824
+ ].sort(compareStrings)) {
1825
+ const consumerCommit = requirePin(consumerRepo, 'the consuming repository').sourceCommit;
1826
+ for (const subject of subjects) {
1827
+ if (subject.repo === consumerRepo && subject.sourceCommit !== undefined &&
1828
+ subject.sourceCommit !== consumerCommit) {
1829
+ throw new Error(
1830
+ `runtime subject "${subject.kind}:${subject.id}" is resolved at ` +
1831
+ `${subject.sourceCommit}, but "${consumerRepo}" is pinned at ${consumerCommit}`,
1832
+ );
1833
+ }
1834
+ }
1835
+ // The record is read against EVERY input the index declares for this
1836
+ // consumer, not the roster-filtered subset: set equality with the
1837
+ // consumer's record is the fence against a drifted declaration, and it must
1838
+ // not weaken because an edition ships fewer services.
1839
+ for (const resolved of resolveVendored({
1840
+ indexInputs: runtimeSourceIndex.crossRepoBuildInputs,
1841
+ consumerRepo,
1842
+ consumerRepoDir: join(repoRoot, consumerRepo),
1843
+ consumerCommit,
1844
+ })) {
1845
+ vendoredCommitByInputKey.set(
1846
+ consumerInputKey(consumerRepo, {
1847
+ sourceRepo: relative(repoRoot, resolved.sourceRepoDir),
1848
+ sourcePath: resolved.sourcePath,
1849
+ destinationPath: resolved.destinationPath,
1850
+ }),
1851
+ resolved.sourceCommit,
1852
+ );
1853
+ }
1854
+ }
1855
+ const crossRepoInputsByConsumer = new Map();
1856
+ for (const input of needed) {
1857
+ requirePin(input.sourceRepo, `vendored into "${input.consumerRepo}"`);
1858
+ // A null `consumerService` means the vendoring step runs for EVERY service
1859
+ // built from that repository; it is keyed on the repository alone.
1860
+ const key = `${input.consumerRepo}\u0000${input.consumerService ?? ''}`;
1861
+ const sourceCommit = vendoredCommitByInputKey.get(
1862
+ consumerInputKey(input.consumerRepo, input),
1863
+ );
1864
+ if (!sourceCommit) {
1865
+ throw new Error(
1866
+ `cross-repo build input for "${input.consumerService}" vendoring ` +
1867
+ `"${input.sourceRepo}:${input.sourcePath}" has no commit in ` +
1868
+ `"${input.consumerRepo}"'s own cross-repo build-input record — its ` +
1869
+ `vendored content cannot be pinned, so the image cannot be addressed`,
1870
+ );
1871
+ }
1872
+ const entries = crossRepoInputsByConsumer.get(key) ?? [];
1873
+ entries.push({
1874
+ destinationPath: input.destinationPath,
1875
+ sourceRepoDir: join(repoRoot, input.sourceRepo),
1876
+ sourcePath: input.sourcePath,
1877
+ sourceCommit,
1878
+ });
1879
+ crossRepoInputsByConsumer.set(key, entries);
1880
+ }
1881
+ return crossRepoInputsByConsumer;
1882
+ }
1883
+
1884
+ /**
1885
+ * The release's inputs: the COMMITTED lock, and the edition's own indexes
1886
+ * (`editionPinnedInputs`: the committed ones, or rebuilt at the repositories the
1887
+ * edition holds).
1888
+ */
1889
+ async function releaseImageInputs(schemas, id) {
1890
+ const pinned = await editionPinnedInputs(id, { schemas });
1891
+ return {
1892
+ lock: validateImmutableDistributionLock(
1893
+ readBoundDistributionFile({
1894
+ distributionsRoot: editionsRoot(),
1895
+ distributionId: id,
1896
+ fileName: 'distribution.lock.json',
1897
+ // A pure READ of the committed lock: the tolerant reader, never the writer.
1898
+ parse: (raw) => parseResolvedDistributionLock(schemas, raw),
1899
+ }).value,
1900
+ ),
1901
+ runtimeSourceIndex: pinned.runtimeImageSourceIndex,
1902
+ biomeIndex: pinned.biomeIndex,
1903
+ requiredPlatforms: resolveDistributionRequiredPlatforms(schemas, id),
1904
+ declaredSources: resolveDistributionDeclaredSources(schemas, id),
1905
+ };
1906
+ }
1907
+
1908
+ /**
1909
+ * A develop snapshot's inputs: the SNAPSHOT distribution lock and the
1910
+ * snapshot indexes and chain (`develop-snapshot-inputs.mjs`), all already
1911
+ * resolved at the snapshot's pins. The runtime index carries the snapshot pins
1912
+ * already and is not re-pinned here.
1913
+ */
1914
+ function snapshotImageInputs(schemas, { inputs, lock }) {
1915
+ return {
1916
+ lock: validateImmutableDistributionLock(lock),
1917
+ runtimeSourceIndex: inputs.runtimeImageSourceIndex,
1918
+ biomeIndex: inputs.biomeIndex,
1919
+ requiredPlatforms: schemas.resolveRuntimeImagePlatforms(inputs.composed.requiredPlatforms),
1920
+ declaredSources: inputs.composed.declaredSources,
1921
+ };
1922
+ }
1923
+
1924
+ /**
1925
+ * The supplemental source declarations an image resolve reads beside
1926
+ * `runtimeSourceIndex`. An index with no supplemental subjects (a customer
1927
+ * edition's own rows) needs none, and its workspace need not declare the
1928
+ * section; any other reads the edition workspace's.
1929
+ */
1930
+ export function supplementalSourcesFor(runtimeSourceIndex) {
1931
+ return runtimeSourceIndex.supplementalSubjects.length === 0
1932
+ ? []
1933
+ : workspaceSection('runtimeImageSources', 'resolving supplemental image subjects').supplementalSources;
1934
+ }
1935
+
1936
+ /**
1937
+ * The image ROWS of one edition: its subjects (hashed at the commits the lock
1938
+ * pins), its platform-service bindings and its bundled web biomes, after every
1939
+ * check that needs no parsed lock. Rows are not a lock: the kernel's image-lock
1940
+ * grammar is the grammar of a COMPLETE lock, and a caller that holds only part
1941
+ * of one (a customer's own rows, merged with the catalogue's) parses the lock
1942
+ * it completes. `resolveImages` is these rows parsed as the whole lock.
1943
+ */
1944
+ export async function resolveImageRows(id, { inputs, lock, onSubjectHashed, recordedImages, previousLock = null } = {}) {
1945
+ assertCanonicalDistributionId(id);
1946
+ if ((inputs === undefined) !== (lock === undefined)) {
1947
+ throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
1948
+ }
1949
+ // A build record (images-from-record.mjs) replaces the hashing on the snapshot lane only, and never beside a probe.
1950
+ if (recordedImages !== undefined && (inputs === undefined || onSubjectHashed !== undefined)) {
1951
+ throw new Error('resolveImages takes recorded images only on the snapshot lane, and never with onSubjectHashed');
1952
+ }
1953
+ const schemas = await loadKernelDistributionSchemas();
1954
+ const {
1955
+ lock: distributionLock,
1956
+ runtimeSourceIndex,
1957
+ biomeIndex,
1958
+ requiredPlatforms,
1959
+ declaredSources,
1960
+ } = inputs === undefined ? await releaseImageInputs(schemas, id) : snapshotImageInputs(schemas, { inputs, lock });
1961
+ // Materialise every EXTERNAL source at the commit the lock pins, and resolve
1962
+ // every `repo` handle through one locator from here down.
1963
+ const locate = sourceTreeLocator(externalSourceTrees(distributionLock));
1964
+ const structure = resolveComposedProcessStructure(
1965
+ distributionLock,
1966
+ resolveImageStructure(
1967
+ distributionLock,
1968
+ runtimeSourceIndex,
1969
+ schemas,
1970
+ requiredPlatforms,
1971
+ declaredSources,
1972
+ {
1973
+ biomeIndex,
1974
+ supplementalSources: supplementalSourcesFor(runtimeSourceIndex),
1975
+ thirdPartyPins: loadThirdPartySourcePins(dataRoot()),
1976
+ },
1977
+ ),
1978
+ schemas,
1979
+ requiredPlatforms,
1980
+ { locate },
1981
+ );
1982
+ const commitsBefore = sourceCommitMap(structure, locate);
1983
+ assertSourceManifestParity(structure, commitsBefore, locate);
1984
+ assertExternalSourcesDeclareNoVendoring(distributionLock, locate, commitsBefore);
1985
+
1986
+ const buildStepDirectoriesByRepo = new Map();
1987
+ for (const directory of runtimeSourceIndex.buildStepContextDirectories) {
1988
+ const entries = buildStepDirectoriesByRepo.get(directory.repo) ?? [];
1989
+ entries.push(directory.path);
1990
+ buildStepDirectoriesByRepo.set(directory.repo, entries);
1991
+ }
1992
+ // Vendored content is pinned from the CONSUMER REPOSITORY'S OWN committed
1993
+ // record of what it vendored (`resolveCrossRepoVendoredInputs`) — never from
1994
+ // the index's `repositoryPins` mirror of the vendored axis, and never from this
1995
+ // edition's resolved commit map. The record is read at the CONSUMER's
1996
+ // repository pin, which is what makes the result roster-independent (see
1997
+ // `resolveCrossRepoInputsForRoster`).
1998
+ //
1999
+ // An image's identity is edition-INDEPENDENT, and that still holds: the record
2000
+ // is a property of the consumer's COMMIT, not of any edition, so every edition
2001
+ // shipping a subject derives the same `contentHash` for it.
2002
+ // The first-party `biome-host-api` image is built once, by the producing
2003
+ // repository's CI, which has no notion of an edition and vendors every
2004
+ // declared cross-repo input. Reading the vendored commit from `commitsBefore`
2005
+ // made that edition-independent quantity depend on the edition, which failed
2006
+ // in both directions at once:
2007
+ // • it is UNCOMPUTABLE for an edition that does not itself resolve the
2008
+ // source repo — `appliance-lean` does not ship the xema-store
2009
+ // biome, so re-resolving it threw here and their locks could not
2010
+ // be regenerated at all, even though the image they name is the very one
2011
+ // the other six editions resolve fine;
2012
+ // • had it not thrown it would have been WORSE, silently minting a second
2013
+ // content hash for one image — a coordinate no CI ever pushes, which is
2014
+ // the exact "the tag the lock names does not exist" failure the delivery
2015
+ // chain exists to prevent (.claude/rules/image-release-coordinate.md).
2016
+ //
2017
+ // Reading `index.repositoryPins` instead was the SAME failure by a different
2018
+ // route, and it shipped. The mirror is generated from the index and committed
2019
+ // separately, so lagging it by a pin is its normal resting state — and the
2020
+ // vendored commit is framed into the image-input hash's component KEY, so a
2021
+ // one-commit lag mints a `contentHash` for byte-identical bytes that no CI run
2022
+ // has ever pushed. Measured 2026-09-07 on `origin/main`: five of sixty-eight
2023
+ // coordinates 404'd for exactly this, while the tags CI DID push were served
2024
+ // on both platforms. The gate that was supposed to catch it,
2025
+ // `tooling/release/check-image-tag-parity.mjs` in the AGGREGATOR (a path that
2026
+ // resolves in no producing repository, though two of them cite it as if it
2027
+ // did), read `index.repositoryPins` too — so both of its halves consumed the
2028
+ // one input that had diverged, and it reported 474/474 verified while the
2029
+ // delivery plane was undeliverable. It now reads the consumer's record, as
2030
+ // this does.
2031
+ const crossRepoInputsByConsumer = resolveCrossRepoInputsForRoster({
2032
+ runtimeSourceIndex,
2033
+ subjects: structure.subjects,
2034
+ composedProcessKind: schemas.RuntimeImageSubjectKind.ComposedProcess,
2035
+ });
2036
+ // A composed image carries EVERY service of its build, so it vendors every
2037
+ // cross-repository input its repository declares — repository-wide and
2038
+ // per-service alike. The set is a property of the repository at its commit,
2039
+ // never of the edition, so every edition derives the same tag for it.
2040
+ const crossRepoInputsFor = (subject) =>
2041
+ subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess
2042
+ ? [...crossRepoInputsByConsumer]
2043
+ .filter(([key]) => key.startsWith(`${subject.repo}\u0000`))
2044
+ .sort(([a], [b]) => compareStrings(a, b))
2045
+ .flatMap(([, entries]) => entries)
2046
+ : [
2047
+ ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000`) ?? []),
2048
+ ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000${subject.id}`) ?? []),
2049
+ ];
2050
+ const subjects = [];
2051
+ for (const subject of structure.subjects) {
2052
+ const sourceCommit = commitsBefore.get(subject.repo);
2053
+ const repoDirectory = locate(subject.repo).dir;
2054
+ const servicePath = subjectServicePath(subject, sourceCommit, locate);
2055
+ const { sourceManifestPath: ignoredSourceManifestPath, ...lockedSubject } =
2056
+ subject;
2057
+ void ignoredSourceManifestPath;
2058
+ // With a build record the content hash and source commit are the record's, carried and never recomputed. The one
2059
+ // exception a record source may declare (`computesHash`, develop image-record only) is a subject no build record
2060
+ // covers: a member that keeps its current resolution (develop-current-resolution.mjs, XEMA-2373), hashed here.
2061
+ const isComposed = subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess;
2062
+ const computesHash = recordedImages === undefined || recordedImages.computesHash?.(lockedSubject) === true;
2063
+ const hashed = computesHash ? {
2064
+ ...lockedSubject,
2065
+ sourceCommit,
2066
+ // The hashing lane computes each member's key itself: the member service's own image-input hash.
2067
+ ...(isComposed ? {
2068
+ members: composedMemberKeys({
2069
+ subject,
2070
+ repoDirectory,
2071
+ sourceCommit,
2072
+ crossRepoInputsByConsumer,
2073
+ buildStepDirectories: buildStepDirectoriesByRepo.get(subject.repo) ?? [],
2074
+ }),
2075
+ } : {}),
2076
+ contentHash: exactImageBuildInputHash(
2077
+ {
2078
+ repoDir: repoDirectory,
2079
+ service: subject.id,
2080
+ servicePath,
2081
+ commit: sourceCommit,
2082
+ crossRepoInputs: crossRepoInputsFor(subject),
2083
+ buildStepDirectories:
2084
+ buildStepDirectoriesByRepo.get(subject.repo) ?? [],
2085
+ },
2086
+ imageInputSourceHashAlgorithm(),
2087
+ ),
2088
+ } : { ...lockedSubject, ...recordedImages.take({ ...lockedSubject, sourceCommit }) };
2089
+ subjects.push(hashed);
2090
+ // The hash is the last thing a registry probe waits for: hand the subject
2091
+ // over now, and let the event loop run so the probes of the subjects
2092
+ // already hashed progress while the rest hash (hashing is synchronous).
2093
+ if (onSubjectHashed !== undefined) {
2094
+ onSubjectHashed(hashed);
2095
+ await yieldToEventLoop();
2096
+ }
2097
+ }
2098
+ // Every subject missing from, contradicted by or extra in the build record, refused at once.
2099
+ recordedImages?.settle();
2100
+ // The composed artifacts are sealed with their generation: the roster's composed services must all be built, then the
2101
+ // kernel carries the previous lock's still-pinned artifacts forward (`previousLock` null or a v4 lock: generation 1).
2102
+ const ComposedProcess = schemas.RuntimeImageSubjectKind.ComposedProcess;
2103
+ const composedBuilt = subjects.filter((subject) => subject.kind === ComposedProcess);
2104
+ assertBuiltCoversEditionRoster({
2105
+ rosterServices: editionRosterServices(distributionLock),
2106
+ serviceSubjects: [...subjects, ...structure.thirdPartySubjects].filter((subject) => subject.kind === schemas.RuntimeImageSubjectKind.Service),
2107
+ composedBuilt,
2108
+ });
2109
+ if (composedBuilt.length > 0 || previousLock !== null) {
2110
+ const sealed = schemas.carryForwardComposedArtifacts({ previous: previousLock, built: composedBuilt });
2111
+ subjects.splice(0, subjects.length, ...subjects.filter((subject) => subject.kind !== ComposedProcess), ...sealed);
2112
+ subjects.sort(compareSubject);
2113
+ }
2114
+ // Release lane only: a develop snapshot or customer overlay is not covered by
2115
+ // the committed ledger.
2116
+ if (inputs === undefined) {
2117
+ assertBuildLedgerComplete({
2118
+ ledger: readBuildLedger(),
2119
+ distributionId: id,
2120
+ subjects,
2121
+ requiredPlatforms,
2122
+ expectedAt: join(dataRoot(), BUILD_LEDGER_FILE),
2123
+ });
2124
+ }
2125
+ const webBiomes = structure.webBiomes.map((biome) => ({
2126
+ ...biome,
2127
+ sourceCommit: commitsBefore.get(biome.repo),
2128
+ }));
2129
+ // The SAME locator the hash used. The default locator resolves every repo
2130
+ // handle to a first-party tree under the aggregator root, so for an edition
2131
+ // with an external source it looked for `<sourcesRoot()>/<source name>`, found no
2132
+ // repository there, and refused with "absent from the local object database"
2133
+ // — after every hash had already been computed correctly through `locate`.
2134
+ // Measured 2026-09-16 resolving a customer edition against a customer root.
2135
+ const commitsAfter = sourceCommitMap(structure, locate);
2136
+ if (serializeJson([...commitsBefore]) !== serializeJson([...commitsAfter])) {
2137
+ throw new Error(
2138
+ 'refusing image-lock write: a source repository commit changed while hashing',
2139
+ );
2140
+ }
2141
+ // After the ledger check and the hashing: a third-party subject has no ledger row and no checkout, and its
2142
+ // `contentHash` is the publisher's statement, so none of the above read it.
2143
+ return {
2144
+ subjects: structure.thirdPartySubjects.length === 0 ? subjects : [...subjects, ...structure.thirdPartySubjects].sort(compareSubject),
2145
+ thirdPartySubjects: structure.thirdPartySubjects,
2146
+ platformServiceBindings: structure.platformServiceBindings,
2147
+ webBiomes,
2148
+ contractAuthority: runtimeSourceIndex.contractAuthority,
2149
+ commits: [...commitsBefore],
2150
+ };
2151
+ }
2152
+
2153
+ export const PREVIOUS_SEAL_USAGE =
2154
+ 'exactly one of `--previous-lock <images.lock.json> --previous-lock-sha256 <hex>` (the images.lock layer of the previous ' +
2155
+ 'edition, taken by the caller) or `--first-seal` (only for a distribution that has never had an edition)';
2156
+
2157
+ /**
2158
+ * The previous seal an images-lock write carries composed artifacts forward from, as an EXPLICIT input of exactly one kind:
2159
+ * - the previous lock with the sha256 of its bytes (taken by the caller from the published edition's images.lock layer);
2160
+ * - `--first-seal` (null: generation 1).
2161
+ * None, both kinds, or half of the pair is refused; the bytes must hash to the given digest, and the lock is parsed
2162
+ * with the kernel's `parseImageLock` and must be this edition's own (`distributionId`).
2163
+ */
2164
+ export async function resolvePreviousSeal({ distributionId, previousLockPath, previousLockSha256, firstSeal = false }) {
2165
+ const given = [previousLockPath, previousLockSha256].filter((value) => value !== undefined);
2166
+ if (firstSeal && given.length > 0) {
2167
+ throw new Error(`more than one previous seal is given (--previous-lock/--previous-lock-sha256, --first-seal); give ${PREVIOUS_SEAL_USAGE}`);
2168
+ }
2169
+ if (firstSeal) {return null;}
2170
+ if (given.length !== 2) {
2171
+ throw new Error(
2172
+ given.length === 0
2173
+ ? `no previous seal is given; give ${PREVIOUS_SEAL_USAGE}`
2174
+ : `--previous-lock and --previous-lock-sha256 go together (one was given); give ${PREVIOUS_SEAL_USAGE}`,
2175
+ );
2176
+ }
2177
+ const bytes = readPreviousLockBytes(previousLockPath, previousLockSha256);
2178
+ return parsePreviousLock(distributionId, parseStrictJson(bytes.toString('utf8'), previousLockPath), '--previous-lock');
2179
+ }
2180
+
2181
+ /**
2182
+ * The bytes of a previous lock file, held to the sha256 (hex) they were given with: the one check of the pair
2183
+ * (`flags` names the pair in the refusal). The parse, with the kernel and against the edition's id, is the caller's.
2184
+ */
2185
+ export function readPreviousLockBytes(path, sha256, flags = { lock: '--previous-lock', sha256: '--previous-lock-sha256' }) {
2186
+ const bytes = readFileSync(path);
2187
+ const actual = createHash('sha256').update(bytes).digest('hex');
2188
+ if (actual !== sha256) {
2189
+ throw new Error(`${flags.lock} ${path} has sha256 ${actual}, not the given ${flags.sha256} ${sha256}`);
2190
+ }
2191
+ return bytes;
2192
+ }
2193
+
2194
+ /** The in-repo lane's previous lock: the committed file at `outPath`, parsed before it is overwritten; null (first seal) when there is none. */
2195
+ export async function readCommittedPreviousLock(distributionId, outPath) {
2196
+ return existsSync(outPath) ? parsePreviousLock(distributionId, readStrictJsonFile(outPath), outPath) : null;
2197
+ }
2198
+
2199
+ async function parsePreviousLock(distributionId, document, label) {
2200
+ const previous = parsePreviousImageLock(await loadKernelDistributionSchemas(), document);
2201
+ if (previous.distributionId !== distributionId) {
2202
+ throw new Error(`${label} is the images lock of "${previous.distributionId}", not of "${distributionId}"`);
2203
+ }
2204
+ return previous;
2205
+ }
2206
+
2207
+ /**
2208
+ * The composed artifacts of a STRUCTURAL view (no source hashing), carried forward from `previousLock` through the kernel's own
2209
+ * `carryForwardComposedArtifacts`, exactly as a reseal does. A structural subject has no content hash or member keys; each
2210
+ * composed id takes them from its newest previous artifact (a structure the previous lock does not cover keeps none, and the
2211
+ * kernel refuses it: the caller reports that as out of date). Source drift is `--check-sources`' job, through `resolveImages`.
2212
+ */
2213
+ export function carryComposedForwardStructurally({ subjects, previousLock, schemas }) {
2214
+ const ComposedProcess = schemas.RuntimeImageSubjectKind.ComposedProcess;
2215
+ const newest = new Map();
2216
+ for (const subject of previousLock?.subjects ?? []) {
2217
+ if (subject.kind === ComposedProcess && (newest.get(subject.id)?.generation ?? 0) <= (subject.generation ?? 0)) {newest.set(subject.id, subject);}
2218
+ }
2219
+ const built = subjects.filter((subject) => subject.kind === ComposedProcess).map((subject) => {
2220
+ const previous = newest.get(subject.id);
2221
+ if (previous === undefined) {return subject;}
2222
+ const keyOf = new Map((previous.members ?? []).map((member) => [member.service, member.key]));
2223
+ return {
2224
+ ...subject,
2225
+ contentHash: previous.contentHash,
2226
+ members: (subject.members ?? []).map((member) => (keyOf.has(member.service) ? { ...member, key: keyOf.get(member.service) } : member)),
2227
+ };
2228
+ });
2229
+ if (built.length === 0 && previousLock === null) {return subjects;}
2230
+ const sealed = schemas.carryForwardComposedArtifacts({ previous: previousLock, built });
2231
+ return [...subjects.filter((subject) => subject.kind !== ComposedProcess), ...sealed].sort(compareSubject);
2232
+ }
2233
+
2234
+ /** One edition's COMPLETE image lock: its rows, parsed and validated as the kernel's image lock. */
2235
+ export async function resolveImages(id, { inputs, lock, onSubjectHashed, recordedImages, previousLock = null } = {}) {
2236
+ const { subjects, thirdPartySubjects, platformServiceBindings, webBiomes, contractAuthority, commits } = await resolveImageRows(id, { inputs, lock, onSubjectHashed, recordedImages, previousLock });
2237
+ const schemas = await loadKernelDistributionSchemas();
2238
+ // A lock whose hashes are a build record's (recordedImages) computed none, so only its structure is checked.
2239
+ const validate = recordedImages === undefined ? validateImmutableImageLock : validateImageLockStructure;
2240
+ const imageLock = validate(
2241
+ schemas.parseImageLock({
2242
+ schemaVersion: imageLockWriteSchemaVersion(schemas, subjects, `distribution "${id}"`),
2243
+ sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
2244
+ distributionId: id,
2245
+ contractAuthority,
2246
+ subjects,
2247
+ platformServiceBindings,
2248
+ webBiomes,
2249
+ }),
2250
+ schemas,
2251
+ );
2252
+ return { imageLock, commits, thirdPartySubjects };
2253
+ }
2254
+
2255
+ /**
2256
+ * Resolve and persist one schema-v3 image lock. Keeping the explicit trusted
2257
+ * write acknowledgement on this exported boundary means the in-process plane
2258
+ * refresh has the same safety contract as the standalone CLI.
2259
+ */
2260
+ export async function writeResolvedImages(
2261
+ id,
2262
+ { trustedWriteV3 = false, log = true, previousLockPath = undefined, previousLockSha256 = undefined, firstSeal = false, previousFromTarget = false } = {},
2263
+ ) {
2264
+ assertCanonicalDistributionId(id);
2265
+ if (!trustedWriteV3) {
2266
+ throw new Error(
2267
+ 'refusing to write a schema-v3 runtime image roster without --trusted-write-v3',
2268
+ );
2269
+ }
2270
+ // The RELEASE lock is published-only: a develop edition resolves through
2271
+ // `develop-edition.mjs` and never reaches this writer.
2272
+ const outPath = distributionOutputPath(
2273
+ editionsRoot(),
2274
+ id,
2275
+ 'images.lock.json',
2276
+ );
2277
+ // The in-repo lane: the previous lock is the committed file at the target path (git is its authority), read before it is
2278
+ // overwritten; with no file there, this is the first seal.
2279
+ const previousLock = previousFromTarget
2280
+ ? await readCommittedPreviousLock(id, outPath)
2281
+ : await resolvePreviousSeal({ distributionId: id, previousLockPath, previousLockSha256, firstSeal });
2282
+ if (previousFromTarget && previousLock === null && log) {console.log(`[dist:images] ${id}: first seal: no previous images.lock.json`);}
2283
+ const { imageLock, commits, thirdPartySubjects } = await resolveImages(id, { previousLock });
2284
+ assertReleaseContent(imageLock, `the images lock for "${id}"`);
2285
+ writeFileSync(outPath, serializeJson(imageLock));
2286
+ if (log) {
2287
+ console.log(
2288
+ `[dist:images] wrote ${outPath} — ${imageLock.subjects.length} actual runtime image subjects, ${imageLock.webBiomes.length} bundled web biomes, ${imageLock.platformServiceBindings.length} platform bindings.`,
2289
+ );
2290
+ for (const [repo, commit] of commits) {
2291
+ console.log(` source ${repo}@${commit}`);
2292
+ }
2293
+ for (const subject of thirdPartySubjects) {
2294
+ console.log(` third-party ${subject.kind}:${subject.id} ${subject.imageRef} ${attestedContentHash(subject)}`);
2295
+ }
2296
+ }
2297
+ return { imageLock, commits };
2298
+ }
2299
+
2300
+ async function main() {
2301
+ const id = process.argv[2];
2302
+ const trustedWriteV3 = process.argv.includes('--trusted-write-v3');
2303
+ if (!id) {
2304
+ throw new Error(
2305
+ `usage: resolve-images.mjs <distribution-id> --trusted-write-v3 (${PREVIOUS_SEAL_USAGE})`,
2306
+ );
2307
+ }
2308
+ // `--editions-root` (and its `=` spelling, plus the value that follows
2309
+ // the bare form) is consumed by `editionsRoot()` in lib.mjs, off
2310
+ // `process.argv`, before this parser ever runs. It has to be accepted here or
2311
+ // the flag that decides WHICH editions root is read is rejected by the one
2312
+ // command that reads it.
2313
+ const rest = process.argv.slice(3);
2314
+ const unknownArgs = [];
2315
+ let previousLockPath;
2316
+ let previousLockSha256;
2317
+ let firstSeal = false;
2318
+ for (let i = 0; i < rest.length; i += 1) {
2319
+ const argument = rest[i];
2320
+ if (argument === '--trusted-write-v3') {continue;}
2321
+ if (argument === '--first-seal') {
2322
+ firstSeal = true;
2323
+ continue;
2324
+ }
2325
+ if (argument === '--previous-lock' || argument === '--previous-lock-sha256') {
2326
+ i += 1;
2327
+ if (rest[i] === undefined) {throw new Error(`${argument} needs a value`);}
2328
+ if (argument === '--previous-lock') {previousLockPath = rest[i];} else {previousLockSha256 = rest[i];}
2329
+ continue;
2330
+ }
2331
+ unknownArgs.push(argument);
2332
+ }
2333
+ if (unknownArgs.length > 0) {
2334
+ throw new Error(`unknown argument "${unknownArgs[0]}"`);
2335
+ }
2336
+ await writeResolvedImages(id, { trustedWriteV3, previousLockPath, previousLockSha256, firstSeal });
2337
+ }
2338
+
2339
+ if (isEntryModule(import.meta.url)) {
2340
+ main().catch((error) => {
2341
+ console.error(`[dist:images] ${error.message}`);
2342
+ process.exit(1);
2343
+ });
2344
+ }