@xemahq/xema 0.52.0 → 0.54.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 (50) 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 +1013 -870
  13. package/dist/edition/verify-image-evidence.bundle.provenance.json +501 -495
  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/edition/README.md +54 -2
  19. package/edition/adapter-bundled-versions.mjs +2 -1
  20. package/edition/authorization-base.mjs +9 -8
  21. package/edition/biome-content.mjs +23 -29
  22. package/edition/candidate-edition.mjs +692 -0
  23. package/edition/checkout-indexed-source-repositories.mjs +6 -8
  24. package/edition/collected-refusals.mjs +59 -0
  25. package/edition/contract-authority.mjs +122 -0
  26. package/edition/cosign-certificate-constraints.mjs +18 -8
  27. package/edition/customer-edition-verbs.mjs +30 -8
  28. package/edition/develop-chart-set.mjs +7 -6
  29. package/edition/develop-edition-invariants.mjs +2 -1
  30. package/edition/develop-edition-resolve.mjs +109 -31
  31. package/edition/develop-edition.mjs +49 -8
  32. package/edition/develop-engine-image.mjs +95 -0
  33. package/edition/develop-image-probe.mjs +2 -1
  34. package/edition/develop-resolve-report.mjs +159 -0
  35. package/edition/develop-snapshot-inputs.mjs +4 -3
  36. package/edition/develop-verify-image-record.mjs +23 -4
  37. package/edition/edition-artifact.mjs +17 -7
  38. package/edition/edition-protocol.mjs +8 -2
  39. package/edition/evidence-input-mode.mjs +4 -22
  40. package/edition/images-from-record.mjs +362 -0
  41. package/edition/lib.mjs +1 -19
  42. package/edition/locked-bundle-signer.mjs +15 -3
  43. package/edition/operation-grant-admission.mjs +51 -9
  44. package/edition/operation-requirements.mjs +11 -0
  45. package/edition/platform-service-class.mjs +4 -2
  46. package/edition/resolve-distribution.mjs +95 -82
  47. package/edition/resolve-images.mjs +12 -5
  48. package/edition/shallow-checkout.mjs +18 -0
  49. package/edition/workspace-sections.mjs +23 -3
  50. package/package.json +7 -3
