@xemahq/xema 0.53.0 → 0.55.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 (70) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +2 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/auth.d.ts +9 -0
  6. package/dist/commands/auth.d.ts.map +1 -0
  7. package/dist/commands/auth.js +66 -0
  8. package/dist/commands/auth.js.map +1 -0
  9. package/dist/commands/edition.d.ts.map +1 -1
  10. package/dist/commands/edition.js +1 -0
  11. package/dist/commands/edition.js.map +1 -1
  12. package/dist/edition/verify-image-evidence.bundle.mjs +2062 -1025
  13. package/dist/edition/verify-image-evidence.bundle.provenance.json +595 -505
  14. package/dist/runtime/edition/verb-map.d.ts +2 -1
  15. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  16. package/dist/runtime/edition/verb-map.js +2 -0
  17. package/dist/runtime/edition/verb-map.js.map +1 -1
  18. package/dist/runtime/lock-loader.d.ts +2 -1
  19. package/dist/runtime/lock-loader.d.ts.map +1 -1
  20. package/dist/runtime/lock-loader.js +15 -5
  21. package/dist/runtime/lock-loader.js.map +1 -1
  22. package/edition/README.md +61 -4
  23. package/edition/adapter-bundled-versions.mjs +2 -1
  24. package/edition/authorization-base.mjs +9 -8
  25. package/edition/biome-content.mjs +23 -29
  26. package/edition/candidate-edition.mjs +692 -0
  27. package/edition/check-committed-image-evidence.mjs +44 -15
  28. package/edition/checkout-indexed-source-repositories.mjs +6 -8
  29. package/edition/collected-refusals.mjs +59 -0
  30. package/edition/contract-authority.mjs +122 -0
  31. package/edition/cosign-certificate-constraints.mjs +18 -8
  32. package/edition/customer-edition-verbs.mjs +43 -10
  33. package/edition/develop-chart-set.mjs +7 -6
  34. package/edition/develop-current-resolution.mjs +29 -0
  35. package/edition/develop-edition-invariants.mjs +2 -1
  36. package/edition/develop-edition-resolve.mjs +110 -31
  37. package/edition/develop-edition.mjs +73 -11
  38. package/edition/develop-engine-image.mjs +95 -0
  39. package/edition/develop-freeze.mjs +24 -1
  40. package/edition/develop-graph.mjs +5 -3
  41. package/edition/develop-image-probe.mjs +8 -5
  42. package/edition/develop-image-record.mjs +194 -0
  43. package/edition/develop-record-ancestry.mjs +23 -5
  44. package/edition/develop-resolve-report.mjs +159 -0
  45. package/edition/develop-select-records.mjs +29 -21
  46. package/edition/develop-selection-line.mjs +30 -0
  47. package/edition/develop-selection-pins.mjs +30 -0
  48. package/edition/develop-snapshot-inputs.mjs +4 -3
  49. package/edition/develop-verify-image-record.mjs +23 -4
  50. package/edition/dist-check.mjs +45 -9
  51. package/edition/distribution-evidence-set.mjs +14 -9
  52. package/edition/edition-artifact.mjs +17 -7
  53. package/edition/edition-image-seal.mjs +2 -1
  54. package/edition/edition-protocol.mjs +8 -19
  55. package/edition/image-evidence.mjs +6 -2
  56. package/edition/image-lock-service-rows.mjs +4 -4
  57. package/edition/image-subject-identity.mjs +24 -0
  58. package/edition/images-from-record.mjs +298 -0
  59. package/edition/lib.mjs +25 -45
  60. package/edition/locked-bundle-signer.mjs +15 -3
  61. package/edition/operation-grant-admission.mjs +51 -9
  62. package/edition/resolve-customer-edition.mjs +1 -1
  63. package/edition/resolve-distribution.mjs +95 -82
  64. package/edition/resolve-edition.mjs +2 -1
  65. package/edition/resolve-images.mjs +233 -14
  66. package/edition/seal-image-digests.mjs +24 -13
  67. package/edition/shallow-checkout.mjs +18 -0
  68. package/edition/verify-image-platforms.mjs +2 -0
  69. package/edition/workspace-sections.mjs +33 -3
  70. package/package.json +7 -3
