@xemahq/xema 0.26.0 → 0.28.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 (230) hide show
  1. package/README.md +70 -37
  2. package/dist/assets/distribution-assets.manifest.json +1 -2
  3. package/dist/cli.js +2 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/dev.js +1 -0
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/commands/down.d.ts.map +1 -1
  8. package/dist/commands/down.js +7 -1
  9. package/dist/commands/down.js.map +1 -1
  10. package/dist/commands/edition.d.ts +3 -0
  11. package/dist/commands/edition.d.ts.map +1 -0
  12. package/dist/commands/edition.js +46 -0
  13. package/dist/commands/edition.js.map +1 -0
  14. package/dist/commands/init.js +2 -2
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/serve.js +1 -0
  17. package/dist/commands/serve.js.map +1 -1
  18. package/dist/commands/up.js +1 -0
  19. package/dist/commands/up.js.map +1 -1
  20. package/dist/dev/env-defaults.d.ts.map +1 -1
  21. package/dist/dev/env-defaults.js +5 -2
  22. package/dist/dev/env-defaults.js.map +1 -1
  23. package/dist/dev/infra-preflight.d.ts +3 -2
  24. package/dist/dev/infra-preflight.d.ts.map +1 -1
  25. package/dist/dev/infra-preflight.js +18 -8
  26. package/dist/dev/infra-preflight.js.map +1 -1
  27. package/dist/edition/verify-image-evidence.bundle.mjs +54283 -0
  28. package/dist/edition/verify-image-evidence.bundle.provenance.json +2877 -0
  29. package/dist/runtime/config/schema.d.ts +1 -0
  30. package/dist/runtime/config/schema.d.ts.map +1 -1
  31. package/dist/runtime/custody/local-custody.d.ts +12 -0
  32. package/dist/runtime/custody/local-custody.d.ts.map +1 -0
  33. package/dist/runtime/custody/local-custody.js +22 -0
  34. package/dist/runtime/custody/local-custody.js.map +1 -0
  35. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
  36. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
  37. package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
  38. package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
  39. package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
  40. package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
  41. package/dist/runtime/custody/openbao-custody-store.js +55 -0
  42. package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
  43. package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
  44. package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
  45. package/dist/runtime/custody/openbao-http-client.js +154 -0
  46. package/dist/runtime/custody/openbao-http-client.js.map +1 -0
  47. package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
  48. package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
  49. package/dist/runtime/custody/openbao-local-constants.js +48 -0
  50. package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
  51. package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
  52. package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
  53. package/dist/runtime/custody/openbao-local-runner.js +86 -0
  54. package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
  55. package/dist/runtime/edition/run-entry.d.ts +9 -0
  56. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  57. package/dist/runtime/edition/run-entry.js +36 -0
  58. package/dist/runtime/edition/run-entry.js.map +1 -0
  59. package/dist/runtime/edition/verb-map.d.ts +60 -0
  60. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  61. package/dist/runtime/edition/verb-map.js +146 -0
  62. package/dist/runtime/edition/verb-map.js.map +1 -0
  63. package/dist/runtime/env-seeder.d.ts +2 -0
  64. package/dist/runtime/env-seeder.d.ts.map +1 -1
  65. package/dist/runtime/env-seeder.js +18 -3
  66. package/dist/runtime/env-seeder.js.map +1 -1
  67. package/dist/runtime/launch.d.ts +2 -1
  68. package/dist/runtime/launch.d.ts.map +1 -1
  69. package/dist/runtime/launch.js +31 -7
  70. package/dist/runtime/launch.js.map +1 -1
  71. package/dist/runtime/paths.d.ts +1 -2
  72. package/dist/runtime/paths.d.ts.map +1 -1
  73. package/dist/runtime/paths.js +0 -10
  74. package/dist/runtime/paths.js.map +1 -1
  75. package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
  76. package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
  77. package/dist/runtime/runner/chart-local-provenance.js +114 -0
  78. package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
  79. package/dist/runtime/runner/chart-preflight.d.ts +8 -8
  80. package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
  81. package/dist/runtime/runner/chart-preflight.js +30 -69
  82. package/dist/runtime/runner/chart-preflight.js.map +1 -1
  83. package/dist/runtime/runner/chart-pull.d.ts +14 -0
  84. package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
  85. package/dist/runtime/runner/chart-pull.js +96 -0
  86. package/dist/runtime/runner/chart-pull.js.map +1 -0
  87. package/dist/runtime/runner/chart-resolver.d.ts +4 -12
  88. package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
  89. package/dist/runtime/runner/chart-resolver.js +16 -62
  90. package/dist/runtime/runner/chart-resolver.js.map +1 -1
  91. package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
  92. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  93. package/dist/runtime/runner/chart-set-lock.js +55 -99
  94. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  95. package/dist/runtime/runner/helm-cli.d.ts +3 -1
  96. package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
  97. package/dist/runtime/runner/helm-cli.js +9 -7
  98. package/dist/runtime/runner/helm-cli.js.map +1 -1
  99. package/dist/runtime/runner/helm-runner.d.ts +4 -4
  100. package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
  101. package/dist/runtime/runner/helm-runner.js +18 -16
  102. package/dist/runtime/runner/helm-runner.js.map +1 -1
  103. package/dist/runtime/sources/kinds/git.d.ts +1 -2
  104. package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
  105. package/dist/runtime/sources/kinds/git.js +8 -5
  106. package/dist/runtime/sources/kinds/git.js.map +1 -1
  107. package/dist/runtime/sources/manifest-walk.d.ts +3 -1
  108. package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
  109. package/dist/runtime/sources/manifest-walk.js +8 -3
  110. package/dist/runtime/sources/manifest-walk.js.map +1 -1
  111. package/dist/runtime/sources/schema.d.ts +3 -0
  112. package/dist/runtime/sources/schema.d.ts.map +1 -1
  113. package/dist/runtime/sources/schema.js +6 -1
  114. package/dist/runtime/sources/schema.js.map +1 -1
  115. package/dist/runtime/supervisor.d.ts +2 -0
  116. package/dist/runtime/supervisor.d.ts.map +1 -1
  117. package/dist/runtime/supervisor.js +1 -0
  118. package/dist/runtime/supervisor.js.map +1 -1
  119. package/edition/README.md +313 -0
  120. package/edition/assemble-producer-input.mjs +1251 -0
  121. package/edition/authorization-base.mjs +347 -0
  122. package/edition/biome-content.mjs +268 -0
  123. package/edition/build-biome-index.mjs +175 -0
  124. package/edition/build-platform-service-index.mjs +288 -0
  125. package/edition/build-runtime-image-source-index.mjs +821 -0
  126. package/edition/built-verifier.mjs +55 -0
  127. package/edition/catalogue-edition.mjs +49 -0
  128. package/edition/catalogue-fetch.mjs +64 -0
  129. package/edition/catalogue.mjs +135 -0
  130. package/edition/chart-set-source-commit.mjs +92 -0
  131. package/edition/check-committed-image-evidence.mjs +383 -0
  132. package/edition/checkout-indexed-source-repositories.mjs +105 -0
  133. package/edition/comment-blanker.mjs +75 -0
  134. package/edition/connector-source-authority.mjs +60 -0
  135. package/edition/customer-definition.mjs +183 -0
  136. package/edition/customer-flags.mjs +7 -0
  137. package/edition/develop-customer-edition.mjs +99 -0
  138. package/edition/develop-edition-resolve.mjs +65 -0
  139. package/edition/develop-edition.mjs +274 -0
  140. package/edition/develop-freeze.mjs +173 -0
  141. package/edition/develop-image-coordinate.mjs +54 -0
  142. package/edition/develop-seal.mjs +77 -0
  143. package/edition/develop-snapshot-inputs.mjs +104 -0
  144. package/edition/dist-check.mjs +986 -0
  145. package/edition/distribution-evidence-set.mjs +987 -0
  146. package/edition/distribution-paths.mjs +191 -0
  147. package/edition/edition-artifact.mjs +284 -0
  148. package/edition/edition-biome-manifests.mjs +85 -0
  149. package/edition/edition-channel.mjs +6 -0
  150. package/edition/edition-chart-set.mjs +93 -0
  151. package/edition/edition-config.mjs +205 -0
  152. package/edition/edition-constants.mjs +57 -0
  153. package/edition/edition-contract.json +30 -0
  154. package/edition/edition-list.mjs +35 -0
  155. package/edition/edition-locks.mjs +69 -0
  156. package/edition/edition-protocol.mjs +228 -0
  157. package/edition/edition-workspace.mjs +187 -0
  158. package/edition/evidence-checkout.mjs +439 -0
  159. package/edition/evidence-grammars.mjs +16 -0
  160. package/edition/evidence-trust-pin.mjs +304 -0
  161. package/edition/evidence-trust-policy.mjs +294 -0
  162. package/edition/external-source-walk.mjs +463 -0
  163. package/edition/first-party-source-ids.mjs +59 -0
  164. package/edition/fleet-coordinate-finality.mjs +350 -0
  165. package/edition/git-source-auth.mjs +52 -0
  166. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  167. package/edition/image-evidence-replay-state.mjs +242 -0
  168. package/edition/image-evidence.mjs +1233 -0
  169. package/edition/kernel-contract-authority.mjs +443 -0
  170. package/edition/lib.mjs +3021 -0
  171. package/edition/no-follow-file.mjs +94 -0
  172. package/edition/oci-evidence-verbs.mjs +392 -0
  173. package/edition/oci-image-source-evidence.mjs +239 -0
  174. package/edition/oras.mjs +648 -0
  175. package/edition/pin-source.mjs +227 -0
  176. package/edition/plan-image-evidence-refresh.mjs +408 -0
  177. package/edition/platform-lock-readability.mjs +892 -0
  178. package/edition/platform-service-class.mjs +18 -0
  179. package/edition/platform-service-source-authority.mjs +105 -0
  180. package/edition/positive-closure.mjs +162 -0
  181. package/edition/produce-distribution-completeness-evidence.mjs +311 -0
  182. package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
  183. package/edition/produce-image-source-evidence.mjs +765 -0
  184. package/edition/producer-evidence-bundle.mjs +157 -0
  185. package/edition/registry-credentials.mjs +193 -0
  186. package/edition/release-catalogue.mjs +39 -0
  187. package/edition/release-channel-guard.mjs +109 -0
  188. package/edition/release-edition-artifact.mjs +157 -0
  189. package/edition/remedy-command.mjs +89 -0
  190. package/edition/repin-evidence-trust.mjs +101 -0
  191. package/edition/required-resource-provider.mjs +58 -0
  192. package/edition/resolve-chart-set.mjs +415 -0
  193. package/edition/resolve-customer-edition.mjs +288 -0
  194. package/edition/resolve-distribution.mjs +2318 -0
  195. package/edition/resolve-edition.mjs +186 -0
  196. package/edition/resolve-images.mjs +1606 -0
  197. package/edition/seal-image-digests.mjs +855 -0
  198. package/edition/sealed-evidence-accept.mjs +82 -0
  199. package/edition/source-authority.mjs +725 -0
  200. package/edition/source-closure.mjs +116 -0
  201. package/edition/strict-json.mjs +121 -0
  202. package/edition/string-order.mjs +5 -0
  203. package/edition/trust-policy-arming.mjs +120 -0
  204. package/edition/trusted-executable.mjs +166 -0
  205. package/edition/verifier-binding.mjs +45 -0
  206. package/edition/verifier-files.mjs +8 -0
  207. package/edition/verifier-kernel-schemas.mjs +22 -0
  208. package/edition/verifier-provenance.mjs +64 -0
  209. package/edition/verify-image-evidence.mjs +458 -0
  210. package/edition/verify-image-platforms.mjs +495 -0
  211. package/edition/why.mjs +56 -0
  212. package/edition/workspace-sections.mjs +195 -0
  213. package/examples/06-biome-sources.yaml +1 -0
  214. package/package.json +14 -6
  215. package/dist/commands/dist.d.ts +0 -16
  216. package/dist/commands/dist.d.ts.map +0 -1
  217. package/dist/commands/dist.js +0 -182
  218. package/dist/commands/dist.js.map +0 -1
  219. package/dist/runtime/distribution/available-biome.d.ts +0 -10
  220. package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
  221. package/dist/runtime/distribution/available-biome.js +0 -156
  222. package/dist/runtime/distribution/available-biome.js.map +0 -1
  223. package/dist/runtime/distribution/compose.d.ts +0 -23
  224. package/dist/runtime/distribution/compose.d.ts.map +0 -1
  225. package/dist/runtime/distribution/compose.js +0 -48
  226. package/dist/runtime/distribution/compose.js.map +0 -1
  227. package/dist/runtime/runner/chart-trust.d.ts +0 -57
  228. package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
  229. package/dist/runtime/runner/chart-trust.js +0 -270
  230. package/dist/runtime/runner/chart-trust.js.map +0 -1
