@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
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { execFileSync } from 'node:child_process';
19
19
 
20
+ import { RefusalError } from './collected-refusals.mjs';
20
21
  import { dataRoot, deployRoot, editionsRoot, kernelRepoDir, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
21
22
  import { buildIndexesAtPins } from './indexes-at-pins.mjs';
22
23
  import {
@@ -45,14 +46,14 @@ function git(dir, args) {
45
46
  */
46
47
  export function assertFrozenCheckout({ dir, pins, key }) {
47
48
  const pinned = pins[key];
48
- if (pinned === undefined) {throw new Error(`the snapshot has no pin "${key}" for ${dir}`);}
49
+ if (pinned === undefined) {throw new RefusalError(`the snapshot has no pin "${key}" for ${dir}`);}
49
50
  const head = git(dir, ['rev-parse', 'HEAD']);
50
51
  if (head !== pinned) {
51
- throw new Error(`${dir} is at ${head}, but the snapshot pins "${key}" at ${pinned}`);
52
+ throw new RefusalError(`${dir} is at ${head}, but the snapshot pins "${key}" at ${pinned}`);
52
53
  }
53
54
  const dirty = git(dir, ['status', '--porcelain']);
54
55
  if (dirty.length > 0) {
55
- throw new Error(`${dir} is not clean, so it is not the frozen commit ${pinned} ("${key}"):\n${dirty}`);
56
+ throw new RefusalError(`${dir} is not clean, so it is not the frozen commit ${pinned} ("${key}"):\n${dirty}`);
56
57
  }
57
58
  }
58
59
 
@@ -58,8 +58,28 @@ function producerMember(producer) {
58
58
  * @param {string} request.registry the registry root that holds `build-records/<repo>`
59
59
  * @returns {Promise<{image: string, digest: string, producer: string, commit: string}>}
60
60
  */
61
- export async function verifyImageRecord({ image, producer, registry, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) {
61
+ export async function verifyImageRecord({ image, producer, ...verifier }) {
62
62
  const { name, digest } = parseImageReference(image);
63
+ const record = await findVerifiedProducerImageRecord({
64
+ producer,
65
+ ...verifier,
66
+ wanted: `lists ${name}@${digest}`,
67
+ matches: (candidate) => candidate.images.some((listed) => listed.name === name && listed.digest === digest),
68
+ });
69
+ return { image: name, digest, producer, commit: record.commit };
70
+ }
71
+
72
+ /**
73
+ * The newest verified develop ci.yaml record of `producer` (newest run first) for which `matches(record)` holds.
74
+ * Every record read on the way is cosign-verified, so a newer bad record refuses before an older one is read.
75
+ * Refused, naming `wanted`, when no verified ci.yaml record matches.
76
+ *
77
+ * @param {object} request
78
+ * @param {string} request.producer `<owner>/<repo>`
79
+ * @param {(record: object) => boolean} request.matches
80
+ * @param {string} request.wanted what a match is, for the refusal ("lists <name>@<digest>")
81
+ */
82
+ export async function findVerifiedProducerImageRecord({ producer, matches, wanted, registry, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) {
63
83
  const member = producerMember(producer);
64
84
  const branch = VERIFY_IMAGE_RECORD_BRANCH;
65
85
  const { repository, authority, client, runs } = await listBuildRecordRuns({ kernel, clientFor, registry, member, branch });
@@ -69,10 +89,9 @@ export async function verifyImageRecord({ image, producer, registry, dataRootRep
69
89
  repository, member, branch, dataRootRepositoryUrl, tag,
70
90
  });
71
91
  if (record.producer.workflow !== PRODUCER_IMAGE_WORKFLOW) {continue;}
72
- if (!record.images.some((listed) => listed.name === name && listed.digest === digest)) {continue;}
73
- return { image: name, digest, producer, commit: record.commit };
92
+ if (matches(record)) {return record;}
74
93
  }
75
- throw new RecordRefused(producer, `no verified ${PRODUCER_IMAGE_WORKFLOW} build record of ${repository} for branch ${branch} lists ${name}@${digest}`);
94
+ throw new RecordRefused(producer, `no verified ${PRODUCER_IMAGE_WORKFLOW} build record of ${repository} for branch ${branch} ${wanted}`);
76
95
  }
77
96
 
78
97
  /** The `verify-image-record` verb. */
@@ -50,6 +50,7 @@ import { assertKernelEditionProtocol } from './edition-protocol.mjs';
50
50
  import { describeEditionHolds } from './edition-source-pins.mjs';
51
51
  import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, kernelPackageDir, platformServiceIndexPath, runtimeImageSourceIndexPath, workspaceArgv } from './edition-workspace.mjs';
52
52
  import { checkEvidenceTrustPolicy } from './evidence-trust-policy.mjs';
53
+ import { imageSubjectIdentity } from './image-subject-identity.mjs';
53
54
  import {
54
55
  compareIndexEntries,
55
56
  comparableDistributionLock,
@@ -58,6 +59,7 @@ import {
58
59
  loadKernelDistributionSchemas,
59
60
  parseResolvedDistributionLock,
60
61
  serializeJson,
62
+ sha256Hex,
61
63
  validateImmutableBiomeIndex,
62
64
  validateImmutableDistributionLock,
63
65
  validateImmutableImageLock,
@@ -69,6 +71,8 @@ import {
69
71
  resolveDistributionDeclaredSources,
70
72
  resolveDistributionRequiredPlatforms,
71
73
  externalSourceTrees,
74
+ carryComposedForwardStructurally,
75
+ readCommittedPreviousLock,
72
76
  resolveImages,
73
77
  resolveComposedProcessStructure,
74
78
  resolveImageStructure,
@@ -237,6 +241,24 @@ async function checkAuthorizationLock(id, kernel, index) {
237
241
  * with the same structure ship the same images and bundle the same web biomes,
238
242
  * regardless of current source content (which the build re-hashes anyway).
239
243
  */
244
+ /**
245
+ * The committed lock with a stand-in `imageDigest` on every UNSEALED composed artifact, so the kernel can carry it forward.
246
+ * The kernel's v5 parse refuses two composed artifacts sharing one imageDigest, so each stand-in is unique to its subject:
247
+ * the sha256 of its identity (`imageSubjectIdentity`: `composed-process:<imageRef>:<contentHash>`, unique per lock).
248
+ * It equals a real manifest digest only by a sha256 preimage, and it never reaches a verdict: both sides of every
249
+ * comparison drop `imageDigest` (`withoutSealedDigests`).
250
+ */
251
+ export function withStandInDigests(lock, schemas) {
252
+ return {
253
+ ...lock,
254
+ subjects: lock.subjects.map((subject) =>
255
+ subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess && subject.imageDigest === undefined
256
+ ? { ...subject, imageDigest: `sha256:${sha256Hex(imageSubjectIdentity(subject))}` }
257
+ : subject,
258
+ ),
259
+ };
260
+ }
261
+
240
262
  function imageStructure(lock) {
241
263
  return serializeJson({
242
264
  distributionId: lock.distributionId,
@@ -315,12 +337,6 @@ export async function checkImagesLock(id, checkSourceEvidence) {
315
337
  schemas,
316
338
  requiredPlatforms,
317
339
  );
318
- const fresh = {
319
- distributionId: id,
320
- contractAuthority: runtimeSourceIndex.contractAuthority,
321
- ...structure,
322
- subjects: structureSubjects(structure),
323
- };
324
340
  if (!existsSync(imageLockPath)) {
325
341
  console.error(
326
342
  `[dist:check] ${id}: images.lock.json is missing. Run \`${commandFor('resolve-images.mjs', id)}\`.`,
@@ -328,9 +344,29 @@ export async function checkImagesLock(id, checkSourceEvidence) {
328
344
  return false;
329
345
  }
330
346
  const committed = validateImmutableImageLock(
331
- schemas.parseImageLock(JSON.parse(readFileSync(imageLockPath, 'utf8'))),
347
+ (await readCommittedPreviousLock(id, imageLockPath)),
332
348
  schemas,
333
349
  );
350
+ // The committed lock is the previous lock of the fresh resolve, so the fresh view carries the composed artifacts forward
351
+ // exactly as a reseal does (generation, member keys, one artifact per live generation). Its digests are compared away
352
+ // below, so a committed lock that predates sealing is given one stand-in digest per subject to be carried.
353
+ const previousLock = withStandInDigests(committed, schemas);
354
+ let freshSubjects;
355
+ try {
356
+ freshSubjects = carryComposedForwardStructurally({ subjects: structureSubjects(structure), previousLock, schemas });
357
+ } catch (error) {
358
+ console.error(
359
+ `[dist:check] ${id}: images.lock.json is structurally out of date (its composed artifacts do not cover the declared composed processes: ${error.message}). ` +
360
+ `Run \`${commandFor('resolve-images.mjs', id)}\`.`,
361
+ );
362
+ return false;
363
+ }
364
+ const fresh = {
365
+ distributionId: id,
366
+ contractAuthority: runtimeSourceIndex.contractAuthority,
367
+ ...structure,
368
+ subjects: freshSubjects,
369
+ };
334
370
  if (
335
371
  serializeJson(committed.contractAuthority) !==
336
372
  serializeJson(runtimeSourceIndex.contractAuthority)
@@ -350,12 +386,12 @@ export async function checkImagesLock(id, checkSourceEvidence) {
350
386
  return false;
351
387
  }
352
388
  if (checkSourceEvidence) {
353
- const { imageLock: sourceResolved } = await resolveImages(id);
389
+ const { imageLock: sourceResolved } = await resolveImages(id, { previousLock });
354
390
  // The same subtraction as the structural view above: the source resolve
355
391
  // carries no digest, and the committed lock may.
356
392
  if (
357
393
  normalize(withoutSealedDigests(committed)) !==
358
- normalize(sourceResolved)
394
+ normalize(withoutSealedDigests(sourceResolved))
359
395
  ) {
360
396
  console.error(
361
397
  `[dist:check] ${id}: images.lock.json source content hashes are out of date. ` +
@@ -56,6 +56,7 @@ import {
56
56
  sha256Digest,
57
57
  sourceIdentity,
58
58
  } from './image-evidence.mjs';
59
+ import { completenessImageKey } from './image-subject-identity.mjs';
59
60
  import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
60
61
  import { compareStrings } from './lib.mjs';
61
62
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
@@ -133,7 +134,7 @@ export { compareCompletenessImages as compareEvidenceSubjects };
133
134
  * Keyed by the same `kind\0id` identity `image-evidence.mjs` uses everywhere
134
135
  * else, so two subjects can never collide and one subject can never move.
135
136
  */
136
- function subjectStem(subject) {
137
+ export function subjectStem(subject) {
137
138
  return createHash('sha256')
138
139
  .update(sourceIdentity(subject))
139
140
  .digest('hex');
@@ -315,10 +316,9 @@ function indexOneEvidenceDirectory({
315
316
  });
316
317
  continue;
317
318
  }
318
- const subjectKey = sourceIdentity({
319
- kind: entry.subjectKind,
320
- id: entry.subjectId,
321
- });
319
+ // Only named in a defect: the record is indexed by the statement's own source coordinate, which already
320
+ // tells the generations of a composed artifact apart (its sourceTreeHash).
321
+ const subjectKey = `${entry.subjectKind}:${entry.subjectId}`;
322
322
  const descriptor = entry.evidence;
323
323
  let statementBytes;
324
324
  let bundleBytes;
@@ -691,12 +691,17 @@ export function buildDistributionImagesStatement({
691
691
  // Refusing here as well means an incomplete set can never be SIGNED, rather
692
692
  // than being signed and rejected days later at a customer install.
693
693
  const expectedIdentities = imageLock.subjects
694
- .map((subject) => sourceIdentity(subject))
694
+ .map((subject) =>
695
+ completenessImageKey({
696
+ subjectKind: subject.kind,
697
+ subjectId: subject.id,
698
+ imageRepository: subject.imageRef,
699
+ manifestDigest: subject.imageDigest,
700
+ }),
701
+ )
695
702
  .sort(compareStrings);
696
703
  const actualIdentities = images
697
- .map((image) =>
698
- sourceIdentity({ kind: image.subjectKind, id: image.subjectId }),
699
- )
704
+ .map((image) => completenessImageKey(image))
700
705
  .sort(compareStrings);
701
706
  if (
702
707
  new Set(actualIdentities).size !== actualIdentities.length ||
@@ -290,7 +290,7 @@ export function canonicalSourceImages(sourceImages = new Map()) {
290
290
  }
291
291
 
292
292
  /**
293
- * The tag of an edition resolved from a snapshot (a develop edition, a customer release edition): sha256 of the canonical `{ cli, engine, pins, sourceImages }`. Every input of
293
+ * The tag of an edition resolved from a snapshot (a develop edition, a customer release edition): sha256 of the canonical `{ cli, engine, pins, sourceImages }`, plus `imageBuildRecord` (the record's digest) for a develop resolve --images-from. Every input of
294
294
  * the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins: a vendor edition's
295
295
  * commits fix its definitions; a customer edition's pins carry its sources' commits, the catalogue digest and the
296
296
  * digest of its canonical definition (`definition`: vendor selection, image registry mirror, Store publisher anchors); the
@@ -300,25 +300,35 @@ export function canonicalSourceImages(sourceImages = new Map()) {
300
300
  * only refuse the existing one (`check-existing`) or reuse a stale one. `provenance.workflow` (the run that
301
301
  * wrote the bytes) is the one recorded field outside it: a fact about the artifact, not an input to it.
302
302
  */
303
- export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), version = cliVersion() }) {
303
+ export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), imageBuildRecordDigest, version = cliVersion() }) {
304
304
  return sha256Hex(
305
305
  serializeJson({
306
306
  cli: version,
307
307
  engine: assertEngineImage(engineImage),
308
308
  pins: sortedSnapshotPins(pins),
309
309
  sourceImages: canonicalSourceImages(sourceImages),
310
+ // A develop resolve from an image build record (--images-from): the record fixes the image rows, and a satellite
311
+ // digest changes independently of the pins. Absent on the probe path, whose tag inputs are unchanged.
312
+ ...(imageBuildRecordDigest === undefined ? {} : { imageBuildRecord: assertImageBuildRecordDigest(imageBuildRecordDigest) }),
310
313
  }),
311
314
  );
312
315
  }
313
316
 
317
+ function assertImageBuildRecordDigest(digest) {
318
+ if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) {
319
+ throw new Error(`an image build record digest must be 64 lowercase hex; got ${JSON.stringify(digest)}`);
320
+ }
321
+ return digest;
322
+ }
323
+
314
324
  /** The target of an edition resolved from a snapshot of pins, on either channel: the tag is `snapshotEditionTag`. */
315
- export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages }) {
316
- return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages }));
325
+ export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest }) {
326
+ return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages, imageBuildRecordDigest }));
317
327
  }
318
328
 
319
- /** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine and the source images. */
320
- export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages }) {
321
- return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages });
329
+ /** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine, the source images and any image build record. */
330
+ export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest }) {
331
+ return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest });
322
332
  }