@@ -0,0 +1,362 @@
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 CLI-owned and versioned here, behind this one module. It is a generic image-evidence concept
28
+ // that belongs in @xemahq/contracts (beside BuildRecordSchema); moving it there changes only `parseImageBuildRecord`.
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, isDevelopImageRef } 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
+ /** The one schema version of the image build record this CLI reads. */
45
+ export const IMAGE_BUILD_RECORD_SCHEMA_VERSION = 1;
46
+
47
+ /** Which list of the record an entry came from: how far it is trusted. */
48
+ export const RecordedImageOrigin = Object.freeze({
49
+ Built: 'images',
50
+ Satellite: 'satellites',
51
+ });
52
+
53
+ /** The one record list each declared image origin admits. */
54
+ const LIST_OF_ORIGIN = Object.freeze({
55
+ [FirstPartyImageOrigin.Workspace]: RecordedImageOrigin.Built,
56
+ [FirstPartyImageOrigin.Satellite]: RecordedImageOrigin.Satellite,
57
+ });
58
+
59
+ const RECORD_KEYS = Object.freeze(['schemaVersion', ...Object.values(RecordedImageOrigin)]);
60
+ const ENTRY_KEYS = Object.freeze(['subject', 'repository', 'contentHash', 'digest', 'platforms', 'sourceSha']);
61
+ const PLATFORM_REQUIRED_KEYS = Object.freeze(['os', 'architecture']);
62
+ const PLATFORM_KEYS = Object.freeze([...PLATFORM_REQUIRED_KEYS, 'variant']);
63
+
64
+ const SUBJECT = /^[a-z][a-z0-9-]*:[a-z0-9][a-z0-9._-]*$/;
65
+ const REPOSITORY = /^[a-z0-9.-]+(?::[1-9]\d{0,4})?\/(?:[a-z0-9._-]+\/)*[a-z0-9._-]+$/;
66
+ const CONTENT_HASH = /^[0-9a-f]{64}$/;
67
+ const DIGEST = /^sha256:[0-9a-f]{64}$/;
68
+ const SOURCE_SHA = /^[0-9a-f]{40}$/;
69
+
70
+ /** A build record that is malformed, or does not cover the edition exactly. */
71
+ export class ImageBuildRecordError extends RefusalError {
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = 'ImageBuildRecordError';
75
+ }
76
+ }
77
+
78
+ /** The subject key the image lock, the probe and the seal use. */
79
+ export const subjectKeyOf = (subject) => `${subject.kind}:${subject.id}`;
80
+
81
+ const platformKey = (platform) => [platform.os, platform.architecture, platform.variant ?? ''].join('/');
82
+
83
+ function isPlainObject(value) {
84
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
85
+ }
86
+
87
+ function refuseUnknownKeys(value, allowed, where, problems) {
88
+ for (const key of Object.keys(value)) {
89
+ if (!allowed.includes(key)) {problems.push(`${where}: unknown key ${JSON.stringify(key)}`);}
90
+ }
91
+ }
92
+
93
+ function checkText(value, pattern, where, problems) {
94
+ if (typeof value !== 'string' || !pattern.test(value)) {problems.push(`${where} must match ${pattern}; got ${JSON.stringify(value)}`);}
95
+ }
96
+
97
+ function checkPlatforms(platforms, where, problems) {
98
+ if (!Array.isArray(platforms) || platforms.length === 0) {
99
+ problems.push(`${where} must be a non-empty array of {os, architecture[, variant]}`);
100
+ return;
101
+ }
102
+ const seen = new Set();
103
+ platforms.forEach((platform, index) => {
104
+ const at = `${where}[${index}]`;
105
+ if (!isPlainObject(platform)) {
106
+ problems.push(`${at} must be an object`);
107
+ return;
108
+ }
109
+ refuseUnknownKeys(platform, PLATFORM_KEYS, at, problems);
110
+ for (const key of Object.keys(platform).filter((name) => PLATFORM_KEYS.includes(name))) {
111
+ if (typeof platform[key] !== 'string' || platform[key].length === 0) {problems.push(`${at}.${key} must be a non-empty string`);}
112
+ }
113
+ for (const key of PLATFORM_REQUIRED_KEYS) {
114
+ if (!Object.hasOwn(platform, key)) {problems.push(`${at} has no ${key}`);}
115
+ }
116
+ const key = platformKey(platform);
117
+ if (seen.has(key)) {problems.push(`${at} repeats platform ${key}`);}
118
+ seen.add(key);
119
+ });
120
+ }
121
+
122
+ function checkEntry(entry, where, problems) {
123
+ if (!isPlainObject(entry)) {
124
+ problems.push(`${where} must be an object`);
125
+ return;
126
+ }
127
+ refuseUnknownKeys(entry, ENTRY_KEYS, where, problems);
128
+ for (const key of ENTRY_KEYS) {
129
+ if (!Object.hasOwn(entry, key)) {problems.push(`${where} has no ${key}`);}
130
+ }
131
+ checkText(entry.subject, SUBJECT, `${where}.subject`, problems);
132
+ checkText(entry.repository, REPOSITORY, `${where}.repository`, problems);
133
+ if (typeof entry.repository === 'string' && REPOSITORY.test(entry.repository) && !isDevelopImageRef(entry.repository)) {
134
+ problems.push(`${where}.repository ${entry.repository} is not a develop repository`);
135
+ }
136
+ checkText(entry.contentHash, CONTENT_HASH, `${where}.contentHash`, problems);
137
+ checkText(entry.digest, DIGEST, `${where}.digest`, problems);
138
+ checkText(entry.sourceSha, SOURCE_SHA, `${where}.sourceSha`, problems);
139
+ checkPlatforms(entry.platforms, `${where}.platforms`, problems);
140
+ }
141
+
142
+ /**
143
+ * Parse an image build record: `{schemaVersion: 1, images: [entry], satellites: [entry]}`, every list sorted by
144
+ * subject, no subject in two entries, and no unknown key anywhere. Every problem is named in one refusal.
145
+ *
146
+ * @param {unknown} document
147
+ * @param {string} source where the record came from (named in the refusal)
148
+ * @returns {{schemaVersion: 1, images: object[], satellites: object[]}}
149
+ */
150
+ export function parseImageBuildRecord(document, source) {
151
+ const problems = [];
152
+ if (!isPlainObject(document)) {throw new ImageBuildRecordError(`image build record ${source} must be a JSON object`);}
153
+ refuseUnknownKeys(document, RECORD_KEYS, 'record', problems);
154
+ if (document.schemaVersion !== IMAGE_BUILD_RECORD_SCHEMA_VERSION) {
155
+ problems.push(`record.schemaVersion must be ${IMAGE_BUILD_RECORD_SCHEMA_VERSION}; got ${JSON.stringify(document.schemaVersion)}`);
156
+ }
157
+ const owner = new Map();
158
+ for (const origin of Object.values(RecordedImageOrigin)) {
159
+ const entries = document[origin];
160
+ if (!Array.isArray(entries)) {
161
+ problems.push(`record.${origin} must be an array`);
162
+ continue;
163
+ }
164
+ entries.forEach((entry, index) => {
165
+ const where = `record.${origin}[${index}]`;
166
+ checkEntry(entry, where, problems);
167
+ const subject = entry?.subject;
168
+ if (typeof subject !== 'string') {return;}
169
+ const previous = entries[index - 1]?.subject;
170
+ if (typeof previous === 'string' && previous >= subject && previous !== subject) {
171
+ problems.push(`${where}: record.${origin} must be sorted by subject ("${subject}" follows "${previous}")`);
172
+ }
173
+ if (owner.has(subject)) {problems.push(`${where}: subject ${subject} is already recorded at ${owner.get(subject)}`);}
174
+ else {owner.set(subject, where);}
175
+ });
176
+ }
177
+ if (problems.length > 0) {
178
+ throw new ImageBuildRecordError(`image build record ${source} is refused:\n ${problems.join('\n ')}`);
179
+ }
180
+ return document;
181
+ }
182
+
183
+ /**
184
+ * sha256 (hex) of the parsed record's canonical JSON (canonical-evidence-json.mjs: keys sorted), every list in its
185
+ * canonical order: entries by subject (parse enforces it) and each entry's platforms by os/architecture/variant. It is
186
+ * the `imageBuildRecord` input of the develop edition's target tag (snapshotEditionTag).
187
+ */
188
+ export function imageBuildRecordDigest(record) {
189
+ const canonicalEntry = (entry) => ({ ...entry, platforms: [...entry.platforms].sort((a, b) => compareStrings(platformKey(a), platformKey(b))) });
190
+ return sha256Hex(canonicalJson({
191
+ schemaVersion: record.schemaVersion,
192
+ ...Object.fromEntries(Object.values(RecordedImageOrigin).map((origin) => [origin, record[origin].map(canonicalEntry)])),
193
+ }));
194
+ }
195
+
196
+ /** Read and parse the record file `--images-from` names (duplicate JSON keys refused). */
197
+ export function readImageBuildRecord(path) {
198
+ return parseImageBuildRecord(readStrictJsonFile(path), path);
199
+ }
200
+
201
+ /**
202
+ * The record as `resolveImages` consumes it (`recordedImages`): `take` gives each first-party subject its recorded
203
+ * content hash and source commit, and `settle` refuses, all at once, every subject the record does not cover exactly.
204
+ *
205
+ * Each subject's entry must sit in the list its source's declared `imageOrigin` names (LIST_OF_ORIGIN); an entry in the
206
+ * other list, or a source with no declared origin, is one more problem `settle` names.
207
+ *
208
+ * @param {{images: object[], satellites: object[]}} record a parsed record
209
+ * @param {{distributionId: string, declaredSources?: object}} context `declaredSources` is `firstPartySources` (tests pass it)
210
+ */
211
+ export function createRecordedImages(record, { distributionId, declaredSources = firstPartySources('checking each build record entry against its source\'s declared image origin') }) {
212
+ const entries = new Map();
213
+ for (const origin of Object.values(RecordedImageOrigin)) {
214
+ for (const entry of record[origin]) {entries.set(entry.subject, { origin, entry });}
215
+ }
216
+ const taken = new Map();
217
+ const problems = [];
218
+ let settled = false;
219
+
220
+ return {
221
+ take(subject) {
222
+ const key = subjectKeyOf(subject);
223
+ const recorded = entries.get(key);
224
+ if (recorded === undefined) {
225
+ problems.push(`${key} (${subject.repo}@${subject.sourceCommit}) is not in the record`);
226
+ return { sourceCommit: subject.sourceCommit, contentHash: null };
227
+ }
228
+ if (taken.has(key)) {throw new Error(`images-from-record: subject ${key} was taken twice`);}
229
+ const { entry, origin } = recorded;
230
+ const listProblem = recordListProblem(key, subject, origin, declaredSources);
231
+ if (listProblem !== null) {problems.push(listProblem);}
232
+ if (entry.sourceSha !== subject.sourceCommit) {
233
+ problems.push(`${key}: the record's sourceSha ${entry.sourceSha} is not ${subject.sourceCommit}, the commit the snapshot pins for ${subject.repo}`);
234
+ }
235
+ const repository = developImageRef(subject.imageRef);
236
+ if (entry.repository !== repository) {
237
+ problems.push(`${key}: the record's repository ${entry.repository} is not its develop repository ${repository}`);
238
+ }
239
+ const recordedPlatforms = new Set(entry.platforms.map(platformKey));
240
+ const absent = subject.requiredPlatforms.map(platformKey).filter((platform) => !recordedPlatforms.has(platform));
241
+ if (absent.length > 0) {problems.push(`${key}: the record's platforms lack the required ${absent.join(', ')}`);}
242
+ taken.set(key, { subject, ...recorded });
243
+ return { sourceCommit: entry.sourceSha, contentHash: entry.contentHash };
244
+ },
245
+ settle() {
246
+ settled = true;
247
+ 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}"`);
248
+ const all = [...problems, ...extra];
249
+ if (all.length > 0) {
250
+ throw new ImageBuildRecordError(`the image build record does not cover "${distributionId}" exactly (${all.length} problem(s); no probe fallback):\n ${all.join('\n ')}`);
251
+ }
252
+ },
253
+ /** The satellite entries taken, each with the subject it covers, for the cosign verify. */
254
+ satelliteClaims() {
255
+ if (!settled) {throw new Error('images-from-record: satellite claims are read only after settle()');}
256
+ return [...taken.values()].filter(({ origin }) => origin === RecordedImageOrigin.Satellite).map(({ subject, entry }) => ({ subject, entry }));
257
+ },
258
+ /** The recorded entry of a subject, or undefined for a subject the record does not cover (a third-party one). */
259
+ entryOf(subject) {
260
+ return taken.get(subjectKeyOf(subject))?.entry;
261
+ },
262
+ };
263
+ }
264
+
265
+ /** Why the record list `origin` may not carry the subject's entry, or null when its source's declared origin names it. */
266
+ function recordListProblem(key, subject, origin, declaredSources) {
267
+ if (!Object.hasOwn(declaredSources, subject.repo)) {
268
+ return `${key} (record.${origin}): ${subject.repo} is not a declared first-party source`;
269
+ }
270
+ const declared = declaredSources[subject.repo].imageOrigin;
271
+ if (declared === undefined) {
272
+ 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`;
273
+ }
274
+ const wanted = LIST_OF_ORIGIN[declared];
275
+ if (origin !== wanted) {
276
+ return `${key} is in record.${origin}, but firstPartySources["${subject.repo}"].imageOrigin is "${declared}": its entry belongs in record.${wanted}`;
277
+ }
278
+ return null;
279
+ }
280
+
281
+ /** The `owner/name` a satellite subject's develop identity is bound to: its repository's declared URL. */
282
+ export function satelliteProducerOf(subject) {
283
+ const sources = firstPartySources('naming a satellite producer');
284
+ if (!Object.hasOwn(sources, subject.repo)) {
285
+ throw new ImageBuildRecordError(`${subjectKeyOf(subject)}: ${subject.repo} is not a declared first-party source; a satellite producer is never named by the record`);
286
+ }
287
+ const context = `firstPartySources["${subject.repo}"].repositoryUrl`;
288
+ return repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(sources[subject.repo].repositoryUrl, context), context);
289
+ }
290
+
291
+ /**
292
+ * The cosign verifier of satellite claims: for each, the producer's newest verified develop ci.yaml build record
293
+ * (develop-record-verify.mjs: keyless, offline against the pinned trusted root, issuer
294
+ * https://token.actions.githubusercontent.com, identity the data root's record workflow at refs/heads/develop, called by
295
+ * the producer) whose commit is the claim's sourceSha and which lists its repository, digest and content hash. Every
296
+ * refused claim is named at once; a registry or cosign that could not answer is a plain Error. The registry is read
297
+ * for the records and signatures only: no digest is discovered and nothing is rehashed.
298
+ *
299
+ * @param {object} request
300
+ * @param {string} request.registry the registry root that holds `build-records/<repo>`
301
+ * @param {string} request.cosignBinary absolute path of cosign
302
+ * @param {Function} [request.withVerifier] the pinned record verifier scope (tests replace it, to run offline)
303
+ * @param {Function} [request.findRecord] the newest-first verified record walk (tests replace it)
304
+ * @returns {(claims: {subject: object, entry: object}[]) => Promise<void>}
305
+ */
306
+ export function createSatelliteVerifier({ registry, cosignBinary, env = process.env, withVerifier = withRecordVerifier, findRecord = findVerifiedProducerImageRecord, ...verifierInjections }) {
307
+ return async (claims) => {
308
+ if (claims.length === 0) {return;}
309
+ await withVerifier({ cosignBinary, env, ...verifierInjections }, async (verifier) => {
310
+ const refused = [];
311
+ for (const { subject, entry } of claims) {
312
+ try {
313
+ await findRecord({
314
+ ...verifier,
315
+ registry,
316
+ producer: satelliteProducerOf(subject),
317
+ wanted: `at ${entry.sourceSha} lists ${entry.repository}@${entry.digest} with content hash ${entry.contentHash}`,
318
+ matches: (record) =>
319
+ record.commit === entry.sourceSha &&
320
+ record.images.some((listed) => listed.name === entry.repository && listed.digest === entry.digest && listed.contentHash === entry.contentHash),
321
+ });
322
+ } catch (error) {
323
+ if (!(error instanceof RecordRefused)) {throw error;}
324
+ refused.push(`${subjectKeyOf(subject)}: ${error.message}`);
325
+ }
326
+ }
327
+ if (refused.length > 0) {
328
+ throw new ImageBuildRecordError(`${refused.length} satellite image(s) of the build record are not verified:\n ${refused.join('\n ')}`);
329
+ }
330
+ });
331
+ };
332
+ }
333
+
334
+ /**
335
+ * The develop images lock of a snapshot from the record: the subjects as `resolveImages` structures them, each
336
+ * first-party one with the record's content hash and source commit, coverage settled, satellites verified.
337
+ *
338
+ * @returns {Promise<{imageLock: object, recorded: ReturnType<typeof createRecordedImages>}>}
339
+ */
340
+ export async function resolveImagesFromRecord({ id, inputs, lock, record, resolveImages, verifySatellites }) {
341
+ const recorded = createRecordedImages(record, { distributionId: id });
342
+ const { imageLock } = await resolveImages(id, { inputs, lock, recordedImages: recorded });
343
+ await verifySatellites(recorded.satelliteClaims());
344
+ return { imageLock, recorded };
345
+ }
346
+
347
+ /**
348
+ * Seal the lock from the record: each recorded subject takes the record's develop repository and digest, with no
349
+ * registry read. A third-party subject (the only kind the record does not cover) keeps its pinned seal.
350
+ */
351
+ export async function sealImagesFromRecord({ imageLock, recorded, thirdPartyPins, ...sealOptions }) {
352
+ const unrecorded = imageLock.subjects.filter((subject) => recorded.entryOf(subject) === undefined);
353
+ const pinned = unrecorded.length === 0 ? [] : await sealImageSubjects({ channel: EditionChannel.Develop, distributionId: imageLock.distributionId, subjects: unrecorded, thirdPartyPins, ...sealOptions });
354
+ const pinnedByKey = new Map(pinned.map((subject) => [subjectKeyOf(subject), subject]));
355
+ const subjects = imageLock.subjects.map((subject) => {
356
+ const entry = recorded.entryOf(subject);
357
+ if (entry === undefined) {return pinnedByKey.get(subjectKeyOf(subject));}
358
+ return { ...subject, imageRef: entry.repository, imageDigest: entry.digest };
359
+ });
360
+ const schemas = await loadKernelDistributionSchemas();
361
+ return validateImmutableImageLock(schemas.parseImageLock({ ...imageLock, subjects }), schemas);
362
+ }
package/edition/lib.mjs CHANGED
@@ -1981,6 +1981,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1981
1981
  logicalName: requirement.logicalName,
1982
1982
  scope: requirement.scope,
1983
1983
  inclusion: requirement.inclusion,
1984
+ ...(requirement.sharing === undefined ? {} : { sharing: requirement.sharing }),
1984
1985
  });
1985
1986
  }
1986
1987
  for (const provision of provisions) {
@@ -2097,25 +2098,6 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
2097
2098
  providesResources.sort(
2098
2099
  (a, b) => compareStrings(a.service, b.service) || compareStrings(a.kind, b.kind),
2099
2100
  );
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
2101
  // The two arrays partition the biome's images — a name in both would be
2120
2102
  // simultaneously rostered and unschedulable.
2121
2103
  for (const name of runtimeComponents) {
@@ -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
+ }
@@ -60,6 +60,7 @@ function loadKernelAudienceGrammars() {
60
60
  kernelAudienceGrammars = {
61
61
  ExactAudienceSchema: elementSchema('requiresAudiences'),
62
62
  AudienceNamespaceSchema: elementSchema('ownsAudienceNamespaces'),
63
+ PeerServiceNameSchema: elementSchema('optionalPeers'),
63
64
  };
64
65
  return kernelAudienceGrammars;
65
66
  }
@@ -162,6 +163,16 @@ export function componentOwnedAudienceNamespaces(component, where) {
162
163
  return declaredPrivilegeList(component, where, 'ownsAudienceNamespaces', AudienceNamespaceSchema, 'audience namespace');
163
164
  }
164
165
 
166
+ /**
167
+ * The peer services one platform service uses only when they are served
168
+ * (`optionalPeers`, edition protocol v38), each graded by the kernel's peer
169
+ * service name grammar. Declared INTENT only; `null` when undeclared.
170
+ */
171
+ export function componentOptionalPeers(component, where) {
172
+ const { PeerServiceNameSchema } = loadKernelAudienceGrammars();
173
+ return declaredPrivilegeList(component, where, 'optionalPeers', PeerServiceNameSchema, 'peer service name');
174
+ }
175
+
165
176
  /**
166
177
  * The event subscription clauses one workload component declares
167
178
  * (`subscribesEvents`), each graded by the kernel's clause grammar and returned
@@ -1,5 +1,5 @@
1
1
  import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
2
- import { componentAudienceRequirements, componentOperationRequirements, componentOwnedAudienceNamespaces } from './operation-requirements.mjs';
2
+ import { componentAudienceRequirements, componentOperationRequirements, componentOptionalPeers, componentOwnedAudienceNamespaces } from './operation-requirements.mjs';
3
3
  import { compareStrings } from './string-order.mjs';
4
4
 
5
5
  /**
@@ -58,7 +58,7 @@ export function sourceOwnedCapabilityDomains(sourceManifest, where) {
58
58
  /**
59
59
  * What a platform service declares it requires and owns, as the index carries it, read from the
60
60
  * same declaration as {@link sourceOwnedCapabilityDomains} (the `xema` block of its source manifest):
61
- * `requiresOperations`, `requiresAudiences` and `ownsAudienceNamespaces`. Each is sorted, unique and
61
+ * `requiresOperations`, `requiresAudiences`, `ownsAudienceNamespaces` and `optionalPeers`. Each is sorted, unique and
62
62
  * non-empty, or no key at all. Declared INTENT: the lock admits or refuses each
63
63
  * (`admitOperationGrants`, `admitExactAudiences`).
64
64
  *
@@ -70,10 +70,12 @@ export function sourceDeclaredRequirements(sourceManifest, where) {
70
70
  const requiresOperations = componentOperationRequirements(xema, where);
71
71
  const requiresAudiences = componentAudienceRequirements(xema, where);
72
72
  const ownsAudienceNamespaces = componentOwnedAudienceNamespaces(xema, where);
73
+ const optionalPeers = componentOptionalPeers(xema, where);
73
74
  return {
74
75
  ...(requiresOperations === null ? {} : { requiresOperations }),
75
76
  ...(requiresAudiences === null ? {} : { requiresAudiences }),
76
77
  ...(ownsAudienceNamespaces === null ? {} : { ownsAudienceNamespaces }),
78
+ ...(optionalPeers === null ? {} : { optionalPeers }),
77
79
  };
78
80
  }
79
81