@@ -0,0 +1,1606 @@
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 { writeFileSync } from 'node:fs';
37
+ import { join, relative, sep } from 'node:path';
38
+
39
+ import {
40
+ assertCanonicalDistributionId,
41
+ distributionOutputPath,
42
+ readBoundDistributionFile,
43
+ } from './distribution-paths.mjs';
44
+ import { editionWorkspaceConfig } from './edition-config.mjs';
45
+ import { biomeIndexPath, editionsRoot, runtimeImageSourceIndexPath, WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, sourcePath, sourcesRoot } from './edition-workspace.mjs';
46
+ import {
47
+ externalSourceCacheDir,
48
+ materializeGitSource,
49
+ } from './external-source-walk.mjs';
50
+ import { assertDeclaredSourceId, firstPartySourceId } from './first-party-source-ids.mjs';
51
+ import {
52
+ CROSS_REPO_BUILD_INPUTS_PATH,
53
+ compareStrings,
54
+ crossRepoInputKey,
55
+ hashImageBuildInputs,
56
+ imageInputSourceHashAlgorithm,
57
+ loadKernelDistributionSchemas,
58
+ parseResolvedDistributionLock,
59
+ readGitJsonAtCommit,
60
+ resolveCrossRepoVendoredInputs,
61
+ serializeJson,
62
+ sha256Hex,
63
+ validateImmutableDistributionLock,
64
+ validateImmutableImageLock,
65
+ } from './lib.mjs';
66
+ import { assertReleaseContent } from './release-channel-guard.mjs';
67
+ import { composeEdition } from './resolve-distribution.mjs';
68
+ import { readStrictJsonFile } from './strict-json.mjs';
69
+
70
+ if (isEntryModule(import.meta.url)) {
71
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
72
+ }
73
+
74
+ const firstPartyImageRegistry = () => editionWorkspaceConfig().imageRegistry;
75
+
76
+ /**
77
+ * WHERE A LOCKED BIOME'S SOURCE TREE IS ON DISK — the one answer this plane
78
+ * needs, and the one thing every read in this file used to assume.
79
+ *
80
+ * Every Git read here was `sourcePath(<repo>)`, which encodes an
81
+ * assumption that held while every biome came from a sibling repository in the
82
+ * aggregator's layout: that a `repo` HANDLE IS A PATH under `sourcesRoot()`. A
83
+ * biome drawn from an EXTERNAL source breaks it — its `repo` is that source's
84
+ * own LOGICAL name (`sources[].name`), deliberately not a `repos/<x>` path,
85
+ * because a hosting repository's name is source-management detail and must not
86
+ * become a biome's provenance. `<sourcesRoot()>/<logical name>` is a directory that
87
+ * does not exist, so the content hash could not be computed at all.
88
+ *
89
+ * The fix is ONE locator rather than a special case per read site: a `repo`
90
+ * handle resolves to a checkout DIRECTORY plus the BASE its `path` is relative
91
+ * to. Both kinds answer the same shape — a first-party repo's paths are
92
+ * `sourcesRoot()`-relative, an external source's are relative to the cache the walk
93
+ * materialised it into — so every call site reads the same two fields and none
94
+ * of them knows which kind it has.
95
+ *
96
+ * WHAT THE HASH THEN COVERS IS UNCHANGED, and that is the point of routing the
97
+ * DIRECTORY rather than teaching the hash about sources. `hashImageBuildInputs`
98
+ * reads the service's own source tree, the transitive workspace-package
99
+ * closure, the exact Dockerfile blob the build resolves to, every file the
100
+ * Dockerfile copies out of the context, the filtered pnpm lock graph, the
101
+ * context-directory residue and any vendored cross-repo tree — all out of the
102
+ * Git objects of whatever repository it is handed, at an exact commit. Handing
103
+ * it an external checkout therefore covers that repository's FULL build input
104
+ * by the same construction it already covers a first-party one, with no second
105
+ * definition of what an image is built from.
106
+ *
107
+ * MATERIALISATION IS AT THE LOCKED COMMIT, never at the declared ref. The lock
108
+ * already recorded the exact commit its manifests were read at; re-resolving a
109
+ * ref here would let a hash be computed over a different revision than the one
110
+ * the edition locked, silently and in the direction nothing detects.
111
+ * `materializeGitSource` short-circuits an exact 40-hex ref instead of sending
112
+ * it to `ls-remote`, so passing the commit AS the ref is the pin rather than a
113
+ * re-resolution.
114
+ */
115
+ export function externalSourceTrees(
116
+ lock,
117
+ { cacheRoot = externalSourceCacheDir(), materialize = materializeGitSource } = {},
118
+ ) {
119
+ const byRepo = new Map();
120
+ for (const biome of lock.biomes ?? []) {
121
+ if (biome.source === undefined) {continue;}
122
+ const name = biome.source.name;
123
+ const incumbent = byRepo.get(name);
124
+ if (incumbent) {
125
+ if (incumbent.commit !== biome.source.commit) {
126
+ throw new Error(
127
+ `distribution "${lock.distributionId}" pins two commits for external source ` +
128
+ `"${name}" (${incumbent.commit} and ${biome.source.commit}) — one source is one ` +
129
+ 'revision, so an image built from it cannot be addressed',
130
+ );
131
+ }
132
+ continue;
133
+ }
134
+ byRepo.set(name, { source: biome.source, commit: biome.source.commit });
135
+ }
136
+ const trees = new Map();
137
+ for (const [name, { source, commit }] of byRepo) {
138
+ const { directory } = materialize({ source: { ...source, ref: commit }, cacheRoot });
139
+ trees.set(name, { dir: directory, base: cacheRoot });
140
+ }
141
+ return trees;
142
+ }
143
+
144
+ /**
145
+ * `repo` handle -> `{ dir, base }`.
146
+ *
147
+ * An unknown handle is FIRST-PARTY, which is the only default that can be
148
+ * correct: the external set is CLOSED by the lock's own `sources[]`, so
149
+ * anything outside it came from this fleet's layout. A handle that is neither
150
+ * resolves to a directory that does not exist and fails at the first Git read,
151
+ * naming the path — a loud failure rather than a silent fall back to an empty
152
+ * tree, which would hash to a coordinate nothing ever published.
153
+ */
154
+ export function sourceTreeLocator(externalTrees) {
155
+ return (repo) =>
156
+ externalTrees.get(repo) ?? { dir: sourcePath(repo), base: sourcesRoot() };
157
+ }
158
+
159
+ /** The first-party-only locator — for callers that hold no lock to read. */
160
+ const FIRST_PARTY_TREES = (repo) => ({ dir: sourcePath(repo), base: sourcesRoot() });
161
+ const imageBuildInputHashByIdentity = new Map();
162
+ const sourceManifestHashByIdentity = new Map();
163
+
164
+ function readJson(path) {
165
+ return readStrictJsonFile(path);
166
+ }
167
+
168
+ /**
169
+ * Memoized image build-input closure hash.
170
+ *
171
+ * Every distribution that ships a service resolves the SAME closure for it, and
172
+ * the closure reads immutable Git objects at an exact commit, so one digest per
173
+ * (service, service path, commit, vendored inputs) is exact — not a cache with
174
+ * an invalidation story.
175
+ */
176
+ function exactImageBuildInputHash(request, algorithm) {
177
+ const identity = serializeJson({ request, algorithm });
178
+ const cached = imageBuildInputHashByIdentity.get(identity);
179
+ if (cached) {return cached;}
180
+ const hash = hashImageBuildInputs(request, algorithm);
181
+ imageBuildInputHashByIdentity.set(identity, hash);
182
+ return hash;
183
+ }
184
+
185
+ function exactSourceManifestHash(
186
+ repoDirectory,
187
+ repoRelativePath,
188
+ sourceCommit,
189
+ ) {
190
+ const identity = serializeJson({
191
+ repoDirectory,
192
+ repoRelativePath,
193
+ sourceCommit,
194
+ });
195
+ const cached = sourceManifestHashByIdentity.get(identity);
196
+ if (cached) {return cached;}
197
+ const hash = sha256Hex(
198
+ serializeJson(
199
+ readGitJsonAtCommit(repoDirectory, repoRelativePath, sourceCommit),
200
+ ),
201
+ );
202
+ sourceManifestHashByIdentity.set(identity, hash);
203
+ return hash;
204
+ }
205
+
206
+ /**
207
+ * Key of one vendored input WITHIN one consumer repository.
208
+ *
209
+ * Both the store and the lookup go through this, so the two sides cannot spell
210
+ * the separator differently — which they did, and every lookup missed.
211
+ */
212
+ function consumerInputKey(consumerRepo, input) {
213
+ return `${consumerRepo}\u0000${crossRepoInputKey(input)}`;
214
+ }
215
+
216
+ /**
217
+ * Compose one image ref: WHERE the artifact lives, and WHAT it is called.
218
+ *
219
+ * `registry` defaults to the vendor namespace because that is where everything
220
+ * this organisation BUILDS is published, and every caller but one wants it. The
221
+ * exception is a biome drawn from an external source, whose coordinate is a
222
+ * DECLARED fact carried on the lock (`biome.source.imageRegistry`) rather than a
223
+ * constant — see the biomes loop in `resolveImageStructure`.
224
+ *
225
+ * `namePrefix` is the second half of that same declared coordinate, and it is
226
+ * what keeps a subject's LOGICAL id separate from its PUBLISHED name. Those two
227
+ * are the same string for every first-party subject — measured 2026-09-15, 67
228
+ * of 67 deploy-manifest entries and 60 of 60 `oci-image` component keys — but
229
+ * that is a property of owning the whole of the first-party registry namespace, not a law. A
230
+ * customer organisation's namespace is flat and carries every product that
231
+ * customer ships, so a bare component name there identifies nothing in
232
+ * particular and the first collision silently overwrites somebody's tags.
233
+ *
234
+ * DEFAULTING TO THE EMPTY STRING IS THE IDENTITY ELEMENT OF CONCATENATION, not
235
+ * a fallback: an unqualified name is what every existing subject already gets,
236
+ * so the absent case reproduces today's behaviour exactly rather than
237
+ * substituting something. Contrast `registry`, whose absence on an external
238
+ * source is REFUSED in `resolveImageStructure` — there the only available
239
+ * default names the wrong ORG, which is a misdirection rather than a preference.
240
+ *
241
+ * Both parameters are deliberately not module-level mutables: two editions are
242
+ * resolved in one process by `dist:check`, and a coordinate held in module scope
243
+ * would leak one edition's customer namespace into the next one's subjects.
244
+ */
245
+ export function imageRefForSubject(
246
+ subjectId,
247
+ registry = firstPartyImageRegistry(),
248
+ namePrefix = '',
249
+ ) {
250
+ return `${registry}/${namePrefix}${subjectId}`;
251
+ }
252
+
253
+ /**
254
+ * The OCI platforms one distribution requires every runtime image to ship.
255
+ *
256
+ * Read from that edition's OWN `xema-distribution.json`
257
+ * and projected onto image-lock platform
258
+ * coordinates by the kernel. This deliberately replaced a global frozen
259
+ * `[{linux, amd64}]` constant: with one constant, every subject of every
260
+ * edition declared amd64 — including `appliance-lean`/`appliance-base`, whose
261
+ * installer (`xema-appliance/os/dgx/install-k3s.sh`, `ansible/site.yml`) refuses
262
+ * any host that is not ARM64. The image plane therefore declared the one
263
+ * architecture the appliance could not run, and nothing in the chain could say
264
+ * so.
265
+ *
266
+ * The kernel owns the vocabulary and the fail-fast: an absent, empty, unknown,
267
+ * duplicated or misordered declaration throws here rather than resolving to a
268
+ * default. There is no default.
269
+ */
270
+ export function resolveDistributionRequiredPlatforms(schemas, id) {
271
+ assertCanonicalDistributionId(id);
272
+ const { requiredPlatforms } = composeEdition(schemas.parseDistribution, id);
273
+ return schemas.resolveRuntimeImagePlatforms(requiredPlatforms);
274
+ }
275
+
276
+ /**
277
+ * The edition's DECLARED `sources[]`,
278
+ *
279
+ * WHY THIS IS A PARAMETER AND NOT A LOCK FIELD. `imageNamePrefix` is producer
280
+ * INPUT: it composes the image NAME and is then spent, because the lock carries
281
+ * the fully-qualified `imageRef`. It cannot travel in the lock to reach the
282
+ * resolver — `DistributionLockSchema` is `.strict()` and learned that key only
283
+ * in `@xemahq/contracts` 0.10.0, so a pinned image resolving `^0.9.x` refuses
284
+ * the whole lock at boot. Persisting it took presenca-dev's three lock
285
+ * consumers into CrashLoopBackOff on 2026-09-17.
286
+ *
287
+ * So the producer reads its OWN declaration, and `requiredPlatforms` one
288
+ * function up is the precedent: a declared fact the pure structure resolver
289
+ * needs arrives as an argument the impure caller composes. That keeps
290
+ * `resolveImageStructure` pure over the committed lock and runtime index, which
291
+ * is what lets `dist-check` grade an images lock offline.
292
+ */
293
+ export function resolveDistributionDeclaredSources(schemas, id) {
294
+ assertCanonicalDistributionId(id);
295
+ const { declaredSources } = composeEdition(schemas.parseDistribution, id);
296
+ return declaredSources;
297
+ }
298
+
299
+ function subjectKey(subject) {
300
+ return `${subject.kind}\0${subject.id}`;
301
+ }
302
+
303
+ function bindingKey(binding) {
304
+ return `${binding.subjectKind}\0${binding.subjectId}`;
305
+ }
306
+
307
+ function compareSubject(left, right) {
308
+ return (
309
+ compareStrings(left.kind, right.kind) || compareStrings(left.id, right.id)
310
+ );
311
+ }
312
+
313
+ function compareOwner(left, right) {
314
+ return compareStrings(serializeJson(left), serializeJson(right));
315
+ }
316
+
317
+ function addSubject(subjects, candidate) {
318
+ const key = subjectKey(candidate);
319
+ const existing = subjects.get(key);
320
+ if (!existing) {
321
+ subjects.set(key, {
322
+ ...candidate,
323
+ owners: [...candidate.owners],
324
+ });
325
+ return;
326
+ }
327
+ const withoutOwners = ({ owners: _owners, ...value }) => value;
328
+ if (
329
+ serializeJson(withoutOwners(existing)) !==
330
+ serializeJson(withoutOwners(candidate))
331
+ ) {
332
+ throw new Error(
333
+ `runtime image subject "${candidate.kind}:${candidate.id}" has conflicting source identities`,
334
+ );
335
+ }
336
+ const ownerKeys = new Set(existing.owners.map(serializeJson));
337
+ for (const owner of candidate.owners) {
338
+ if (!ownerKeys.has(serializeJson(owner))) {existing.owners.push(owner);}
339
+ }
340
+ }
341
+
342
+ /**
343
+ * The refusal for a SELECTED platform service whose declared artifact
344
+ * dependency no selected biome produces.
345
+ *
346
+ * The edge is DECLARED, never inferred from a name: a platform service's
347
+ * `runtimeBinding` (platform-service-source-authority.mjs, projected into
348
+ * `runtime-image-source-index.json#platformServiceBindings`) says which image
349
+ * subject carries it — `opencode-image-prepuller` → `runtime-component:
350
+ * opencode-worker`. When that subject is not supplemental it is produced by a
351
+ * biome, and the biome that produces it is the one whose OWN manifest declares
352
+ * it (`services` / `runtimeComponents` in the biome index). So excluding the
353
+ * provider biome while keeping the dependent service is refused here, naming
354
+ * both ends; excluding both leaves no edge and resolves.
355
+ */
356
+ function missingArtifactDependencyMessage(lock, serviceName, binding, biomeIndex) {
357
+ const field =
358
+ binding.subjectKind === 'runtime-component' ? 'runtimeComponents' : 'services';
359
+ const providers = Array.isArray(biomeIndex)
360
+ ? [
361
+ ...new Set(
362
+ biomeIndex
363
+ .filter((entry) => (entry[field] ?? []).includes(binding.subjectId))
364
+ .map((entry) => entry.id),
365
+ ),
366
+ ].sort(compareStrings)
367
+ : [];
368
+ const providerText =
369
+ providers.length > 0
370
+ ? `, which is declared by biome ${providers.map((id) => `"${id}"`).join(' / ')}` +
371
+ ' — and this edition does not select it'
372
+ : ', and no biome this edition selects declares it';
373
+ return (
374
+ `distribution "${lock.distributionId}" ships platform service "${serviceName}", ` +
375
+ `whose declared runtime binding requires image subject ` +
376
+ `"${binding.subjectKind}:${binding.subjectId}"${providerText}. ` +
377
+ `Either keep the providing biome, or exclude "${serviceName}" as well — ` +
378
+ 'refusing rather than resolving an edition whose service has no image.'
379
+ );
380
+ }
381
+
382
+ /**
383
+ * Offline roster derivation. It reads only committed locks/indexes and performs
384
+ * no registry, GitHub, DigitalOcean, or source-worktree access.
385
+ *
386
+ * `requiredPlatforms` is supplied by the caller (see
387
+ * {@link resolveDistributionRequiredPlatforms}) rather than read here, so this
388
+ * function stays a pure projection of its inputs — and so a caller that forgets
389
+ * the distribution's declaration gets a hard error instead of a default.
390
+ *
391
+ * THE ROSTER IS THE ARTIFACT CLOSURE OF THE LOGICAL CLOSURE, and nothing else.
392
+ * `runtime-image-source-index.json` is the FLEET's catalogue — every platform
393
+ * service binding, every supplemental subject — and an edition selects from it.
394
+ * A binding for a platform service this edition does not ship is simply not
395
+ * part of this edition's artifact graph and is dropped; it is never a reason to
396
+ * refuse. What DOES refuse is a SELECTED service whose declared artifact
397
+ * dependency is absent from the closure (see `missingArtifactDependencyMessage`).
398
+ *
399
+ * `options.biomeIndex` is optional and used ONLY to name, in a refusal, which
400
+ * biome declares the missing artifact. It never changes a resolved roster.
401
+ */
402
+ export function resolveImageStructure(
403
+ lock,
404
+ runtimeSourceIndex,
405
+ enums,
406
+ requiredPlatforms,
407
+ declaredSources,
408
+ { biomeIndex } = {},
409
+ ) {
410
+ if (!Array.isArray(requiredPlatforms) || requiredPlatforms.length === 0) {
411
+ throw new Error(
412
+ `distribution "${lock.distributionId}" resolved no required image platforms — ` +
413
+ `every distribution declares requiredPlatforms in its xema-distribution.json ` +
414
+ `and there is no default; a silent default is the bug this replaced`,
415
+ );
416
+ }
417
+ const subjects = new Map();
418
+ const webBiomes = [];
419
+ const platformServiceBindings = [];
420
+ const repositoryPins = new Map(
421
+ runtimeSourceIndex.repositoryPins.map((repository) => [
422
+ repository.repo,
423
+ repository,
424
+ ]),
425
+ );
426
+ const requireRepositoryPin = (repo) => {
427
+ const pin = repositoryPins.get(repo);
428
+ if (!pin) {
429
+ throw new Error(`runtime image source "${repo}" has no repository pin`);
430
+ }
431
+ return pin;
432
+ };
433
+
434
+ for (const biome of lock.biomes) {
435
+ // WHERE THIS BIOME'S IMAGES LIVE, and WHICH COMMIT THEY ARE BUILT FROM.
436
+ // Both answers move together when a biome is drawn from an external source,
437
+ // because both are facts about that source rather than about this fleet.
438
+ //
439
+ // ── THE REGISTRY ──
440
+ //
441
+ // A first-party biome is published to the vendor namespace. A biome drawn
442
+ // from an EXTERNAL source is not, and its coordinate is DECLARED — on the
443
+ // `sources[]` entry as `imageRegistry`, carried into the lock verbatim by
444
+ // `external-source-walk.mjs` and read HERE. This used to refuse
445
+ // unconditionally, including when the coordinate WAS declared, because
446
+ // reading it was not implemented; that was an honest partial and it is now
447
+ // implemented.
448
+ //
449
+ // THE REFUSAL SURVIVES, NARROWED TO THE CASE IT WAS ALWAYS ABOUT: an
450
+ // external source that declares NO registry. The kernel legislates that on
451
+ // the field itself — *"a plane that needs one and finds it absent must
452
+ // REFUSE rather than substitute the first-party registry, which would
453
+ // publish a customer's build into the vendor org."* Absent must never
454
+ // become a fallback to the first-party registry. That substitution is the entire
455
+ // defect; narrowing the refusal must not soften it into a default.
456
+ //
457
+ // ── THE COMMIT ──
458
+ //
459
+ // A first-party biome's commit comes from `runtime-image-source-index.json`
460
+ // via `requireRepositoryPin`. An external biome has no entry there and
461
+ // never will: its `repo` is the source's own LOGICAL name as declared in
462
+ // `sources[]`, not a `repos/<x>` path, so the pin lookup would fail with "has no
463
+ // repository pin" — a true sentence about the wrong plane. Its pin is the
464
+ // commit the WALK resolved and the lock recorded, which is the same fact
465
+ // held by the authority that actually resolved it.
466
+ //
467
+ // ── PLACEMENT ──
468
+ //
469
+ // Both are resolved BEFORE `requireRepositoryPin` and before the
470
+ // `target === 'web'` branch, so services, runtime components and web biomes
471
+ // are covered by one statement rather than leaving the web branch's
472
+ // `continue` as a hole.
473
+ //
474
+ // KNOWN NARROWING, stated rather than discovered: a web biome composes no
475
+ // image ref at all, so an external source shipping ONLY web biomes needs no
476
+ // registry and is nonetheless refused here. That is deliberately left
477
+ // fail-closed — it has no subject today (both externally-sourced biomes in
478
+ // this plane declare `target: "server"`), and loosening a fence for a case
479
+ // that does not exist is how a fence stops meaning anything.
480
+ let imageRegistry = firstPartyImageRegistry();
481
+ // The identity element, not a fallback — see `imageRefForSubject`. A
482
+ // first-party subject is published under its own id, which is exactly what
483
+ // an empty prefix composes.
484
+ let imageNamePrefix = '';
485
+ let sourceCommit;
486
+ if (biome.source === undefined) {
487
+ sourceCommit = requireRepositoryPin(biome.repo).sourceCommit;
488
+ } else {
489
+ if (biome.source.imageRegistry === undefined) {
490
+ throw new Error(
491
+ `distribution "${lock.distributionId}" biome "${biome.id}" was drawn from external ` +
492
+ `source "${biome.source.name}" (${biome.source.url}@${biome.source.commit}), which ` +
493
+ 'declares no imageRegistry, so this plane cannot address its images. Composing ' +
494
+ `them under "${firstPartyImageRegistry()}" would address a build this organisation does not ` +
495
+ 'produce, inside the vendor namespace. Declare `imageRegistry` on that sources[] ' +
496
+ 'entry — refusing rather than substituting the first-party registry.',
497
+ );
498
+ }
499
+ imageRegistry = biome.source.imageRegistry;
500
+ // THE PREFIX COMES FROM THE DECLARATION, NEVER FROM THE LOCK.
501
+ //
502
+ // This read was `biome.source.imageNamePrefix`, which required the lock to
503
+ // carry the key — and a lock carrying it is refused outright by any
504
+ // consumer on `@xemahq/contracts` `^0.9.x`, whose DistributionLockSchema
505
+ // is `.strict()` and learned the key only in 0.10.0.
506
+ //
507
+ // Removing it from the lock WITHOUT moving this read is what composed
508
+ // `<customer-registry>/pai-core-api` instead of `<customer-registry>/presenca-pai-core-api`:
509
+ // the `?? ''` swallowed the absence, so an unqualified name in a shared
510
+ // organisation namespace resolved silently. That is worse than a 404 —
511
+ // the first collision overwrites another product's tags.
512
+ //
513
+ // A MISSING DECLARED ENTRY IS THEREFORE REFUSED, never defaulted. An entry
514
+ // that declares NO prefix is a different fact and is honoured: the empty
515
+ // prefix is the identity element, composing a subject under its bare id.
516
+ if (!Array.isArray(declaredSources)) {
517
+ throw new Error(
518
+ `distribution "${lock.distributionId}" biome "${biome.id}" was drawn from ` +
519
+ `external source "${biome.source.name}", but this resolve was given no ` +
520
+ 'declared sources[] to read its imageNamePrefix from. That prefix is ' +
521
+ 'producer input and is deliberately not carried in the lock; pass ' +
522
+ '`resolveDistributionDeclaredSources(schemas, id)` — refusing rather ' +
523
+ 'than composing an unprefixed name.',
524
+ );
525
+ }
526
+ const declaredSource = declaredSources.find(
527
+ (candidate) => candidate.name === biome.source.name,
528
+ );
529
+ if (declaredSource === undefined) {
530
+ throw new Error(
531
+ `distribution "${lock.distributionId}" biome "${biome.id}" was drawn from ` +
532
+ `external source "${biome.source.name}", which this distribution's ` +
533
+ `composed sources[] does not declare (declared: ${
534
+ declaredSources.map((candidate) => candidate.name).join(', ') ||
535
+ 'none'
536
+ }). The image name cannot be composed without its declaration — ` +
537
+ 'refusing rather than publishing under an unprefixed name in a shared ' +
538
+ "organisation namespace, where the first collision overwrites another " +
539
+ "product's tags.",
540
+ );
541
+ }
542
+ imageNamePrefix = declaredSource.imageNamePrefix ?? '';
543
+ sourceCommit = biome.source.commit;
544
+ }
545
+ if (biome.target === 'web') {
546
+ webBiomes.push({
547
+ biomeId: biome.id,
548
+ repo: biome.repo,
549
+ path: biome.path,
550
+ sourceCommit,
551
+ sourceManifestHash: biome.sourceManifestHash,
552
+ });
553
+ continue;
554
+ }
555
+ // A worker that runs a sibling service's image builds none of its own: the
556
+ // service's subject is the one sealed coordinate, and the deploy plane
557
+ // reaches it through `workerImages[worker].imageOf`. Minting a subject for
558
+ // the worker would name an image nothing builds.
559
+ const sharedImageWorkers = new Set(Object.keys(biome.workerImages ?? {}));
560
+ for (const service of biome.services ?? []) {
561
+ if (sharedImageWorkers.has(service)) {
562
+ continue;
563
+ }
564
+ addSubject(subjects, {
565
+ kind: enums.RuntimeImageSubjectKind.Service,
566
+ id: service,
567
+ imageRef: imageRefForSubject(service, imageRegistry, imageNamePrefix),
568
+ repo: biome.repo,
569
+ path: biome.path,
570
+ sourceCommit,
571
+ sourceManifestHash: biome.sourceManifestHash,
572
+ requiredPlatforms,
573
+ owners: [
574
+ {
575
+ kind: enums.RuntimeImageOwnerKind.Biome,
576
+ biomeId: biome.id,
577
+ },
578
+ ],
579
+ });
580
+ }
581
+ // Runtime components are FULL delivery subjects — built, content-addressed,
582
+ // signed, mirrored and air-gapped exactly like a service image. They differ
583
+ // only in that nothing schedules them, which the deploy plane reads off the
584
+ // subject KIND. Their source path resolves through the same
585
+ // `ships.apis[].path` SSOT, because a runtime component IS a `ships.apis[]`
586
+ // entry — it just carries `workload: "runtime-component"`.
587
+ for (const component of biome.runtimeComponents ?? []) {
588
+ addSubject(subjects, {
589
+ kind: enums.RuntimeImageSubjectKind.RuntimeComponent,
590
+ id: component,
591
+ imageRef: imageRefForSubject(component, imageRegistry, imageNamePrefix),
592
+ repo: biome.repo,
593
+ path: biome.path,
594
+ sourceCommit,
595
+ sourceManifestHash: biome.sourceManifestHash,
596
+ requiredPlatforms,
597
+ owners: [
598
+ {
599
+ kind: enums.RuntimeImageOwnerKind.Biome,
600
+ biomeId: biome.id,
601
+ },
602
+ ],
603
+ });
604
+ }
605
+ }
606
+
607
+ const runtimeBindings = new Map(
608
+ runtimeSourceIndex.platformServiceBindings.map((binding) => [
609
+ binding.platformService,
610
+ binding,
611
+ ]),
612
+ );
613
+ const supplementalSubjects = new Map(
614
+ runtimeSourceIndex.supplementalSubjects.map((subject) => [
615
+ subjectKey(subject),
616
+ subject,
617
+ ]),
618
+ );
619
+
620
+ for (const service of lock.platformServices ?? []) {
621
+ let binding;
622
+ // The discriminator is the DECLARED runtime binding, never "does the lock
623
+ // carry a source". Every platform service carries an exact source identity
624
+ // now (`LockedPlatformService.source` is required at lock schemaVersion 2),
625
+ // so `if (service.source)` became unconditionally true — and it would have
626
+ // minted a repository-ROOT `platform` subject for `xema-kernel-server`,
627
+ // `docs-api`, `xema-host-web` and `opencode-image-prepuller`, hashing the
628
+ // wrong closure for three of them and inventing an image that has never
629
+ // existed for the fourth. A binding names the subject that really carries
630
+ // the service; its absence means the service IS its own repository image.
631
+ const runtimeBinding = runtimeBindings.get(service.name);
632
+ if (!runtimeBinding) {
633
+ if (!service.source) {
634
+ throw new Error(
635
+ `platform service "${service.name}" has no declared runtime image binding ` +
636
+ 'and no exact source identity, so no image subject can be resolved for it',
637
+ );
638
+ }
639
+ const repository = requireRepositoryPin(service.source.repo);
640
+ if (service.source.sourceCommit !== repository.sourceCommit) {
641
+ throw new Error(
642
+ `platform service "${service.name}" source commit does not match its repository pin`,
643
+ );
644
+ }
645
+ binding = {
646
+ platformService: service.name,
647
+ subjectKind: enums.RuntimeImageSubjectKind.Platform,
648
+ subjectId: service.source.imageKey,
649
+ };
650
+ addSubject(subjects, {
651
+ kind: binding.subjectKind,
652
+ id: binding.subjectId,
653
+ imageRef: imageRefForSubject(binding.subjectId),
654
+ repo: service.source.repo,
655
+ path: service.source.repo,
656
+ sourceCommit: repository.sourceCommit,
657
+ sourceManifestHash: service.source.sourceManifestHash,
658
+ sourceManifestPath: service.source.path,
659
+ requiredPlatforms,
660
+ owners: [
661
+ {
662
+ kind: enums.RuntimeImageOwnerKind.PlatformService,
663
+ platformService: service.name,
664
+ },
665
+ ],
666
+ });
667
+ } else {
668
+ binding = runtimeBinding;
669
+ const key = bindingKey(binding);
670
+ const supplemental = supplementalSubjects.get(key);
671
+ if (supplemental) {
672
+ const repository = requireRepositoryPin(supplemental.repo);
673
+ if (supplemental.sourceCommit !== repository.sourceCommit) {
674
+ throw new Error(
675
+ `runtime subject "${supplemental.kind}:${supplemental.id}" source commit does not match its repository pin`,
676
+ );
677
+ }
678
+ addSubject(subjects, {
679
+ kind: supplemental.kind,
680
+ id: supplemental.id,
681
+ imageRef: supplemental.imageRef,
682
+ repo: supplemental.repo,
683
+ path: supplemental.path,
684
+ sourceCommit: repository.sourceCommit,
685
+ sourceManifestHash: supplemental.sourceManifestHash,
686
+ sourceManifestPath: supplemental.sourceManifestPath,
687
+ requiredPlatforms,
688
+ owners: [
689
+ {
690
+ kind: enums.RuntimeImageOwnerKind.PlatformService,
691
+ platformService: service.name,
692
+ },
693
+ ],
694
+ });
695
+ } else {
696
+ const target = subjects.get(key);
697
+ if (!target) {
698
+ throw new Error(
699
+ missingArtifactDependencyMessage(lock, service.name, binding, biomeIndex),
700
+ );
701
+ }
702
+ addSubject(subjects, {
703
+ ...target,
704
+ owners: [
705
+ {
706
+ kind: enums.RuntimeImageOwnerKind.PlatformService,
707
+ platformService: service.name,
708
+ },
709
+ ],
710
+ });
711
+ }
712
+ }
713
+ platformServiceBindings.push(binding);
714
+ }
715
+
716
+ // A binding for a service this edition does not ship is outside its artifact
717
+ // graph. It was REFUSED here ("runtime image source index contains unselected
718
+ // platform service"), which made every image-bearing platform service
719
+ // unexcludable: `billing-api` could be dropped only because it happens to be
720
+ // independently sourced and carries no binding, while `docs-api`,
721
+ // `xema-host-web` and `opencode-image-prepuller` could not. The index is the
722
+ // fleet catalogue, not this edition's roster; the roster is derived from the
723
+ // lock's `platformServices`, above. A binding naming a service the COMPOSED
724
+ // catalogue does not know at all is still a declaration defect, and is
725
+ // refused by `build-runtime-image-source-index.mjs --check`, which owns the
726
+ // index — not by every edition that happens to omit the service.
727
+
728
+ const orderedSubjects = [...subjects.values()]
729
+ .map((subject) => ({
730
+ ...subject,
731
+ owners: subject.owners.sort(compareOwner),
732
+ }))
733
+ .sort(compareSubject);
734
+ webBiomes.sort((left, right) => compareStrings(left.biomeId, right.biomeId));
735
+ platformServiceBindings.sort((left, right) =>
736
+ compareStrings(left.platformService, right.platformService),
737
+ );
738
+ return {
739
+ subjects: orderedSubjects,
740
+ webBiomes,
741
+ platformServiceBindings,
742
+ };
743
+ }
744
+
745
+ /**
746
+ * THE COMPOSED-PROCESS IMAGE OF ONE BUILD — where a producing repository
747
+ * declares it, and how an edition addresses it.
748
+ *
749
+ * The kernel (`RuntimeImageSubjectKind.ComposedProcess`) fixes WHAT the subject
750
+ * is: one repository at one commit, carrying every platform-scheduled service
751
+ * of that build and `@xemahq/xema-service-nest`'s `xema-composed-process`
752
+ * entrypoint, owned by the biomes whose services it carries. Neither the kernel
753
+ * nor the SDK fixes WHERE a repository builds it, so this plane does, once:
754
+ *
755
+ * - the image directory is `infra/composed-process/` at the repository root,
756
+ * holding its own `Dockerfile` and an `image-build-inputs.json` whose
757
+ * `workspacePackages` lists every member service's workspace package
758
+ * directory — the Dockerfile-only image shape `hashImageBuildInputs`
759
+ * already reads (`opencode-worker` is the other one). Its tag is therefore
760
+ * an ordinary `xema-image-input-sha256-v2` key, computed by the one
761
+ * implementation every producer uses, with `SERVICE` = the subject id and
762
+ * `SERVICE_PATH` = this directory;
763
+ * - the DECLARATION is that `image-build-inputs.json` existing at the pinned
764
+ * commit. A repository that declares none has no composed image, and an
765
+ * edition carries no composed subject for it — which the deploy plane
766
+ * reports as "the producer builds no composed-process image at that
767
+ * commit". That is an honest absence, not a skipped subject: a DECLARED
768
+ * image that has not been built is still emitted, carries no
769
+ * `imageDigest`, and is undeliverable until it is sealed;
770
+ * - the subject id is `<declared source id>-composed-process`. The source
771
+ * id is DECLARED, never derived from where the repository is hosted: an
772
+ * external source's is the edition's `sources[].name` (which the walk
773
+ * also stamps as the biome's `repo`), a first-party repository's is
774
+ * declared in `first-party-source-ids.mjs`. A rename or host move changes
775
+ * `repositoryUrl` — provenance — and no subject id, `imageRef` or
776
+ * `contentHash` (the id is the `SERVICE` input of the tag). It cannot be
777
+ * a constant: two first-party repositories publish into one registry
778
+ * namespace, and the image lock refuses two subjects sharing an
779
+ * `imageRef`. The producing build publishes under this same name;
780
+ * - `imageRef` is composed exactly as the repository's service subjects are
781
+ * (`imageRefForSubject`, with the same registry and declared prefix).
782
+ */
783
+ export const COMPOSED_PROCESS_IMAGE_PATH = 'infra/composed-process';
784
+ export const COMPOSED_PROCESS_DECLARATION_FILE = 'image-build-inputs.json';
785
+ const COMPOSED_PROCESS_ID_SUFFIX = '-composed-process';
786
+
787
+ export function composedProcessSubjectId(sourceId) {
788
+ return `${assertDeclaredSourceId(sourceId, 'composed-process subject')}${COMPOSED_PROCESS_ID_SUFFIX}`;
789
+ }
790
+
791
+ /**
792
+ * The DECLARED source id of every repository this edition builds a service
793
+ * image from, keyed by the subject's `repo` handle — the edition's own
794
+ * `sources[].name` for a source-walked biome, the first-party declaration
795
+ * otherwise. Never read off a URL.
796
+ */
797
+ export function editionSourceIds(lock) {
798
+ const ids = new Map();
799
+ for (const biome of lock.biomes ?? []) {
800
+ if (biome.source === undefined) {continue;}
801
+ if (biome.repo !== biome.source.name) {
802
+ throw new Error(
803
+ `lock biome "${biome.id}" is drawn from source "${biome.source.name}" but its repo ` +
804
+ `handle is "${biome.repo}" — a source-walked biome's repo IS its declared source id`,
805
+ );
806
+ }
807
+ ids.set(biome.repo, assertDeclaredSourceId(biome.source.name, `lock biome "${biome.id}" source`));
808
+ }
809
+ return { get: (repo) => ids.get(repo) ?? firstPartySourceId(repo) };
810
+ }
811
+
812
+ /**
813
+ * Read each repository's composed-process DECLARATION at the commit the
814
+ * edition builds its services from. Only repositories that contribute a
815
+ * service subject are asked: a composed image carries services, so a
816
+ * repository shipping none has nothing to compose.
817
+ */
818
+ export function readComposedProcessDeclarations(
819
+ structure,
820
+ enums,
821
+ sourceIds,
822
+ locate = FIRST_PARTY_TREES,
823
+ ) {
824
+ const commits = new Map();
825
+ for (const subject of structure.subjects) {
826
+ if (subject.kind !== enums.RuntimeImageSubjectKind.Service) {continue;}
827
+ if (!subject.owners.some((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome)) {continue;}
828
+ commits.set(subject.repo, subject.sourceCommit);
829
+ }
830
+ const declarations = [];
831
+ for (const [repo, sourceCommit] of [...commits].sort(([a], [b]) => compareStrings(a, b))) {
832
+ const { dir, base } = locate(repo);
833
+ const declarationPath = `${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
834
+ // ABSENCE IS ONLY AN ANSWER ABOUT A COMMIT THAT WAS READ. A repository
835
+ // missing from this layout, or one lacking the pinned commit, would
836
+ // otherwise read as "declares nothing", and `dist-check` would grade a lock
837
+ // that dropped the composed subject as up to date.
838
+ try {
839
+ execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}^{commit}`], {
840
+ stdio: ['ignore', 'ignore', 'ignore'],
841
+ });
842
+ } catch {
843
+ throw new Error(
844
+ `runtime image source "${repo}" pins commit "${sourceCommit}", which is absent from ` +
845
+ `${dir} — its composed-process declaration cannot be read; fetch the repository ` +
846
+ 'before resolving',
847
+ );
848
+ }
849
+ try {
850
+ execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}:${declarationPath}`], {
851
+ stdio: ['ignore', 'ignore', 'ignore'],
852
+ });
853
+ } catch {
854
+ continue; // declares no composed-process image at this commit
855
+ }
856
+ const sourceId = sourceIds.get(repo);
857
+ const repoRelative = relative(base, dir).split(sep).join('/');
858
+ declarations.push({
859
+ repo,
860
+ sourceId,
861
+ sourceCommit,
862
+ path: `${repoRelative}/${COMPOSED_PROCESS_IMAGE_PATH}`,
863
+ sourceManifestPath: `${repoRelative}/${declarationPath}`,
864
+ sourceManifestHash: exactSourceManifestHash(dir, declarationPath, sourceCommit),
865
+ });
866
+ }
867
+ return declarations;
868
+ }
869
+
870
+ /**
871
+ * Add ONE `composed-process` subject per declaring repository to an edition's
872
+ * structure. Pure over its inputs, so the structural check and the source
873
+ * resolve derive the same subject from the same declarations.
874
+ *
875
+ * Owners are the biomes whose service subjects come from that repository in
876
+ * this edition — the members the deploy plane may place in a process with it.
877
+ */
878
+ export function withComposedProcessSubjects(structure, declarations, enums, requiredPlatforms) {
879
+ if (declarations.length === 0) {return structure;}
880
+ const subjects = new Map(structure.subjects.map((subject) => [subjectKey(subject), subject]));
881
+ for (const declaration of declarations) {
882
+ const services = structure.subjects.filter(
883
+ (subject) =>
884
+ subject.kind === enums.RuntimeImageSubjectKind.Service && subject.repo === declaration.repo,
885
+ );
886
+ // One owner per biome: a biome shipping several services owns the
887
+ // composed image once (the kernel refuses a repeated owner identity).
888
+ const biomeOwners = [
889
+ ...new Map(
890
+ services
891
+ .flatMap((subject) => subject.owners)
892
+ .filter((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome)
893
+ .map((owner) => [owner.biomeId, owner]),
894
+ ).values(),
895
+ ];
896
+ if (biomeOwners.length === 0) {
897
+ throw new Error(
898
+ `composed-process declaration for "${declaration.repo}" names no biome service ` +
899
+ 'subject in this edition — a composed image carries services, so it has no owner',
900
+ );
901
+ }
902
+ for (const subject of services) {
903
+ if (subject.sourceCommit !== declaration.sourceCommit) {
904
+ throw new Error(
905
+ `runtime subject "${subject.kind}:${subject.id}" is resolved at ${subject.sourceCommit}, ` +
906
+ `but "${declaration.repo}"'s composed-process image is declared at ` +
907
+ `${declaration.sourceCommit} — one composed image is ONE build`,
908
+ );
909
+ }
910
+ }
911
+ // The registry and declared prefix the repository's services are published
912
+ // under: `imageRefForSubject` is `<registry>/<prefix><id>`, so stripping
913
+ // the service id recovers it exactly. Two services disagreeing would mean
914
+ // one build published under two coordinates, which nothing can address.
915
+ const namespaces = new Set(
916
+ services.map((subject) => {
917
+ if (!subject.imageRef.endsWith(subject.id)) {
918
+ throw new Error(
919
+ `runtime subject "${subject.kind}:${subject.id}" imageRef "${subject.imageRef}" ` +
920
+ 'does not end with its id, so its namespace cannot be recovered',
921
+ );
922
+ }
923
+ return subject.imageRef.slice(0, -subject.id.length);
924
+ }),
925
+ );
926
+ if (namespaces.size !== 1) {
927
+ throw new Error(
928
+ `"${declaration.repo}"'s services publish under ${namespaces.size} namespaces ` +
929
+ `(${[...namespaces].join(', ')}) — a composed image of one build has one`,
930
+ );
931
+ }
932
+ const id = composedProcessSubjectId(declaration.sourceId);
933
+ addSubject(subjects, {
934
+ kind: enums.RuntimeImageSubjectKind.ComposedProcess,
935
+ id,
936
+ imageRef: `${[...namespaces][0]}${id}`,
937
+ repo: declaration.repo,
938
+ path: declaration.path,
939
+ sourceCommit: declaration.sourceCommit,
940
+ sourceManifestHash: declaration.sourceManifestHash,
941
+ sourceManifestPath: declaration.sourceManifestPath,
942
+ requiredPlatforms,
943
+ owners: biomeOwners.map((owner) => ({ kind: owner.kind, biomeId: owner.biomeId })),
944
+ });
945
+ }
946
+ return {
947
+ ...structure,
948
+ subjects: [...subjects.values()]
949
+ .map((subject) => ({ ...subject, owners: [...subject.owners].sort(compareOwner) }))
950
+ .sort(compareSubject),
951
+ };
952
+ }
953
+
954
+ /**
955
+ * The edition structure INCLUDING its composed-process subjects — the one
956
+ * entry point the source resolve and the structural check share, so neither
957
+ * can carry a subject the other does not. A repository that declares no
958
+ * composed image at its pinned commit contributes no subject.
959
+ */
960
+ export function resolveComposedProcessStructure(
961
+ lock,
962
+ structure,
963
+ enums,
964
+ requiredPlatforms,
965
+ { locate } = {},
966
+ ) {
967
+ return withComposedProcessSubjects(
968
+ structure,
969
+ readComposedProcessDeclarations(
970
+ structure,
971
+ enums,
972
+ editionSourceIds(lock),
973
+ // Materialised only when something will be read through it.
974
+ locate ?? sourceTreeLocator(externalSourceTrees(lock)),
975
+ ),
976
+ enums,
977
+ requiredPlatforms,
978
+ );
979
+ }
980
+
981
+ /**
982
+ * The exact commit every image source repository is resolved at.
983
+ *
984
+ * The PIN is the authority, not the local checkout. Requiring each submodule's
985
+ * `HEAD` to equal its pin made regeneration impossible in any working tree that
986
+ * had moved on — which is every working tree — and bought nothing: the hashes
987
+ * read immutable Git objects at the pinned commit, so what is checked out
988
+ * cannot change a digest. What DOES have to hold is that the pinned commit is
989
+ * present in the local object database, which is asserted here.
990
+ */
991
+ function sourceCommitMap(structure, locate = FIRST_PARTY_TREES) {
992
+ const commitByRepo = new Map();
993
+ for (const source of [...structure.subjects, ...structure.webBiomes]) {
994
+ if (!source.sourceCommit) {continue;}
995
+ const incumbent = commitByRepo.get(source.repo);
996
+ if (incumbent && incumbent !== source.sourceCommit) {
997
+ throw new Error(
998
+ `runtime image roster pins multiple commits for "${source.repo}"`,
999
+ );
1000
+ }
1001
+ commitByRepo.set(source.repo, source.sourceCommit);
1002
+ }
1003
+ const repositories = [
1004
+ ...new Set([
1005
+ ...structure.subjects.map((subject) => subject.repo),
1006
+ ...structure.webBiomes.map((biome) => biome.repo),
1007
+ ]),
1008
+ ].sort(compareStrings);
1009
+ for (const repo of repositories) {
1010
+ const commit = commitByRepo.get(repo);
1011
+ if (!commit) {
1012
+ throw new Error(`runtime image source "${repo}" has no pinned commit`);
1013
+ }
1014
+ try {
1015
+ execFileSync(
1016
+ 'git',
1017
+ ['-C', locate(repo).dir, 'cat-file', '-e', `${commit}^{commit}`],
1018
+ { stdio: ['ignore', 'ignore', 'pipe'] },
1019
+ );
1020
+ } catch {
1021
+ throw new Error(
1022
+ `runtime image source "${repo}" pins commit "${commit}", which is absent ` +
1023
+ `from the local object database — fetch the repository before resolving`,
1024
+ );
1025
+ }
1026
+ }
1027
+ return commitByRepo;
1028
+ }
1029
+
1030
+ /**
1031
+ * The SERVICE directory an image is built from, repository-relative.
1032
+ *
1033
+ * A biome subject's `path` is the BIOME directory, and a biome can ship several
1034
+ * services — `agent-runtime` ships four. Hashing the biome directory would give
1035
+ * all four the same closure and would move all four when any one of them
1036
+ * changes, so the service directory is read from the manifest SSOT
1037
+ * (`xema.ships.apis[].path`) rather than synthesized. Non-biome subjects
1038
+ * (`sourceManifestPath` present) already carry their own service directory.
1039
+ *
1040
+ * Exported for `assemble-producer-input.mjs`, which must derive the SAME
1041
+ * service directory to hash the SAME build-input closure the lock names.
1042
+ */
1043
+ export function subjectServicePath(subject, sourceCommit, locate = FIRST_PARTY_TREES) {
1044
+ const { dir: repoDirectory, base } = locate(subject.repo);
1045
+ const toRepoRelative = (absolute) =>
1046
+ relative(repoDirectory, absolute).split(sep).join('/') || '.';
1047
+ if (subject.sourceManifestPath) {
1048
+ return toRepoRelative(join(base, subject.path));
1049
+ }
1050
+ const biomePath = toRepoRelative(join(base, subject.path));
1051
+ const manifestPath =
1052
+ biomePath === '.' ? 'xema-biome.json' : `${biomePath}/xema-biome.json`;
1053
+ const manifest = readGitJsonAtCommit(
1054
+ repoDirectory,
1055
+ manifestPath,
1056
+ sourceCommit,
1057
+ );
1058
+ const xema = manifest?.xema;
1059
+ const apis = xema?.ships?.apis;
1060
+ const legacyApis = Array.isArray(apis) ? apis : [];
1061
+ // component-contract-v5: a component's source directory is declared by its
1062
+ // own `artifact.path`.
1063
+ //
1064
+ // Both `service` AND `worker` kinds qualify. Restricting this to `service`
1065
+ // was correct only while a runtime component was spelled in the legacy
1066
+ // `ships.apis[]` block; v5 dropped that field, so `opencode-worker` —
1067
+ // `kind: "worker"`, `scheduler: "runtime"` — fell through to the legacy
1068
+ // branch and failed with "ships no apis" even though its manifest declares
1069
+ // everything needed. What identifies the subject is
1070
+ // `protocol.serviceName`, not the component kind.
1071
+ const components = Array.isArray(xema?.components) ? xema.components : [];
1072
+ const v4ServiceComponents = components.filter(
1073
+ (component) =>
1074
+ component !== null &&
1075
+ typeof component === 'object' &&
1076
+ (component.kind === 'service' || component.kind === 'worker'),
1077
+ );
1078
+ if (legacyApis.length > 0 && v4ServiceComponents.length > 0) {
1079
+ throw new Error(
1080
+ `${subject.repo}@${sourceCommit}:${manifestPath} declares services in BOTH ` +
1081
+ `xema.ships.apis[] (legacy) and xema.components[] kind "service" ` +
1082
+ `(component-contract-v4) — a biome must use exactly one shape`,
1083
+ );
1084
+ }
1085
+ let servicePath;
1086
+ if (v4ServiceComponents.length > 0) {
1087
+ // A SERVICE is identified by `protocol.serviceName`; a WORKER has no
1088
+ // service protocol and is identified by its component `key`. Matching only
1089
+ // on `serviceName` silently found nothing for every worker subject and
1090
+ // reported it as an undeclared service directory.
1091
+ const component = v4ServiceComponents.find(
1092
+ (entry) =>
1093
+ entry?.protocol?.serviceName === subject.id || entry?.key === subject.id,
1094
+ );
1095
+ if (
1096
+ !component ||
1097
+ typeof component?.artifact?.path !== 'string' ||
1098
+ component.artifact.path.length === 0
1099
+ ) {
1100
+ throw new Error(
1101
+ `${subject.repo}@${sourceCommit}:${manifestPath} declares no xema.components[] ` +
1102
+ `kind "service"/"worker" entry identified as "${subject.id}" (by ` +
1103
+ `protocol.serviceName or component key) with an ` +
1104
+ `artifact.path — the service source directory is the manifest's to declare ` +
1105
+ `and must never be synthesized`,
1106
+ );
1107
+ }
1108
+ servicePath = component.artifact.path;
1109
+ } else {
1110
+ if (legacyApis.length === 0) {
1111
+ throw new Error(
1112
+ `${subject.repo}@${sourceCommit}:${manifestPath} ships no apis, but runtime ` +
1113
+ `subject "${subject.kind}:${subject.id}" claims to be one of them`,
1114
+ );
1115
+ }
1116
+ const api = legacyApis.find((entry) => entry?.name === subject.id);
1117
+ if (!api || typeof api.path !== 'string' || api.path.length === 0) {
1118
+ throw new Error(
1119
+ `${subject.repo}@${sourceCommit}:${manifestPath} declares no ships.apis[] entry ` +
1120
+ `named "${subject.id}" with a "path" — the service source directory is the ` +
1121
+ `manifest's to declare and must never be synthesized`,
1122
+ );
1123
+ }
1124
+ servicePath = api.path;
1125
+ }
1126
+ return toRepoRelative(join(base, subject.path, servicePath));
1127
+ }
1128
+
1129
+ function assertSourceManifestParity(structure, commitByRepo, locate = FIRST_PARTY_TREES) {
1130
+ for (const source of structure.subjects) {
1131
+ const { dir: repoDirectory, base } = locate(source.repo);
1132
+ const sourceCommit = commitByRepo.get(source.repo);
1133
+ const manifestPath = source.sourceManifestPath
1134
+ ? relative(repoDirectory, join(base, source.sourceManifestPath))
1135
+ .split(sep)
1136
+ .join('/')
1137
+ : relative(repoDirectory, join(base, source.path, 'xema-biome.json'))
1138
+ .split(sep)
1139
+ .join('/');
1140
+ const actual = exactSourceManifestHash(
1141
+ repoDirectory,
1142
+ manifestPath,
1143
+ sourceCommit,
1144
+ );
1145
+ if (actual !== source.sourceManifestHash) {
1146
+ throw new Error(
1147
+ `runtime subject "${source.kind}:${source.id}" sourceManifestHash does not match ${source.repo}@${sourceCommit}:${manifestPath}`,
1148
+ );
1149
+ }
1150
+ }
1151
+ for (const biome of structure.webBiomes) {
1152
+ const { dir: repoDirectory, base } = locate(biome.repo);
1153
+ const sourceCommit = commitByRepo.get(biome.repo);
1154
+ const manifestPath = relative(
1155
+ repoDirectory,
1156
+ join(base, biome.path, 'xema-biome.json'),
1157
+ )
1158
+ .split(sep)
1159
+ .join('/');
1160
+ const actual = exactSourceManifestHash(
1161
+ repoDirectory,
1162
+ manifestPath,
1163
+ sourceCommit,
1164
+ );
1165
+ if (actual !== biome.sourceManifestHash) {
1166
+ throw new Error(
1167
+ `web biome "${biome.biomeId}" sourceManifestHash does not match ${biome.repo}@${sourceCommit}:${manifestPath}`,
1168
+ );
1169
+ }
1170
+ }
1171
+ }
1172
+
1173
+ /**
1174
+ * AN EXTERNAL SOURCE THAT VENDORS CROSS-REPOSITORY CONTENT IS REFUSED, LOUDLY.
1175
+ *
1176
+ * `runtime-image-source-index.json` declares the vendoring this fleet's own
1177
+ * repositories do, and an external source has no entry there — correctly, since
1178
+ * the index is the vendor's view of the vendor's repositories. Reading that
1179
+ * absence as "this repository vendors nothing" is the silent half: a Dockerfile
1180
+ * that copies a tree in from elsewhere would have those bytes drop out of the
1181
+ * closure, and the hash would name an image whose content it does not describe.
1182
+ *
1183
+ * So the ABSENCE is checked rather than assumed. The producing repository's own
1184
+ * committed record — the same file `vendor-cross-repo-inputs.mjs` reads to emit
1185
+ * the `--vendored` arguments CI hands the tag computation — is read at the
1186
+ * locked commit. Absent means genuinely nothing to vendor, which is the honest
1187
+ * empty. PRESENT means this plane cannot pin the other side (it has no checkout
1188
+ * of a repository the customer vendors from, and no authority to resolve one),
1189
+ * and an undeclared input must fail rather than silently drop out.
1190
+ */
1191
+ function assertExternalSourcesDeclareNoVendoring(lock, locate, commitByRepo) {
1192
+ const external = new Set(
1193
+ (lock.biomes ?? [])
1194
+ .filter((biome) => biome.source !== undefined)
1195
+ .map((biome) => biome.repo),
1196
+ );
1197
+ for (const repo of [...external].sort(compareStrings)) {
1198
+ const { dir } = locate(repo);
1199
+ const commit = commitByRepo.get(repo);
1200
+ if (!commit) {continue;}
1201
+ // Existence is probed with `cat-file -e` rather than by catching the read:
1202
+ // a failed read writes `fatal: path ... does not exist` to stderr, and a
1203
+ // `fatal:` printed during a successful run is a false signal a reader has
1204
+ // to spend time discounting.
1205
+ let present;
1206
+ try {
1207
+ execFileSync('git', ['-C', dir, 'cat-file', '-e', `${commit}:${CROSS_REPO_BUILD_INPUTS_PATH}`], {
1208
+ stdio: ['ignore', 'ignore', 'ignore'],
1209
+ });
1210
+ present = true;
1211
+ } catch {
1212
+ present = false;
1213
+ }
1214
+ if (!present) {continue;} // absent — nothing vendored, which is the honest empty
1215
+ const record = readGitJsonAtCommit(dir, CROSS_REPO_BUILD_INPUTS_PATH, commit);
1216
+ const inputs = Array.isArray(record?.inputs) ? record.inputs : [];
1217
+ if (inputs.length === 0) {continue;}
1218
+ throw new Error(
1219
+ `external source "${repo}" declares ${inputs.length} cross-repository build ` +
1220
+ `input(s) in ${CROSS_REPO_BUILD_INPUTS_PATH} at ${commit}. This plane cannot pin ` +
1221
+ 'the other side of them — it holds no checkout of the repositories they vendor ' +
1222
+ 'from — so the image content hash would omit bytes the build bakes in and name a ' +
1223
+ 'coordinate it does not describe. Refusing rather than hashing an incomplete ' +
1224
+ 'closure.',
1225
+ );
1226
+ }
1227
+ }
1228
+
1229
+ /**
1230
+ * The vendored cross-repository build inputs of THIS roster's images, keyed
1231
+ * `${consumerRepo}\0${consumerService ?? ''}`.
1232
+ *
1233
+ * EVERY COMMIT HERE COMES FROM A REPOSITORY PIN, NEVER FROM ROSTER MEMBERSHIP.
1234
+ *
1235
+ * The consumer commit — the commit whose committed
1236
+ * `tooling/release/cross-repo-build-inputs.json` names the vendored revisions —
1237
+ * is the consumer repository's `repositoryPins` entry. It used to be read out
1238
+ * of the edition's resolved commit map, which knows a repository only if some
1239
+ * SELECTED biome happens to come from it. That made an image's identity depend
1240
+ * on unrelated membership, and made a trimmed edition unaddressable: an edition
1241
+ * shipping no `repos/xema-community` biome refused with "cross-repo build inputs
1242
+ * are declared for repos/xema-community, but this distribution resolves no
1243
+ * commit for it" — although it shipped no xema-community image at all. For a
1244
+ * first-party subject the two are the same commit by construction (a subject's
1245
+ * `sourceCommit` IS its repository pin), so every edition that resolved before
1246
+ * resolves to byte-identical hashes now.
1247
+ *
1248
+ * Only consumers the roster actually BUILDS FROM are resolved: a consumer with
1249
+ * no selected subject contributes no input to any hash here, so reading its
1250
+ * record would be work that grades nothing. The vendored revision itself is the
1251
+ * consumer's own record at its pin (see the reasoning above the call site): the
1252
+ * pin fixes the record, and the record fixes the bytes. A consumer, or a
1253
+ * repository it vendors from, with NO repository pin is refused by name — the
1254
+ * image cannot be addressed without one.
1255
+ */
1256
+ export function resolveCrossRepoInputsForRoster({
1257
+ runtimeSourceIndex,
1258
+ subjects,
1259
+ resolveVendored = resolveCrossRepoVendoredInputs,
1260
+ repoRoot = sourcesRoot(),
1261
+ // The subject kind that builds EVERY input of its repository (the composed
1262
+ // process image carries every service of its build). Undefined: none does.
1263
+ composedProcessKind = undefined,
1264
+ }) {
1265
+ const pins = new Map(
1266
+ runtimeSourceIndex.repositoryPins.map((pin) => [pin.repo, pin]),
1267
+ );
1268
+ const requirePin = (repo, role) => {
1269
+ const pin = pins.get(repo);
1270
+ if (!pin) {
1271
+ throw new Error(
1272
+ `cross-repo build inputs need a repository pin for "${repo}" (${role}), ` +
1273
+ 'and runtime-image-source-index.json declares none — the vendored content ' +
1274
+ 'cannot be pinned, so the image cannot be addressed',
1275
+ );
1276
+ }
1277
+ return pin;
1278
+ };
1279
+ const builds = (input) =>
1280
+ subjects.some(
1281
+ (subject) =>
1282
+ subject.repo === input.consumerRepo &&
1283
+ (input.consumerService === undefined || input.consumerService === null ||
1284
+ input.consumerService === subject.id ||
1285
+ (composedProcessKind !== undefined && subject.kind === composedProcessKind)),
1286
+ );
1287
+ const needed = runtimeSourceIndex.crossRepoBuildInputs.filter(builds);
1288
+ const vendoredCommitByInputKey = new Map();
1289
+ for (const consumerRepo of [
1290
+ ...new Set(needed.map((input) => input.consumerRepo)),
1291
+ ].sort(compareStrings)) {
1292
+ const consumerCommit = requirePin(consumerRepo, 'the consuming repository').sourceCommit;
1293
+ for (const subject of subjects) {
1294
+ if (subject.repo === consumerRepo && subject.sourceCommit !== undefined &&
1295
+ subject.sourceCommit !== consumerCommit) {
1296
+ throw new Error(
1297
+ `runtime subject "${subject.kind}:${subject.id}" is resolved at ` +
1298
+ `${subject.sourceCommit}, but "${consumerRepo}" is pinned at ${consumerCommit}`,
1299
+ );
1300
+ }
1301
+ }
1302
+ // The record is read against EVERY input the index declares for this
1303
+ // consumer, not the roster-filtered subset: set equality with the
1304
+ // consumer's record is the fence against a drifted declaration, and it must
1305
+ // not weaken because an edition ships fewer services.
1306
+ for (const resolved of resolveVendored({
1307
+ indexInputs: runtimeSourceIndex.crossRepoBuildInputs,
1308
+ consumerRepo,
1309
+ consumerRepoDir: join(repoRoot, consumerRepo),
1310
+ consumerCommit,
1311
+ })) {
1312
+ vendoredCommitByInputKey.set(
1313
+ consumerInputKey(consumerRepo, {
1314
+ sourceRepo: relative(repoRoot, resolved.sourceRepoDir),
1315
+ sourcePath: resolved.sourcePath,
1316
+ destinationPath: resolved.destinationPath,
1317
+ }),
1318
+ resolved.sourceCommit,
1319
+ );
1320
+ }
1321
+ }
1322
+ const crossRepoInputsByConsumer = new Map();
1323
+ for (const input of needed) {
1324
+ requirePin(input.sourceRepo, `vendored into "${input.consumerRepo}"`);
1325
+ // A null `consumerService` means the vendoring step runs for EVERY service
1326
+ // built from that repository; it is keyed on the repository alone.
1327
+ const key = `${input.consumerRepo}\u0000${input.consumerService ?? ''}`;
1328
+ const sourceCommit = vendoredCommitByInputKey.get(
1329
+ consumerInputKey(input.consumerRepo, input),
1330
+ );
1331
+ if (!sourceCommit) {
1332
+ throw new Error(
1333
+ `cross-repo build input for "${input.consumerService}" vendoring ` +
1334
+ `"${input.sourceRepo}:${input.sourcePath}" has no commit in ` +
1335
+ `"${input.consumerRepo}"'s own cross-repo build-input record — its ` +
1336
+ `vendored content cannot be pinned, so the image cannot be addressed`,
1337
+ );
1338
+ }
1339
+ const entries = crossRepoInputsByConsumer.get(key) ?? [];
1340
+ entries.push({
1341
+ destinationPath: input.destinationPath,
1342
+ sourceRepoDir: join(repoRoot, input.sourceRepo),
1343
+ sourcePath: input.sourcePath,
1344
+ sourceCommit,
1345
+ });
1346
+ crossRepoInputsByConsumer.set(key, entries);
1347
+ }
1348
+ return crossRepoInputsByConsumer;
1349
+ }
1350
+
1351
+ /** The release's inputs: the COMMITTED locks and indexes, read from disk. */
1352
+ function releaseImageInputs(schemas, id) {
1353
+ return {
1354
+ lock: validateImmutableDistributionLock(
1355
+ readBoundDistributionFile({
1356
+ distributionsRoot: editionsRoot(),
1357
+ distributionId: id,
1358
+ fileName: 'distribution.lock.json',
1359
+ // A pure READ of the committed lock: the tolerant reader, never the writer.
1360
+ parse: (raw) => parseResolvedDistributionLock(schemas, raw),
1361
+ }).value,
1362
+ ),
1363
+ runtimeSourceIndex: schemas.parseRuntimeImageSourceIndex(readJson(runtimeImageSourceIndexPath())),
1364
+ biomeIndex: readJson(biomeIndexPath()),
1365
+ requiredPlatforms: resolveDistributionRequiredPlatforms(schemas, id),
1366
+ declaredSources: resolveDistributionDeclaredSources(schemas, id),
1367
+ };
1368
+ }
1369
+
1370
+ /**
1371
+ * A develop snapshot's inputs: the SNAPSHOT distribution lock and the
1372
+ * snapshot indexes and chain (`develop-snapshot-inputs.mjs`), all already
1373
+ * resolved at the snapshot's pins. The runtime index carries the snapshot pins
1374
+ * already and is not re-pinned here.
1375
+ */
1376
+ function snapshotImageInputs(schemas, { inputs, lock }) {
1377
+ return {
1378
+ lock: validateImmutableDistributionLock(lock),
1379
+ runtimeSourceIndex: inputs.runtimeImageSourceIndex,
1380
+ biomeIndex: inputs.biomeIndex,
1381
+ requiredPlatforms: schemas.resolveRuntimeImagePlatforms(inputs.composed.requiredPlatforms),
1382
+ declaredSources: inputs.composed.declaredSources,
1383
+ };
1384
+ }
1385
+
1386
+ export async function resolveImages(id, { inputs, lock } = {}) {
1387
+ assertCanonicalDistributionId(id);
1388
+ if ((inputs === undefined) !== (lock === undefined)) {
1389
+ throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
1390
+ }
1391
+ const schemas = await loadKernelDistributionSchemas();
1392
+ const {
1393
+ lock: distributionLock,
1394
+ runtimeSourceIndex,
1395
+ biomeIndex,
1396
+ requiredPlatforms,
1397
+ declaredSources,
1398
+ } = inputs === undefined ? releaseImageInputs(schemas, id) : snapshotImageInputs(schemas, { inputs, lock });
1399
+ // Materialise every EXTERNAL source at the commit the lock pins, and resolve
1400
+ // every `repo` handle through one locator from here down.
1401
+ const locate = sourceTreeLocator(externalSourceTrees(distributionLock));
1402
+ const structure = resolveComposedProcessStructure(
1403
+ distributionLock,
1404
+ resolveImageStructure(
1405
+ distributionLock,
1406
+ runtimeSourceIndex,
1407
+ schemas,
1408
+ requiredPlatforms,
1409
+ declaredSources,
1410
+ { biomeIndex },
1411
+ ),
1412
+ schemas,
1413
+ requiredPlatforms,
1414
+ { locate },
1415
+ );
1416
+ const commitsBefore = sourceCommitMap(structure, locate);
1417
+ assertSourceManifestParity(structure, commitsBefore, locate);
1418
+ assertExternalSourcesDeclareNoVendoring(distributionLock, locate, commitsBefore);
1419
+
1420
+ const buildStepDirectoriesByRepo = new Map();
1421
+ for (const directory of runtimeSourceIndex.buildStepContextDirectories) {
1422
+ const entries = buildStepDirectoriesByRepo.get(directory.repo) ?? [];
1423
+ entries.push(directory.path);
1424
+ buildStepDirectoriesByRepo.set(directory.repo, entries);
1425
+ }
1426
+ // Vendored content is pinned from the CONSUMER REPOSITORY'S OWN committed
1427
+ // record of what it vendored (`resolveCrossRepoVendoredInputs`) — never from
1428
+ // the index's `repositoryPins` mirror of the vendored axis, and never from this
1429
+ // edition's resolved commit map. The record is read at the CONSUMER's
1430
+ // repository pin, which is what makes the result roster-independent (see
1431
+ // `resolveCrossRepoInputsForRoster`).
1432
+ //
1433
+ // An image's identity is edition-INDEPENDENT, and that still holds: the record
1434
+ // is a property of the consumer's COMMIT, not of any edition, so every edition
1435
+ // shipping a subject derives the same `contentHash` for it.
1436
+ // The first-party `biome-host-api` image is built once, by the producing
1437
+ // repository's CI, which has no notion of an edition and vendors every
1438
+ // declared cross-repo input. Reading the vendored commit from `commitsBefore`
1439
+ // made that edition-independent quantity depend on the edition, which failed
1440
+ // in both directions at once:
1441
+ // • it is UNCOMPUTABLE for an edition that does not itself resolve the
1442
+ // source repo — `appliance-lean` and `minimal` do not ship the xema-store
1443
+ // biome, so re-resolving either one threw here and their locks could not
1444
+ // be regenerated at all, even though the image they name is the very one
1445
+ // the other six editions resolve fine;
1446
+ // • had it not thrown it would have been WORSE, silently minting a second
1447
+ // content hash for one image — a coordinate no CI ever pushes, which is
1448
+ // the exact "the tag the lock names does not exist" failure the delivery
1449
+ // chain exists to prevent (.claude/rules/image-release-coordinate.md).
1450
+ //
1451
+ // Reading `index.repositoryPins` instead was the SAME failure by a different
1452
+ // route, and it shipped. The mirror is generated from the index and committed
1453
+ // separately, so lagging it by a pin is its normal resting state — and the
1454
+ // vendored commit is framed into the image-input hash's component KEY, so a
1455
+ // one-commit lag mints a `contentHash` for byte-identical bytes that no CI run
1456
+ // has ever pushed. Measured 2026-09-07 on `origin/main`: five of sixty-eight
1457
+ // coordinates 404'd for exactly this, while the tags CI DID push were served
1458
+ // on both platforms. The gate that was supposed to catch it,
1459
+ // `tooling/release/check-image-tag-parity.mjs` in the AGGREGATOR (a path that
1460
+ // resolves in no producing repository, though two of them cite it as if it
1461
+ // did), read `index.repositoryPins` too — so both of its halves consumed the
1462
+ // one input that had diverged, and it reported 474/474 verified while the
1463
+ // delivery plane was undeliverable. It now reads the consumer's record, as
1464
+ // this does.
1465
+ const crossRepoInputsByConsumer = resolveCrossRepoInputsForRoster({
1466
+ runtimeSourceIndex,
1467
+ subjects: structure.subjects,
1468
+ composedProcessKind: schemas.RuntimeImageSubjectKind.ComposedProcess,
1469
+ });
1470
+ // A composed image carries EVERY service of its build, so it vendors every
1471
+ // cross-repository input its repository declares — repository-wide and
1472
+ // per-service alike. The set is a property of the repository at its commit,
1473
+ // never of the edition, so every edition derives the same tag for it.
1474
+ const crossRepoInputsFor = (subject) =>
1475
+ subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess
1476
+ ? [...crossRepoInputsByConsumer]
1477
+ .filter(([key]) => key.startsWith(`${subject.repo}\u0000`))
1478
+ .sort(([a], [b]) => compareStrings(a, b))
1479
+ .flatMap(([, entries]) => entries)
1480
+ : [
1481
+ ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000`) ?? []),
1482
+ ...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000${subject.id}`) ?? []),
1483
+ ];
1484
+ const subjects = structure.subjects.map((subject) => {
1485
+ const sourceCommit = commitsBefore.get(subject.repo);
1486
+ const repoDirectory = locate(subject.repo).dir;
1487
+ const servicePath = subjectServicePath(subject, sourceCommit, locate);
1488
+ const { sourceManifestPath: ignoredSourceManifestPath, ...lockedSubject } =
1489
+ subject;
1490
+ void ignoredSourceManifestPath;
1491
+ return {
1492
+ ...lockedSubject,
1493
+ sourceCommit,
1494
+ contentHash: exactImageBuildInputHash(
1495
+ {
1496
+ repoDir: repoDirectory,
1497
+ service: subject.id,
1498
+ servicePath,
1499
+ commit: sourceCommit,
1500
+ crossRepoInputs: crossRepoInputsFor(subject),
1501
+ buildStepDirectories:
1502
+ buildStepDirectoriesByRepo.get(subject.repo) ?? [],
1503
+ },
1504
+ imageInputSourceHashAlgorithm(),
1505
+ ),
1506
+ };
1507
+ });
1508
+ const webBiomes = structure.webBiomes.map((biome) => ({
1509
+ ...biome,
1510
+ sourceCommit: commitsBefore.get(biome.repo),
1511
+ }));
1512
+ const imageLock = validateImmutableImageLock(
1513
+ schemas.parseImageLock({
1514
+ schemaVersion: 3,
1515
+ sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
1516
+ distributionId: id,
1517
+ contractAuthority: runtimeSourceIndex.contractAuthority,
1518
+ subjects,
1519
+ platformServiceBindings: structure.platformServiceBindings,
1520
+ webBiomes,
1521
+ }),
1522
+ schemas,
1523
+ );
1524
+ // The SAME locator the hash used. The default locator resolves every repo
1525
+ // handle to a first-party tree under the aggregator root, so for an edition
1526
+ // with an external source it looked for `<sourcesRoot()>/<source name>`, found no
1527
+ // repository there, and refused with "absent from the local object database"
1528
+ // — after every hash had already been computed correctly through `locate`.
1529
+ // Measured 2026-09-16 resolving a customer edition against a customer root.
1530
+ const commitsAfter = sourceCommitMap(structure, locate);
1531
+ if (serializeJson([...commitsBefore]) !== serializeJson([...commitsAfter])) {
1532
+ throw new Error(
1533
+ 'refusing image-lock write: a source repository commit changed while hashing',
1534
+ );
1535
+ }
1536
+ return { imageLock, commits: [...commitsBefore] };
1537
+ }
1538
+
1539
+ /**
1540
+ * Resolve and persist one schema-v3 image lock. Keeping the explicit trusted
1541
+ * write acknowledgement on this exported boundary means the in-process plane
1542
+ * refresh has the same safety contract as the standalone CLI.
1543
+ */
1544
+ export async function writeResolvedImages(
1545
+ id,
1546
+ { trustedWriteV3 = false, log = true } = {},
1547
+ ) {
1548
+ assertCanonicalDistributionId(id);
1549
+ if (!trustedWriteV3) {
1550
+ throw new Error(
1551
+ 'refusing to write a schema-v3 runtime image roster without --trusted-write-v3',
1552
+ );
1553
+ }
1554
+ // The RELEASE lock is published-only: a develop edition resolves through
1555
+ // `develop-edition.mjs` and never reaches this writer.
1556
+ const { imageLock, commits } = await resolveImages(id);
1557
+ const outPath = distributionOutputPath(
1558
+ editionsRoot(),
1559
+ id,
1560
+ 'images.lock.json',
1561
+ );
1562
+ assertReleaseContent(imageLock, `the images lock for "${id}"`);
1563
+ writeFileSync(outPath, serializeJson(imageLock));
1564
+ if (log) {
1565
+ console.log(
1566
+ `[dist:images] wrote ${outPath} — ${imageLock.subjects.length} actual runtime image subjects, ${imageLock.webBiomes.length} bundled web biomes, ${imageLock.platformServiceBindings.length} platform bindings.`,
1567
+ );
1568
+ for (const [repo, commit] of commits) {
1569
+ console.log(` source ${repo}@${commit}`);
1570
+ }
1571
+ }
1572
+ return { imageLock, commits };
1573
+ }
1574
+
1575
+ async function main() {
1576
+ const id = process.argv[2];
1577
+ const trustedWriteV3 = process.argv.includes('--trusted-write-v3');
1578
+ if (!id) {
1579
+ throw new Error(
1580
+ 'usage: resolve-images.mjs <distribution-id> --trusted-write-v3',
1581
+ );
1582
+ }
1583
+ // `--editions-root` (and its `=` spelling, plus the value that follows
1584
+ // the bare form) is consumed by `editionsRoot()` in lib.mjs, off
1585
+ // `process.argv`, before this parser ever runs. It has to be accepted here or
1586
+ // the flag that decides WHICH editions root is read is rejected by the one
1587
+ // command that reads it.
1588
+ const rest = process.argv.slice(3);
1589
+ const unknownArgs = [];
1590
+ for (let i = 0; i < rest.length; i += 1) {
1591
+ const argument = rest[i];
1592
+ if (argument === '--trusted-write-v3') {continue;}
1593
+ unknownArgs.push(argument);
1594
+ }
1595
+ if (unknownArgs.length > 0) {
1596
+ throw new Error(`unknown argument "${unknownArgs[0]}"`);
1597
+ }
1598
+ await writeResolvedImages(id, { trustedWriteV3 });
1599
+ }
1600
+
1601
+ if (isEntryModule(import.meta.url)) {
1602
+ main().catch((error) => {
1603
+ console.error(`[dist:images] ${error.message}`);
1604
+ process.exit(1);
1605
+ });
1606
+ }