323
333
 
324
334
  function realpathOfPlanned(path) {
@@ -19,6 +19,7 @@ import { developImageRef } from './develop-image-coordinate.mjs';
19
19
  import { notBuiltYetError } from './develop-image-probe.mjs';
20
20
  import { EditionChannel } from './edition-channel.mjs';
21
21
  import { editionsRoot } from './edition-workspace.mjs';
22
+ import { imageSubjectIdentity } from './image-subject-identity.mjs';
22
23
  import { validateImmutableImageLock, loadKernelDistributionSchemas, } from './lib.mjs';
23
24
  import { isNotPublishedYet, planDigestSeal } from './seal-image-digests.mjs';
24
25
  import { imageSubjectKey, pinnedManifestDigests, thirdPartyImagePins } from './third-party-image-pins.mjs';
@@ -86,7 +87,7 @@ export async function sealImageSubjects({
86
87
  const digests = new Map([...writes, ...unchanged].map(({ key, digest }) => [key, digest]));
87
88
  return channelSubjects.map((subject) => ({
88
89
  ...subject,
89
- imageDigest: digests.get(`${subject.kind}:${subject.id}`),
90
+ imageDigest: digests.get(imageSubjectIdentity(subject)),
90
91
  }));
91
92
  }
92
93
 
@@ -26,28 +26,17 @@
26
26
 
27
27
  import { realpathSync } from 'node:fs';
28
28
 
29
+ import { RefusalError } from './collected-refusals.mjs';
29
30
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
30
31
 
31
32
  /** The edition protocols this library interprets: version -> content digest. A protocol is listed only
32
- * while every kernel published under it exports what this library imports (`kernel-export-coverage.test.mjs`):
33
- * v27 and older kernels lack `serviceImageSubjects` (first exported by the v28 kernel), so they are not listed.
34
- * No published kernel declares v29 (0.110.2 is v28, 0.110.3 is v30, 0.111.0 is v31, XEMA-2070 kernel is v32; v33 is the kernel's
35
- * bundle-signer protocol (`bundleSigner` on every OCI bundle row); v34 is the kernel's output-mapping, JSON Pointer and
36
- * handoff-repair-turns closure additions (XEMA-2122); v35 is the kernel's independent releases on a resolved deployment (XEMA-2244);
37
- * v36 is the kernel's JobFailureReason workflow closure addition (XEMA-2282);
38
- * v37 is the kernel's DistributionLock.catalogueContentDigest and effective-timeout wording (XEMA-2254, XEMA-2282); v38 is the kernel's optionalPeers service-manifest field (XEMA-2315); v33 to v38 are not yet published: their identities and contracts are captured from the
39
- * linked kernel), so v29 is not listed either. */
33
+ * while every kernel published under it exports what this library imports (`kernel-export-coverage.test.mjs`).
34
+ * Only v45 is listed (contracts 0.119.0: image-lock v5, build record `members`, and an idempotent
35
+ * `carryForwardComposedArtifacts`). A v44 kernel's carry-forward bumps the generation on every call, so dist-check could never
36
+ * be green on its seals; v43 cannot carry build record members; v42 and older lack `DEFAULT_COMPOSED_IMAGE_CLASS` and
37
+ * `dedicatedServiceImageSubjects`. No legacy row is kept. */
40
38
  export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
41
- Object.freeze({ version: 28, contentDigest: 'f7795478b1bfe9a41bcbea9efe496011fcd209a56c90294029a08801e84cf92c' }),
42
- Object.freeze({ version: 30, contentDigest: '91e3cdff255778b5aa94b64ca9316d031be549ead2267a9a9311ff06d4f1db2c' }),
43
- Object.freeze({ version: 31, contentDigest: 'b2026847fdd202e7b9e05815cdc2c9819b60be7007c789cc5b5f291c9c682fdd' }),
44
- Object.freeze({ version: 32, contentDigest: 'a4ca824486052ca7f61b9a68f759b798defb620b6b3e9cbaab2c701bee2c6700' }),
45
- Object.freeze({ version: 33, contentDigest: 'b070713e7abeeefad6da3a9053843b0f7420a466f6d504f60aaa00ef196b2b05' }),
46
- Object.freeze({ version: 34, contentDigest: '370ceef5956d66035265ddbf7b9c46aa686cd10c1e95171c806c0d590ef5f49c' }),
47
- Object.freeze({ version: 35, contentDigest: '3e616e532c45ecbd6277dde31c0d9c12a12139abe06cc0f56329d1ffe9813f8d' }),
48
- Object.freeze({ version: 36, contentDigest: 'b6b0c8ec3fc4182c6c3b623685af6293cfed1321f8d7c1891a83652d864c3df0' }),
49
- Object.freeze({ version: 37, contentDigest: 'f10adca1ca5b6f51f3971756261032eeddd60e372ebfcff06a2a9d8a796d0fef' }),
50
- Object.freeze({ version: 38, contentDigest: 'f670cb922c989fd40ac03703a612e7a6df2937233a1a20a394a859ec835d47d4' }),
39
+ Object.freeze({ version: 45, contentDigest: '54eb2f7d40e966e6efd5bc2ef0f4751b1121786300f4c50066e8add10c3ecb6a' }),
51
40
  ]);