@@ -0,0 +1,298 @@
1
+ // images-from-record — a develop resolve's image digests taken from a BUILD RECORD, with no registry probe and no rehash.
2
+ //
3
+ // The everyday job builds (or reuses by content-hash tag) the edition's images in the aggregator workspace and hands
4
+ // `edition develop resolve --images-from <file>` the record of what it built. Each entry names one image subject of
5
+ // the edition by the identity images.lock.json and the seal key it by (`<kind>:<id>`), and carries
6
+ // `{repository, contentHash, digest, platforms, sourceSha}`:
7
+ //
8
+ // images what the job built or reused itself. Trusted as the job's own statement: the job signs the edition.
9
+ // satellites a satellite producer's develop digest (a first-party source that is not built in the workspace, such
10
+ // as submodules/xema-host-web). Admitted only after a cosign verify that the producer's own develop
11
+ // ci.yaml build record, signed by the data root's record workflow called by that producer
12
+ // (develop-record-verify.mjs), lists exactly this repository, digest and content hash at sourceSha.
13
+ // The producer is the subject's declared `firstPartySources[repo].repositoryUrl`, never the record's.
14
+ //
15
+ // Which list an entry sits in is not the record's choice: each first-party source declares its `imageOrigin` once in
16
+ // `firstPartySources` (FirstPartyImageOrigin: `workspace` -> `images`, `satellite` -> `satellites`). An entry in the
17
+ // other list, or for a source that declares no origin, is refused, so no satellite is trusted without its verify.
18
+ //
19
+ // Coverage is exact: every first-party subject of the edition comes from exactly one entry, and every entry is a
20
+ // subject of the edition. A missing, duplicated or extra subject, a sourceSha other than the commit the snapshot pins
21
+ // for the subject's repository, a repository other than the subject's develop repository, or a platform set without
22
+ // every required platform refuses the whole resolve, every refusal named at once. There is no fallback to the probe.
23
+ // contentHash and sourceSha are carried into the images lock where the probe path writes them, never recomputed.
24
+ // A third-party subject (a pinned OCI biome's image) is not built by the job: it keeps its pinned seal, and a record
25
+ // entry naming one is an extra subject.
26
+ //
27
+ // The record schema is the kernel's (`ImageBuildRecordSchema` / `parseImageBuildRecord`, @xemahq/contracts, edition
28
+ // protocol v41), loaded through the kernel loader; this module owns no parser of its own.
29
+
30
+ import { canonicalJson } from './canonical-evidence-json.mjs';
31
+ import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
32
+ import { RefusalError } from './collected-refusals.mjs';
33
+ import { developImageRef } from './develop-image-coordinate.mjs';
34
+ import { withRecordVerifier } from './develop-record-verify.mjs';
35
+ import { findVerifiedProducerImageRecord } from './develop-verify-image-record.mjs';
36
+ import { EditionChannel } from './edition-channel.mjs';
37
+ import { sealImageSubjects } from './edition-image-seal.mjs';
38
+ import { firstPartySources } from './first-party-source-ids.mjs';
39
+ import { compareStrings, loadKernelDistributionSchemas, sha256Hex, validateImmutableImageLock } from './lib.mjs';
40
+ import { RecordRefused } from './record-refused.mjs';
41
+ import { readStrictJsonFile } from './strict-json.mjs';
42
+ import { FirstPartyImageOrigin } from './workspace-sections.mjs';
43
+
44
+ /** Which list of the record an entry came from: how far it is trusted. */
45
+ export const RecordedImageOrigin = Object.freeze({
46
+ Built: 'images',
47
+ Satellite: 'satellites',
48
+ });
49
+
50
+ /** The one record list each declared image origin admits. */
51
+ const LIST_OF_ORIGIN = Object.freeze({
52
+ [FirstPartyImageOrigin.Workspace]: RecordedImageOrigin.Built,
53
+ [FirstPartyImageOrigin.Satellite]: RecordedImageOrigin.Satellite,
54
+ });
55
+
56
+ /** A build record that is malformed, or does not cover the edition exactly. */
57
+ export class ImageBuildRecordError extends RefusalError {
58
+ constructor(message) {
59
+ super(message);
60
+ this.name = 'ImageBuildRecordError';
61
+ }
62
+ }
63
+
64
+ /** The subject key the image lock, the probe and the seal use. */
65
+ export const subjectKeyOf = (subject) => `${subject.kind}:${subject.id}`;
66
+
67
+ const platformKey = (platform) => [platform.os, platform.architecture, platform.variant ?? ''].join('/');
68
+
69
+ /** The kernel's image-build-record exports, refused as a set when any is absent (a kernel before edition protocol v41). */
70
+ export function requireImageBuildRecordKernel(schemas) {
71
+ const names = ['ImageBuildRecordSchema', 'parseImageBuildRecord', 'IMAGE_BUILD_RECORD_SCHEMA_VERSION'];
72
+ const missing = names.filter((name) => schemas?.[name] === undefined);
73
+ if (missing.length > 0) {
74
+ throw new ImageBuildRecordError(`the kernel's distribution module does not export ${missing.join(', ')}; the image build record needs a kernel at edition protocol v41 or newer`);
75
+ }
76
+ return schemas;
77
+ }
78
+
79
+ /**
80
+ * Parse an image build record with the kernel's own parser (`parseImageBuildRecord`, the ONE contract: both the writer
81
+ * `develop image-record` and this reader go through it): `{schemaVersion: 1, images: [entry], satellites: [entry]}`, every
82
+ * list sorted by subject, no subject twice, no unknown key. A refusal is an `ImageBuildRecordError` carrying the kernel's message.
83
+ *
84
+ * @param {unknown} document
85
+ * @param {string} source where the record came from (named in the refusal)
86
+ * @param {{loadKernel?: () => Promise<object>}} [injections] tests replace the kernel loader
87
+ */
88
+ export async function parseImageBuildRecord(document, source, { loadKernel = loadKernelDistributionSchemas } = {}) {
89
+ const kernel = requireImageBuildRecordKernel(await loadKernel());
90
+ try {
91
+ return kernel.parseImageBuildRecord(document);
92
+ } catch (error) {
93
+ throw new ImageBuildRecordError(`image build record ${source} is refused: ${error.message}`);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * sha256 (hex) of the parsed record's canonical JSON (canonical-evidence-json.mjs: keys sorted), every list in its
99
+ * canonical order: entries by subject (parse enforces it) and each entry's platforms by os/architecture/variant. It is
100
+ * the `imageBuildRecord` input of the develop edition's target tag (snapshotEditionTag).
101
+ */
102
+ export function imageBuildRecordDigest(record) {
103
+ const canonicalEntry = (entry) => ({ ...entry, platforms: [...entry.platforms].sort((a, b) => compareStrings(platformKey(a), platformKey(b))) });
104
+ return sha256Hex(canonicalJson({
105
+ schemaVersion: record.schemaVersion,
106
+ ...Object.fromEntries(Object.values(RecordedImageOrigin).map((origin) => [origin, record[origin].map(canonicalEntry)])),
107
+ }));
108
+ }
109
+
110
+ /** Read and parse the record file `--images-from` names (duplicate JSON keys refused). */
111
+ export async function readImageBuildRecord(path) {
112
+ return parseImageBuildRecord(readStrictJsonFile(path), path);
113
+ }
114
+
115
+ /**
116
+ * The record as `resolveImages` consumes it (`recordedImages`): `take` gives each first-party subject its recorded
117
+ * content hash and source commit, and `settle` refuses, all at once, every subject the record does not cover exactly.
118
+ *
119
+ * Each subject's entry must sit in the list its source's declared `imageOrigin` names (LIST_OF_ORIGIN); an entry in the
120
+ * other list, or a source with no declared origin, is one more problem `settle` names.
121
+ *
122
+ * @param {{images: object[], satellites: object[]}} record a parsed record
123
+ * @param {{distributionId: string, declaredSources?: object}} context `declaredSources` is `firstPartySources` (tests pass it)
124
+ */
125
+ export function createRecordedImages(record, { distributionId, declaredSources = firstPartySources('checking each build record entry against its source\'s declared image origin') }) {
126
+ const entries = new Map();
127
+ for (const origin of Object.values(RecordedImageOrigin)) {
128
+ for (const entry of record[origin]) {entries.set(entry.subject, { origin, entry });}
129
+ }
130
+ const taken = new Map();
131
+ const problems = [];
132
+ let settled = false;
133
+
134
+ return {
135
+ take(subject) {
136
+ const key = subjectKeyOf(subject);
137
+ const recorded = entries.get(key);
138
+ if (recorded === undefined) {
139
+ problems.push(`${key} (${subject.repo}@${subject.sourceCommit}) is not in the record`);
140
+ return { sourceCommit: subject.sourceCommit, contentHash: null };
141
+ }
142
+ if (taken.has(key)) {throw new Error(`images-from-record: subject ${key} was taken twice`);}
143
+ const { entry, origin } = recorded;
144
+ const listProblem = recordListProblem(key, subject, origin, declaredSources);
145
+ if (listProblem !== null) {problems.push(listProblem);}
146
+ if (entry.sourceSha !== subject.sourceCommit) {
147
+ problems.push(`${key}: the record's sourceSha ${entry.sourceSha} is not ${subject.sourceCommit}, the commit the snapshot pins for ${subject.repo}`);
148
+ }
149
+ const repository = developImageRef(subject.imageRef);
150
+ if (entry.repository !== repository) {
151
+ problems.push(`${key}: the record's repository ${entry.repository} is not its develop repository ${repository}`);
152
+ }
153
+ const recordedPlatforms = new Set(entry.platforms.map(platformKey));
154
+ const absent = subject.requiredPlatforms.map(platformKey).filter((platform) => !recordedPlatforms.has(platform));
155
+ if (absent.length > 0) {problems.push(`${key}: the record's platforms lack the required ${absent.join(', ')}`);}
156
+ taken.set(key, { subject, ...recorded });
157
+ return { sourceCommit: entry.sourceSha, contentHash: entry.contentHash, ...recordedMembers(key, subject, entry, problems) };
158
+ },
159
+ settle() {
160
+ settled = true;
161
+ const extra = [...entries.keys()].filter((key) => !taken.has(key)).map((key) => `${key} (record.${entries.get(key).origin}) is not a first-party image subject of "${distributionId}"`);
162
+ const all = [...problems, ...extra];
163
+ if (all.length > 0) {
164
+ throw new ImageBuildRecordError(`the image build record does not cover "${distributionId}" exactly (${all.length} problem(s); no probe fallback):\n ${all.join('\n ')}`);
165
+ }
166
+ },
167
+ /** The satellite entries taken, each with the subject it covers, for the cosign verify. */
168
+ satelliteClaims() {
169
+ if (!settled) {throw new Error('images-from-record: satellite claims are read only after settle()');}
170
+ return [...taken.values()].filter(({ origin }) => origin === RecordedImageOrigin.Satellite).map(({ subject, entry }) => ({ subject, entry }));
171
+ },
172
+ /** The recorded entry of a subject, or undefined for a subject the record does not cover (a third-party one). */
173
+ entryOf(subject) {
174
+ return taken.get(subjectKeyOf(subject))?.entry;
175
+ },
176
+ };
177
+ }
178
+
179
+ /**
180
+ * A composed subject's members with the keys its record entry carries (`members[{service, key}]`), never computed here.
181
+ * An entry without members, or whose services are not the subject's members, is a problem named once; a subject that is
182
+ * not composed gives nothing.
183
+ */
184
+ function recordedMembers(key, subject, entry, problems) {
185
+ if (subject.members === undefined) {return {};}
186
+ if (entry.members === undefined) {
187
+ problems.push(`${key}: the record's composed entry carries no members[{service, key}]`);
188
+ return {};
189
+ }
190
+ const keyOf = new Map(entry.members.map((member) => [member.service, member.key]));
191
+ const wanted = subject.members.map((member) => member.service);
192
+ const absent = wanted.filter((service) => !keyOf.has(service));
193
+ const extra = [...keyOf.keys()].filter((service) => !wanted.includes(service));
194
+ if (absent.length > 0 || extra.length > 0) {
195
+ problems.push(`${key}: the record's members differ from the subject's${absent.length > 0 ? ` (no key for ${absent.join(', ')})` : ''}${extra.length > 0 ? ` (not members: ${extra.join(', ')})` : ''}`);
196
+ return {};
197
+ }
198
+ return { members: subject.members.map((member) => ({ ...member, key: keyOf.get(member.service) })) };
199
+ }
200
+
201
+ /** Why the record list `origin` may not carry the subject's entry, or null when its source's declared origin names it. */
202
+ function recordListProblem(key, subject, origin, declaredSources) {
203
+ if (!Object.hasOwn(declaredSources, subject.repo)) {
204
+ return `${key} (record.${origin}): ${subject.repo} is not a declared first-party source`;
205
+ }
206
+ const declared = declaredSources[subject.repo].imageOrigin;
207
+ if (declared === undefined) {
208
+ return `${key} (record.${origin}): firstPartySources["${subject.repo}"] declares no imageOrigin (one of ${Object.values(FirstPartyImageOrigin).join(', ')}); --images-from admits no entry of an unconfigured source`;
209
+ }
210
+ const wanted = LIST_OF_ORIGIN[declared];
211
+ if (origin !== wanted) {
212
+ return `${key} is in record.${origin}, but firstPartySources["${subject.repo}"].imageOrigin is "${declared}": its entry belongs in record.${wanted}`;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ /** The `owner/name` a satellite subject's develop identity is bound to: its repository's declared URL. */
218
+ export function satelliteProducerOf(subject) {
219
+ const sources = firstPartySources('naming a satellite producer');
220
+ if (!Object.hasOwn(sources, subject.repo)) {
221
+ throw new ImageBuildRecordError(`${subjectKeyOf(subject)}: ${subject.repo} is not a declared first-party source; a satellite producer is never named by the record`);
222
+ }
223
+ const context = `firstPartySources["${subject.repo}"].repositoryUrl`;
224
+ return repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(sources[subject.repo].repositoryUrl, context), context);
225
+ }
226
+
227
+ /**
228
+ * The cosign verifier of satellite claims: for each, the producer's newest verified develop ci.yaml build record
229
+ * (develop-record-verify.mjs: keyless, offline against the pinned trusted root, issuer
230
+ * https://token.actions.githubusercontent.com, identity the data root's record workflow at refs/heads/develop, called by
231
+ * the producer) whose commit is the claim's sourceSha and which lists its repository, digest and content hash. Every
232
+ * refused claim is named at once; a registry or cosign that could not answer is a plain Error. The registry is read
233
+ * for the records and signatures only: no digest is discovered and nothing is rehashed.
234
+ *
235
+ * @param {object} request
236
+ * @param {string} request.registry the registry root that holds `build-records/<repo>`
237
+ * @param {string} request.cosignBinary absolute path of cosign
238
+ * @param {Function} [request.withVerifier] the pinned record verifier scope (tests replace it, to run offline)
239
+ * @param {Function} [request.findRecord] the newest-first verified record walk (tests replace it)
240
+ * @returns {(claims: {subject: object, entry: object}[]) => Promise<void>}
241
+ */
242
+ export function createSatelliteVerifier({ registry, cosignBinary, env = process.env, withVerifier = withRecordVerifier, findRecord = findVerifiedProducerImageRecord, ...verifierInjections }) {
243
+ return async (claims) => {
244
+ if (claims.length === 0) {return;}
245
+ await withVerifier({ cosignBinary, env, ...verifierInjections }, async (verifier) => {
246
+ const refused = [];
247
+ for (const { subject, entry } of claims) {
248
+ try {
249
+ await findRecord({
250
+ ...verifier,
251
+ registry,
252
+ producer: satelliteProducerOf(subject),
253
+ wanted: `at ${entry.sourceSha} lists ${entry.repository}@${entry.digest} with content hash ${entry.contentHash}`,
254
+ matches: (record) =>
255
+ record.commit === entry.sourceSha &&
256
+ record.images.some((listed) => listed.name === entry.repository && listed.digest === entry.digest && listed.contentHash === entry.contentHash),
257
+ });
258
+ } catch (error) {
259
+ if (!(error instanceof RecordRefused)) {throw error;}
260
+ refused.push(`${subjectKeyOf(subject)}: ${error.message}`);
261
+ }
262
+ }
263
+ if (refused.length > 0) {
264
+ throw new ImageBuildRecordError(`${refused.length} satellite image(s) of the build record are not verified:\n ${refused.join('\n ')}`);
265
+ }
266
+ });
267
+ };
268
+ }
269
+
270
+ /**
271
+ * The develop images lock of a snapshot from the record: the subjects as `resolveImages` structures them, each
272
+ * first-party one with the record's content hash and source commit, coverage settled, satellites verified.
273
+ *
274
+ * @returns {Promise<{imageLock: object, recorded: ReturnType<typeof createRecordedImages>}>}
275
+ */
276
+ export async function resolveImagesFromRecord({ id, inputs, lock, record, resolveImages, verifySatellites, previousLock = null }) {
277
+ const recorded = createRecordedImages(record, { distributionId: id });
278
+ const { imageLock } = await resolveImages(id, { inputs, lock, recordedImages: recorded, previousLock });
279
+ await verifySatellites(recorded.satelliteClaims());
280
+ return { imageLock, recorded };
281
+ }
282
+
283
+ /**
284
+ * Seal the lock from the record: each recorded subject takes the record's develop repository and digest, with no
285
+ * registry read. A third-party subject (the only kind the record does not cover) keeps its pinned seal.
286
+ */
287
+ export async function sealImagesFromRecord({ imageLock, recorded, thirdPartyPins, ...sealOptions }) {
288
+ const unrecorded = imageLock.subjects.filter((subject) => recorded.entryOf(subject) === undefined);
289
+ const pinned = unrecorded.length === 0 ? [] : await sealImageSubjects({ channel: EditionChannel.Develop, distributionId: imageLock.distributionId, subjects: unrecorded, thirdPartyPins, ...sealOptions });
290
+ const pinnedByKey = new Map(pinned.map((subject) => [subjectKeyOf(subject), subject]));
291
+ const subjects = imageLock.subjects.map((subject) => {
292
+ const entry = recorded.entryOf(subject);
293
+ if (entry === undefined) {return pinnedByKey.get(subjectKeyOf(subject));}
294
+ return { ...subject, imageRef: entry.repository, imageDigest: entry.digest };
295
+ });
296
+ const schemas = await loadKernelDistributionSchemas();
297
+ return validateImmutableImageLock(schemas.parseImageLock({ ...imageLock, subjects }), schemas);
298
+ }
package/edition/lib.mjs CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  } from './edition-workspace.mjs';
37
37
  import { SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
38
38
  import { readGitJsonAtCommit } from './git-json.mjs';
39
+ import { imageSubjectIdentity } from './image-subject-identity.mjs';
39
40
  import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
40
41
  import {
41
42
  PRINCIPAL_PRIVILEGE_FIELDS,
@@ -719,23 +720,6 @@ export function assertKernelSealsComposedMembership(schemas, subjects, label) {
719
720
  );
720
721
  }
721
722
 
722
- /**
723
- * The READ side of the same rule, for a kernel before image-lock
724
- * schemaVersion 4 (no `ImageLockSchemaVersion`): it reads only schemaVersion
725
- * 3, which names no composed members, so a v3 lock carrying a composed-process
726
- * subject cannot say which services run from it and is refused (worded as the
727
- * kernel's own `LegacyComposedImageLockError`).
728
- */
729
- export function assertLegacyLockHasNoComposedSubject(lock) {
730
- const composed = (lock.subjects ?? []).find((subject) => subject.kind === 'composed-process');
731
- if (composed === undefined) {return;}
732
- throw new ComposedProcessNeedsLockV4Error(
733
- `distribution "${lock.distributionId}": subjects "${composed.id}" is a schemaVersion ` +
734
- `${String(lock.schemaVersion)} composed-process subject, which names no members: regenerate with a CLI ` +
735
- 'that writes image-lock schemaVersion 4, against a kernel at edition protocol >= v14',
736
- );
737
- }
738
-
739
723
  /**
740
724
  * The image-lock schemaVersion a writer emits for `subjects`: the loaded
741
725
  * kernel's `IMAGE_LOCK_WRITE_SCHEMA_VERSION`. A kernel before edition protocol
@@ -800,7 +784,10 @@ export async function loadKernelDistributionSchemas() {
800
784
  parseOciDigest,
801
785
  parseGitTreeId,
802
786
  parseImageLock,
803
- serviceImageSubjects,
787
+ dedicatedServiceImageSubjects,
788
+ carryForwardComposedArtifacts,
789
+ DEFAULT_COMPOSED_IMAGE_CLASS,
790
+ ComposedImageClassSchema,
804
791
  IMAGE_LOCK_WRITE_SCHEMA_VERSION,
805
792
  ImageLockSchemaVersion,
806
793
  parseImageProducerStatement,
@@ -893,7 +880,10 @@ export async function loadKernelDistributionSchemas() {
893
880
  typeof parseOciDigest !== 'function' ||
894
881
  typeof parseGitTreeId !== 'function' ||
895
882
  typeof parseImageLock !== 'function' ||
896
- typeof serviceImageSubjects !== 'function' ||
883
+ typeof dedicatedServiceImageSubjects !== 'function' ||
884
+ typeof carryForwardComposedArtifacts !== 'function' ||
885
+ typeof DEFAULT_COMPOSED_IMAGE_CLASS !== 'string' ||
886
+ typeof ComposedImageClassSchema?.safeParse !== 'function' ||
897
887
  typeof parseImageProducerStatement !== 'function' ||
898
888
  typeof parseDistributionImagesStatement !== 'function' ||
899
889
  typeof parseImageDeliveryEntitlementStatement !== 'function' ||
@@ -997,6 +987,8 @@ export async function loadKernelDistributionSchemas() {
997
987
  // The build-record exports (absent from a kernel before the build-record contract): carried as-is, NOT required
998
988
  // here, so every other verb still loads an older kernel. `select-records` refuses a kernel that lacks them.
999
989
  const { BUILD_RECORD_MEDIA_TYPE, BuildRecordSchema, buildRecordTag, parseBuildRecordTag, compareBuildRecordRuns, parseBuildRecord } = mod;
990
+ // The image-build-record exports (edition protocol v41): the same, carried as-is; `develop image-record` and `--images-from` refuse an older kernel.
991
+ const { IMAGE_BUILD_RECORD_SCHEMA_VERSION, ImageBuildRecordSchema, parseImageBuildRecord } = mod;
1000
992
  return {
1001
993
  BUILD_RECORD_MEDIA_TYPE,
1002
994
  BuildRecordSchema,
@@ -1004,6 +996,9 @@ export async function loadKernelDistributionSchemas() {
1004
996
  parseBuildRecordTag,
1005
997
  compareBuildRecordRuns,
1006
998
  parseBuildRecord,
999
+ IMAGE_BUILD_RECORD_SCHEMA_VERSION,
1000
+ ImageBuildRecordSchema,
1001
+ parseImageBuildRecord,
1007
1002
  parseDistribution,
1008
1003
  parseDistributionLock,
1009
1004
  parseDistributionLockForReading,
@@ -1022,7 +1017,10 @@ export async function loadKernelDistributionSchemas() {
1022
1017
  parseOciDigest,
1023
1018
  parseGitTreeId,
1024
1019
  parseImageLock,
1025
- serviceImageSubjects,
1020
+ dedicatedServiceImageSubjects,
1021
+ carryForwardComposedArtifacts,
1022
+ DEFAULT_COMPOSED_IMAGE_CLASS,
1023
+ ComposedImageClassSchema,
1026
1024
  IMAGE_LOCK_WRITE_SCHEMA_VERSION,
1027
1025
  ImageLockSchemaVersion,
1028
1026
  parseImageProducerStatement,
@@ -1981,6 +1979,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1981
1979
  logicalName: requirement.logicalName,
1982
1980
  scope: requirement.scope,
1983
1981
  inclusion: requirement.inclusion,
1982
+ ...(requirement.sharing === undefined ? {} : { sharing: requirement.sharing }),
1984
1983
  });
1985
1984
  }
1986
1985
  for (const provision of provisions) {
@@ -2097,25 +2096,6 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
2097
2096
  providesResources.sort(
2098
2097
  (a, b) => compareStrings(a.service, b.service) || compareStrings(a.kind, b.kind),
2099
2098
  );
2100
- // The provider derives a resource's PHYSICAL identity from the owning biome
2101
- // plus (kind, scope, logicalName). Two consumers of this biome naming the
2102
- // same tuple would be handed ONE physical resource while each believes it
2103
- // owns it — so the declaration is refused here, where the biome is the whole
2104
- // subject, rather than discovered at the provider.
2105
- const physicalResources = new Map();
2106
- for (const row of serviceResources) {
2107
- const identity = `${row.kind}\0${row.scope}\0${row.logicalName}`;
2108
- const holder = physicalResources.get(identity);
2109
- if (holder !== undefined && holder !== row.consumerService) {
2110
- throw new Error(
2111
- `${rel}: services "${holder}" and "${row.consumerService}" both declare ` +
2112
- `${row.scope}-scoped ${row.kind} "${row.logicalName}" — biome "${id}" ` +
2113
- `derives ONE physical resource from that, so two consumers would share ` +
2114
- `it while each believes it owns it. Give each its own logicalName.`,
2115
- );
2116
- }
2117
- physicalResources.set(identity, row.consumerService);
2118
- }
2119
2099
  // The two arrays partition the biome's images — a name in both would be
2120
2100
  // simultaneously rostered and unschedulable.
2121
2101
  for (const name of runtimeComponents) {
@@ -3129,9 +3109,7 @@ export function validateImmutableImageLock(lock, schemas) {
3129
3109
  throw new Error('trusted kernel distribution schemas are required');
3130
3110
  }
3131
3111
  assertKernelSealsComposedMembership(schemas, lock.subjects, `distribution "${lock.distributionId}"`);
3132
- // The revisions the loaded kernel reads (`ImageLockSchemaVersion`: 3 without
3133
- // composed membership, 4 with it); a kernel before image-lock v4 reads only 3.
3134
- if (schemas.ImageLockSchemaVersion === undefined) {assertLegacyLockHasNoComposedSubject(lock);}
3112
+ // The revisions the loaded kernel reads (`ImageLockSchemaVersion`).
3135
3113
  const readableVersions =
3136
3114
  schemas.ImageLockSchemaVersion === undefined
3137
3115
  ? [PRE_V14_IMAGE_LOCK_SCHEMA_VERSION]
@@ -3146,11 +3124,13 @@ export function validateImmutableImageLock(lock, schemas) {
3146
3124
  );
3147
3125
  }
3148
3126
  const identities = new Set();
3127
+ const namedSubjects = new Set();
3149
3128
  for (const subject of lock.subjects ?? []) {
3150
- const identity = `${subject.kind}\0${subject.id}`;
3129
+ namedSubjects.add(`${subject.kind}:${subject.id}`);
3130
+ const identity = imageSubjectIdentity(subject);
3151
3131
  if (identities.has(identity)) {
3152
3132
  throw new Error(
3153
- `distribution "${lock.distributionId}" has duplicate runtime image subject "${subject.kind}:${subject.id}"`,
3133
+ `distribution "${lock.distributionId}" has duplicate runtime image subject "${identity}"`,
3154
3134
  );
3155
3135
  }
3156
3136
  identities.add(identity);
@@ -3185,7 +3165,7 @@ export function validateImmutableImageLock(lock, schemas) {
3185
3165
  );
3186
3166
  }
3187
3167
  platformServices.add(binding.platformService);
3188
- if (!identities.has(`${binding.subjectKind}\0${binding.subjectId}`)) {
3168
+ if (!namedSubjects.has(`${binding.subjectKind}:${binding.subjectId}`)) {
3189
3169
  throw new Error(
3190
3170
  `distribution "${lock.distributionId}": platform service "${binding.platformService}" binds unknown runtime subject "${binding.subjectKind}:${binding.subjectId}"`,
3191
3171
  );
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { createRequire } from 'node:module';
17
17
 
18
+ import { KernelRefusalName, RefusalError, collectRefusals, isRefusal, joinRefusalMessages } from './collected-refusals.mjs';
18
19
  import { editionWorkspaceConfig } from './edition-config.mjs';
19
20
  import { assertKernelEditionProtocol } from './edition-protocol.mjs';
20
21
  import { dataRoot, kernelDistPath, kernelPackageDir } from './edition-workspace.mjs';
@@ -31,13 +32,16 @@ const PUBLIC_KEY_KIND = 'public-key';
31
32
  const KERNEL_EXPORTS = Object.freeze(['lockedBundleSignerOf', 'assertLockedBundleSignerMatches', 'LockedBundleSignerUnknownPolicyError', 'LockedBundleSignerAmbiguousPolicyError']);
32
33
 
33
34
  /** A named refusal of the bundle signer plane. */
34
- class BundleSignerError extends Error {
35
+ export class BundleSignerError extends RefusalError {
35
36
  constructor(message) {
36
37
  super(message);
37
38
  this.name = new.target.name;
38
39
  }
39
40
  }
40
41
 
42
+ /** Whether `error` is a refusal of the bundle signer plane: this module's own, or one the kernel's signer projection throws. */
43
+ export const isBundleSignerRefusal = (error) => error instanceof BundleSignerError || (isRefusal(error) && Object.values(KernelRefusalName).includes(error.name));
44
+
41
45
  /** No trust policy row can be selected for the subject: no pin, no `firstPartyBundleSigner`, or no policy at all. */
42
46
  export class BundleSignerPolicyAbsentError extends BundleSignerError {}
43
47
  /** The selected row is not of the kind its origin requires. */
@@ -54,6 +58,14 @@ export class BundleSourceOnGitRowError extends BundleSignerError {}
54
58
  export class GitSourcedHostModuleBundleError extends BundleSignerError {}
55
59
  /** The loaded kernel does not export what the bundle signer plane needs. */
56
60
  export class BundleSignerKernelUnsupportedError extends BundleSignerError {}
61
+ /** Several OCI rows were refused in one pass; `.errors` holds each row's own refusal. */
62
+ export class BundleSignerRefusedError extends BundleSignerError {
63
+ /** @param {readonly Error[]} errors */
64
+ constructor(errors) {
65
+ super(`${errors.length} OCI bundle rows are refused:\n\n${joinRefusalMessages(errors)}`);
66
+ this.errors = errors;
67
+ }
68
+ }
57
69
 
58
70
  /** What the kernel must export, validated once: `module` is the loaded kernel `distribution` module. */
59
71
  export function bundleSignerKernelFrom(module, location) {
@@ -234,8 +246,8 @@ export function assertLockBundleSigners({ lock, selection, schemas }) {
234
246
  const subjects = lockBundleSubjects(lock);
235
247
  if (subjects.length === 0) {return;}
236
248
  const chosen = selection ?? loadBundleSignerSelection({ dataRoot: dataRoot(), schemas });
237
- for (const { row, subject } of subjects) {
249
+ collectRefusals(subjects, ({ row, subject }) => {
238
250
  assertNotGitSourced(subject);
239
251
  chosen.assertSealed(subject, row.bundleSigner);
240
- }
252
+ }, isRefusal, BundleSignerRefusedError);
241
253
  }
@@ -10,13 +10,16 @@
10
10
  * and the edition's store-workload ceiling are admitted the same way.
11
11
  */
12
12
 
13
+ import { RefusalError, collectRefusals, isRefusal, joinRefusalMessages } from './collected-refusals.mjs';
14
+
13
15
  /** Thrown when one or more declared operation requirements are not admitted. */
14
- export class OperationGrantAdmissionError extends Error {
16
+ export class OperationGrantAdmissionError extends RefusalError {
15
17
  /**
16
18
  * @param {string} distributionId
17
19
  * @param {ReadonlyArray<{code: string, operation: string, message: string}>} refusals
20
+ * @param {ReadonlyArray<{caller: object, operation: string, reason: string}>} [ungrantedOptional] what the kernel left ungranted beside the refusals
18
21
  */
19
- constructor(distributionId, refusals) {
22
+ constructor(distributionId, refusals, ungrantedOptional = []) {
20
23
  super(
21
24
  `distribution "${distributionId}" declares operation requirements that are not admitted:\n` +
22
25
  refusals.map((refusal) => `[${refusal.code}] ${refusal.message}`).join('\n'),
@@ -24,6 +27,7 @@ export class OperationGrantAdmissionError extends Error {
24
27
  this.name = 'OperationGrantAdmissionError';
25
28
  this.distributionId = distributionId;
26
29
  this.refusals = refusals;
30
+ this.ungrantedOptional = ungrantedOptional;
27
31
  }
28
32
  }
29
33
 
@@ -49,7 +53,7 @@ export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
49
53
  // A platform service's refused requirement fails exactly like a biome component's, through the same error.
50
54
  const allRefusals = [...refusals, ...platformServiceRefusals];
51
55
  if (allRefusals.length > 0) {
52
- throw new OperationGrantAdmissionError(lock.distributionId, allRefusals);
56
+ throw new OperationGrantAdmissionError(lock.distributionId, allRefusals, ungrantedOptional);
53
57
  }
54
58
  if (report !== undefined) {
55
59
  for (const line of ungrantedOptionalOperationLines(lock.distributionId, ungrantedOptional)) {report(line);}
@@ -58,7 +62,7 @@ export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
58
62
  }
59
63
 
60
64
  /** Thrown when one or more declared organization sweeps are not admitted. */
61
- export class OrganizationSweepAdmissionError extends Error {
65
+ export class OrganizationSweepAdmissionError extends RefusalError {
62
66
  /**
63
67
  * @param {string} distributionId
64
68
  * @param {ReadonlyArray<{code: string, biome: string, component: string, sweep: string, message: string}>} refusals
@@ -89,7 +93,7 @@ export function assertOrganizationSweepsAdmitted(schemas, lock) {
89
93
  }
90
94
 
91
95
  /** Thrown when one or more declared exact audiences are not admitted. */
92
- export class ExactAudienceAdmissionError extends Error {
96
+ export class ExactAudienceAdmissionError extends RefusalError {
93
97
  /**
94
98
  * @param {string} distributionId
95
99
  * @param {ReadonlyArray<{code: string, message: string}>} refusals
@@ -120,7 +124,7 @@ export function assertExactAudiencesAdmitted(schemas, lock) {
120
124
  }
121
125
 
122
126
  /** Thrown when one or more requested installed operations are not admitted into the ceiling. */
123
- export class InstalledOperationCeilingAdmissionError extends Error {
127
+ export class InstalledOperationCeilingAdmissionError extends RefusalError {
124
128
  /**
125
129
  * @param {string} distributionId
126
130
  * @param {ReadonlyArray<{code: string, biome: string, component: string, operation: string, message: string}>} refusals
@@ -151,7 +155,7 @@ export function assertInstalledOperationCeilingAdmitted(schemas, lock) {
151
155
  }
152
156
 
153
157
  /** Thrown when one or more domains of the edition's store-workload ceiling are not admitted. */
154
- export class StoreWorkloadCeilingAdmissionError extends Error {
158
+ export class StoreWorkloadCeilingAdmissionError extends RefusalError {
155
159
  /**
156
160
  * @param {string} distributionId
157
161
  * @param {ReadonlyArray<{code: string, domain: string, message: string}>} refusals
@@ -182,7 +186,7 @@ export function assertStoreWorkloadCeilingAdmitted(schemas, lock) {
182
186
  }
183
187
 
184
188
  /** Thrown when one or more declared organization-context relays are not admitted. */
185
- export class OrgContextRelayAdmissionError extends Error {
189
+ export class OrgContextRelayAdmissionError extends RefusalError {
186
190
  /**
187
191
  * @param {string} distributionId
188
192
  * @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
@@ -212,7 +216,7 @@ export function assertOrgContextRelaysAdmitted(schemas, lock) {
212
216
  }
213
217
 
214
218
  /** Thrown when one or more declared event subscription clauses are not admitted. */
215
- export class EventSubscriptionAdmissionError extends Error {
219
+ export class EventSubscriptionAdmissionError extends RefusalError {
216
220
  /**
217
221
  * @param {string} distributionId
218
222
  * @param {ReadonlyArray<{code: string, biome: string, component: string, message: string}>} refusals
@@ -241,3 +245,41 @@ export function assertEventSubscriptionsAdmitted(schemas, lock) {
241
245
  }
242
246
  return admission;
243
247
  }
248
+
249
+ /** Thrown when several admission categories refuse the same lock; `.errors` holds each category's own error. */
250
+ export class LockAdmissionRefusedError extends RefusalError {
251
+ /** @param {readonly Error[]} errors */
252
+ constructor(errors) {
253
+ super(`the distribution lock is refused by ${errors.length} admission checks:\n\n${joinRefusalMessages(errors)}`);
254
+ this.name = 'LockAdmissionRefusedError';
255
+ this.errors = errors;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Run every admission a lock must pass, in the order the resolve has always run them, and refuse with all of them: one
261
+ * refusal is rethrown unchanged, several are one {@link LockAdmissionRefusedError}. `report` receives the optional
262
+ * operations left ungranted. Nothing here changes `lock`.
263
+ */
264
+ export function admitDistributionLock(schemas, lock, { report } = {}) {
265
+ // The two ceilings read the lock's own fields, not the outcome of the grants, so they are checked beside them.
266
+ const admissions = [
267
+ () => assertOperationGrantsAdmitted(schemas, lock, { report }),
268
+ () => assertOrganizationSweepsAdmitted(schemas, lock),
269
+ () => assertExactAudiencesAdmitted(schemas, lock),
270
+ () => assertOrgContextRelaysAdmitted(schemas, lock),
271
+ () => assertEventSubscriptionsAdmitted(schemas, lock),
272
+ () => assertInstalledOperationCeilingAdmitted(schemas, lock),
273
+ () => assertStoreWorkloadCeilingAdmitted(schemas, lock),
274
+ ];
275
+ collectRefusals(admissions, (admit) => admit(), isRefusal, LockAdmissionRefusedError);
276
+ }
277
+
278
+ /**
279
+ * The {@link ungrantedOptionalOperationLines} a refused admission carries: the refusal of the operation grants itself, or the
280
+ * one among several that is. Empty when the grants were not what refused.
281
+ */
282
+ export function ungrantedOptionalLinesOfRefusal(error) {
283
+ const grantRefusal = [error, ...(error.errors ?? [])].find((candidate) => candidate instanceof OperationGrantAdmissionError);
284
+ return grantRefusal === undefined ? [] : ungrantedOptionalOperationLines(grantRefusal.distributionId, grantRefusal.ungrantedOptional);
285
+ }
@@ -150,7 +150,7 @@ export function projectVendorImages(catalogue, lock, enums) {
150
150
  ...(biome.runtimeComponents ?? []).map((id) => [enums.RuntimeImageSubjectKind.RuntimeComponent, id]),
151
151
  ];
152
152
  for (const [kind, id] of wanted) {
153
- if (subjectServingBiome(subjects, biome.id, kind, id, enums) === undefined) {
153
+ if (subjectServingBiome(catalogue.images.schemaVersion, subjects, biome.id, kind, id, enums) === undefined) {
154
154
  throw new CatalogueCeilingError(`the catalogue carries no image row for ${kind}:${id} of selected biome "${biome.id}"`);
155
155
  }
156
156
  }