52
41
 
53
42
  /** Where a kernel package publishes its identity and its contract, package-relative. */
@@ -86,7 +75,7 @@ export const EditionProtocolMismatchReason = Object.freeze({
86
75
  NotOnDevelop: 'not-on-develop',
87
76
  });
88
77
 
89
- export class EditionProtocolMismatchError extends Error {
78
+ export class EditionProtocolMismatchError extends RefusalError {
90
79
  constructor(reason, message) {
91
80
  super(message);
92
81
  this.name = 'EditionProtocolMismatchError';
@@ -20,6 +20,7 @@ import { certificateConstraintArgs } from './cosign-certificate-constraints.mjs'
20
20
  import { assertCanonicalDistributionId } from './distribution-paths.mjs';
21
21
  import { effectiveRepositoryPins, manifestSourcePins } from './edition-source-pins.mjs';
22
22
  import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
23
+ import { evidenceSubjectKey } from './image-subject-identity.mjs';
23
24
  import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
24
25
  import {
25
26
  compareStrings,
@@ -165,7 +166,7 @@ export function assertProducerTtlCeiling({ issuedAt, expiresAt, maxTtlSeconds, l
165
166
  }
166
167
 
167
168
  export function sourceIdentity(subject) {
168
- return `${subject.kind}\0${subject.id}`;
169
+ return evidenceSubjectKey(subject);
169
170
  }
170
171
 
171
172
  /** Root of the committed evidence tree, in every producing repository. */
@@ -424,7 +425,10 @@ export function validateProducerStatementBinding({
424
425
  export function compareCompletenessImages(left, right) {
425
426
  return (
426
427
  compareStrings(left.subjectKind, right.subjectKind) ||
427
- compareStrings(left.subjectId, right.subjectId)
428
+ compareStrings(left.subjectId, right.subjectId) ||
429
+ // The generations of one composed artifact share kind and id: they order by image, then manifest.
430
+ compareStrings(left.imageRepository, right.imageRepository) ||
431
+ compareStrings(left.manifestDigest, right.manifestDigest)
428
432
  );
429
433
  }
430
434
 
@@ -13,14 +13,14 @@ function isOwnedByBiome(subject, biomeId, enums) {
13
13
  /**
14
14
  * The subject carrying the image of `kind:id` for the biome `biomeId`, or undefined.
15
15
  * A service is served by (a) a Service subject with that id owned by the biome, or (b) a subject owned by the biome
16
- * whose `members[]` names the service. The member-to-subject step is the kernel's `serviceImageSubjects`
17
- * (`enums.serviceImageSubjects`), the same rule the launcher's `loadImageLock` applies; this file adds only the
16
+ * whose `members[]` names the service. The member-to-subject step is the kernel's `dedicatedServiceImageSubjects`
17
+ * (`enums.dedicatedServiceImageSubjects`), applied to the lock's `schemaVersion` and the biome's subjects; the same rule the launcher's `loadImageLock` applies; this file adds only the
18
18
  * biome-owner filter. The biome's own `services` list supplies `id`.
19
19
  */
20
- export function subjectServingBiome(subjects, biomeId, kind, id, enums) {
20
+ export function subjectServingBiome(schemaVersion, subjects, biomeId, kind, id, enums) {
21
21
  const owned = subjects.filter((subject) => isOwnedByBiome(subject, biomeId, enums));
22
22
  if (kind !== enums.RuntimeImageSubjectKind.Service) {
23
23
  return owned.find((subject) => subject.kind === kind && subject.id === id);
24
24
  }
25
- return enums.serviceImageSubjects(owned).get(id);
25
+ return enums.dedicatedServiceImageSubjects({ schemaVersion, subjects: owned }).get(id);
26
26
  }
@@ -0,0 +1,24 @@
1
+ // The identity of a subject of an images lock, as the kernel's image-lock v5 uniqueness rule defines it
2
+ // (`parseImageLock`): a subject is `kind:id`, except a composed-process ARTIFACT, which is `imageRef:contentHash` — the
3
+ // generations of one composed artifact share one id and differ by that coordinate. Every map keyed by subject (digest
4
+ // plans, the seal, the lock validation) uses this one function, so two live generations never collapse onto one key.
5
+
6
+ const COMPOSED_PROCESS_KIND = 'composed-process';
7
+
8
+ /** The printable identity of a lock subject (or of a verification entry carrying `kind`, `id`, `imageRef`, `contentHash`). */
9
+ export function imageSubjectIdentity({ kind, id, imageRef, contentHash }) {
10
+ return kind === COMPOSED_PROCESS_KIND ? `${kind}:${imageRef}:${contentHash}` : `${kind}:${id}`;
11
+ }
12
+
13
+ /**
14
+ * The key of a subject in the evidence lane: `kind\0id` for every kind but a composed-process artifact, which is its
15
+ * `imageSubjectIdentity` (so two live generations of one composed artifact get distinct producer stems and index keys).
16
+ */
17
+ export function evidenceSubjectKey({ kind, id, imageRef, contentHash }) {
18
+ return kind === COMPOSED_PROCESS_KIND ? imageSubjectIdentity({ kind, id, imageRef, contentHash }) : `${kind}\0${id}`;
19
+ }
20
+
21
+ /** The key of one image of a completeness statement, which names its image by repository and manifest digest, not by content hash. */
22
+ export function completenessImageKey({ subjectKind, subjectId, imageRepository, manifestDigest }) {
23
+ return subjectKind === COMPOSED_PROCESS_KIND ? `${subjectKind}:${imageRepository}@${manifestDigest}` : `${subjectKind}\0${subjectId}`;
24
+ }