@xemahq/xema 0.48.0 → 0.49.1

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 (98) hide show
  1. package/dist/assets/appliance-base/distribution.lock.json +3936 -1515
  2. package/dist/assets/appliance-base/images.lock.json +878 -1161
  3. package/dist/assets/appliance-lean/distribution.lock.json +3663 -916
  4. package/dist/assets/appliance-lean/images.lock.json +825 -924
  5. package/dist/assets/boot-graph.json +398 -93
  6. package/dist/assets/distribution-assets.manifest.json +10 -10
  7. package/dist/commands/edition.d.ts.map +1 -1
  8. package/dist/commands/edition.js +5 -3
  9. package/dist/commands/edition.js.map +1 -1
  10. package/dist/dev/env-defaults.d.ts.map +1 -1
  11. package/dist/dev/env-defaults.js +0 -1
  12. package/dist/dev/env-defaults.js.map +1 -1
  13. package/dist/edition/verify-image-evidence.bundle.mjs +4386 -2503
  14. package/dist/edition/verify-image-evidence.bundle.provenance.json +590 -488
  15. package/dist/runtime/auth/cli-application.d.ts +18 -0
  16. package/dist/runtime/auth/cli-application.d.ts.map +1 -0
  17. package/dist/runtime/auth/cli-application.js +39 -0
  18. package/dist/runtime/auth/cli-application.js.map +1 -0
  19. package/dist/runtime/edition/verb-map.d.ts +3 -1
  20. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  21. package/dist/runtime/edition/verb-map.js +5 -1
  22. package/dist/runtime/edition/verb-map.js.map +1 -1
  23. package/dist/runtime/env-seeder.d.ts.map +1 -1
  24. package/dist/runtime/env-seeder.js +21 -0
  25. package/dist/runtime/env-seeder.js.map +1 -1
  26. package/dist/runtime/launch.d.ts.map +1 -1
  27. package/dist/runtime/launch.js +0 -18
  28. package/dist/runtime/launch.js.map +1 -1
  29. package/edition/README.md +20 -3
  30. package/edition/biome-content.mjs +5 -2
  31. package/edition/build-runtime-image-source-index.mjs +79 -5
  32. package/edition/canonical-evidence-json.mjs +82 -0
  33. package/edition/catalogue-evidence.mjs +8 -5
  34. package/edition/customer-edition-verbs.mjs +9 -3
  35. package/edition/develop-coverage-run.mjs +2 -1
  36. package/edition/develop-coverage.mjs +12 -1
  37. package/edition/develop-edition-resolve.mjs +3 -2
  38. package/edition/develop-edition.mjs +15 -3
  39. package/edition/develop-record-verify.mjs +216 -0
  40. package/edition/develop-select-records.mjs +24 -225
  41. package/edition/develop-verify-image-record.mjs +90 -0
  42. package/edition/dist-check.mjs +24 -3
  43. package/edition/distribution-evidence-set.mjs +30 -2
  44. package/edition/edition-artifact.mjs +110 -22
  45. package/edition/edition-ceiling.mjs +180 -0
  46. package/edition/edition-config.mjs +91 -0
  47. package/edition/edition-constants.mjs +21 -3
  48. package/edition/edition-contract-parse.mjs +88 -3
  49. package/edition/edition-contract.json +47 -4
  50. package/edition/edition-image-seal.mjs +9 -2
  51. package/edition/edition-kind.mjs +10 -0
  52. package/edition/edition-protocol.mjs +7 -1
  53. package/edition/edition-publisher.mjs +21 -0
  54. package/edition/edition-seal.mjs +257 -0
  55. package/edition/evidence-grammars.mjs +2 -0
  56. package/edition/evidence-trust-pin.mjs +66 -10
  57. package/edition/evidence-trust-policy.mjs +39 -29
  58. package/edition/external-source-walk.mjs +16 -3
  59. package/edition/fleet-coordinate-finality.mjs +18 -5
  60. package/edition/fulcio-certificate-extensions.mjs +231 -0
  61. package/edition/image-evidence.mjs +34 -79
  62. package/edition/locked-bundle-signer.mjs +241 -0
  63. package/edition/oci-evidence-verbs.mjs +7 -1
  64. package/edition/pinned-cosign.mjs +70 -0
  65. package/edition/plan-image-evidence-refresh.mjs +3 -1
  66. package/edition/producer-evidence-bundle.mjs +3 -1
  67. package/edition/producer-evidence-index-file.mjs +4 -0
  68. package/edition/release-edition-artifact.mjs +3 -3
  69. package/edition/repin-evidence-trust.mjs +6 -1
  70. package/edition/resolve-distribution.mjs +90 -7
  71. package/edition/resolve-edition.mjs +1 -1
  72. package/edition/resolve-images.mjs +53 -7
  73. package/edition/seal-image-digests.mjs +19 -8
  74. package/edition/sealed-distribution-lock.mjs +2 -2
  75. package/edition/third-party-admission.mjs +91 -0
  76. package/edition/third-party-bundle-registry.mjs +147 -0
  77. package/edition/third-party-certificate-extensions.mjs +21 -0
  78. package/edition/third-party-image-pins.mjs +41 -0
  79. package/edition/third-party-pins-trusted.mjs +24 -0
  80. package/edition/third-party-producer-evidence.mjs +76 -0
  81. package/edition/third-party-source-check.mjs +28 -0
  82. package/edition/third-party-source-context.mjs +41 -0
  83. package/edition/third-party-source-evidence.mjs +255 -0
  84. package/edition/third-party-source-flags.mjs +134 -0
  85. package/edition/third-party-source-pins.mjs +259 -0
  86. package/edition/third-party-source-uniqueness.mjs +54 -0
  87. package/edition/third-party-source-walk.mjs +115 -0
  88. package/edition/third-party-source-writer.mjs +229 -0
  89. package/edition/third-party-subjects.mjs +128 -0
  90. package/edition/trust-policy-image-ownership.mjs +85 -0
  91. package/edition/verify-catalogue-evidence.mjs +2 -0
  92. package/edition/verify-image-evidence.mjs +2 -0
  93. package/edition/verify-image-platforms.mjs +33 -9
  94. package/package.json +5 -5
  95. package/dist/runtime/auth/client-bootstrap.d.ts +0 -11
  96. package/dist/runtime/auth/client-bootstrap.d.ts.map +0 -1
  97. package/dist/runtime/auth/client-bootstrap.js +0 -60
  98. package/dist/runtime/auth/client-bootstrap.js.map +0 -1
@@ -27,8 +27,11 @@ import {
27
27
  EDITION_REPOSITORY_PREFIX,
28
28
  EDITION_REPOSITORY_SEGMENTS,
29
29
  EditionChannel,
30
+ EditionKind,
30
31
  EditionLockFile,
31
- EvidenceTrustState,
32
+ SEALED_EDITION_CHANNEL,
33
+ SEAL_OF_PATTERN,
34
+ admittedEvidenceTrustStates,
32
35
  } from './edition-constants.mjs';
33
36
  import { dataRoot, editionsRoot } from './edition-workspace.mjs';
34
37
  import { serializeJson, sha256Hex } from './lib.mjs';
@@ -64,21 +67,18 @@ export function assertEngineImage(image) {
64
67
  }
65
68
 
66
69
  /**
67
- * The descriptor: what this artifact is, which locks it carries, where it came
68
- * from, whether the data root's editions are evidence-gated, and the deploy
69
- * engine image that consumes it.
70
+ * The descriptor (xema.edition/v2): what this artifact is (its `kind`, and for a sealed edition the
71
+ * resolved edition it seals, `sealOf`), which locks it carries, where it came from, whether the data
72
+ * root's editions are evidence-gated, and the deploy engine image that consumes it. Every writer of a
73
+ * descriptor passes here, and the strict parse it ends in holds the contract's rule: no route writes
74
+ * an edition whose evidence trust state its kind and channel do not admit, whatever its caller computed.
70
75
  */
71
- export function editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
76
+ export function editionDescriptor({ kind, sealOf, distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
72
77
  assertCanonicalDistributionId(distributionId);
73
- // Every writer of a descriptor passes here: no route writes a release
74
- // edition that is not armed, whatever state its caller computed.
75
- if (channel === EditionChannel.Release && evidenceTrustPolicy !== EvidenceTrustState.Armed) {
76
- throw new Error(
77
- `a release edition requires an armed evidence trust policy; "${distributionId}" was to be written ${JSON.stringify(evidenceTrustPolicy)}`,
78
- );
79
- }
80
78
  return parseEditionDescriptor({
81
79
  schema: EDITION_DESCRIPTOR_SCHEMA,
80
+ kind,
81
+ ...(kind === EditionKind.Sealed ? { sealOf } : {}),
82
82
  distributionId,
83
83
  locks: [...EDITION_LOCKS],
84
84
  provenance: {
@@ -93,6 +93,25 @@ export function editionDescriptor({ distributionId, channel, pins, workflow, evi
93
93
 
94
94
  const COMMIT_PATTERN = /^[0-9a-f]{40}$/;
95
95
  const DIGEST_PIN_PATTERN = /^sha256:[0-9a-f]{64}$/;
96
+ const DESCRIPTOR_KEYS = Object.freeze(['schema', 'kind', 'distributionId', 'locks', 'provenance', 'evidenceTrustPolicy', 'engine']);
97
+
98
+ /**
99
+ * The one parser of a sealed edition's `sealOf`, the digest-pinned ref of the resolved edition it seals: its form is the
100
+ * contract's `sealOf.pattern` (`<registry>/<path>@sha256:<64 hex>`), and its repository is an edition repository
101
+ * (`<registry root>/<channel segment>/xema-edition-<id>`). Returns `{ repository, digest, registryRoot, channel, distributionId }`.
102
+ */
103
+ export function parseSealOf(sealOf) {
104
+ if (typeof sealOf !== 'string' || !SEAL_OF_PATTERN.test(sealOf)) {
105
+ throw new Error(
106
+ `sealOf ${JSON.stringify(sealOf)} does not match the contract's sealOf.pattern ${SEAL_OF_PATTERN.source}: a seal names the resolved edition ` +
107
+ `<registry root>/<channel segment>/${EDITION_REPOSITORY_PREFIX}<id> by digest`,
108
+ );
109
+ }
110
+ const at = sealOf.lastIndexOf('@');
111
+ const digest = sealOf.slice(at + 1);
112
+ const repository = sealOf.slice(0, at);
113
+ return { repository, digest, ...parseEditionRepository(repository) };
114
+ }
96
115
 
97
116
  const exactKeys = (value, keys, where) => {
98
117
  if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join() !== [...keys].sort().join()) {
@@ -100,12 +119,28 @@ const exactKeys = (value, keys, where) => {
100
119
  }
101
120
  };
102
121
 
103
- /** The strict parse of a descriptor: exact keys, closed vocabularies, an engine image by digest. Returns it unchanged. */
122
+ /**
123
+ * The strict parse of a descriptor: exact keys, the closed kind enum (no default, never inferred), `sealOf` exactly on a
124
+ * sealed edition, closed vocabularies, the evidence trust state the contract admits for the kind on the channel, an engine
125
+ * image by digest. Returns it unchanged.
126
+ */
104
127
  export function parseEditionDescriptor(value) {
105
- exactKeys(value, ['schema', 'distributionId', 'locks', 'provenance', 'evidenceTrustPolicy', 'engine'], EDITION_DESCRIPTOR_FILE);
128
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
129
+ throw new Error(`${EDITION_DESCRIPTOR_FILE} must be a JSON object`);
130
+ }
106
131
  if (value.schema !== EDITION_DESCRIPTOR_SCHEMA) {
107
132
  throw new Error(`${EDITION_DESCRIPTOR_FILE}: schema is ${JSON.stringify(value.schema)}, expected ${JSON.stringify(EDITION_DESCRIPTOR_SCHEMA)}`);
108
133
  }
134
+ if (!Object.values(EditionKind).includes(value.kind)) {
135
+ throw new Error(
136
+ `${EDITION_DESCRIPTOR_FILE}: kind is ${JSON.stringify(value.kind)}, expected exactly ${Object.values(EditionKind).join(' | ')}; there is no default kind and none is inferred`,
137
+ );
138
+ }
139
+ const sealed = value.kind === EditionKind.Sealed;
140
+ if (!sealed && 'sealOf' in value) {
141
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: kind ${value.kind} carries sealOf; only a sealed edition names the resolved edition it seals`);
142
+ }
143
+ exactKeys(value, sealed ? [...DESCRIPTOR_KEYS, 'sealOf'] : DESCRIPTOR_KEYS, EDITION_DESCRIPTOR_FILE);
109
144
  assertCanonicalDistributionId(value.distributionId);
110
145
  if (!Array.isArray(value.locks) || value.locks.length === 0 || value.locks.some((file) => typeof file !== 'string' || file === '')) {
111
146
  throw new Error(`${EDITION_DESCRIPTOR_FILE}: locks must be a non-empty array of file names`);
@@ -133,16 +168,30 @@ export function parseEditionDescriptor(value) {
133
168
  if (!workflow.repository || !workflow.runId || typeof workflow.repository !== 'string' || typeof workflow.runId !== 'string') {
134
169
  throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.workflow is {repository, runId}, both non-empty strings`);
135
170
  }
136
- if (!Object.values(EvidenceTrustState).includes(value.evidenceTrustPolicy)) {
137
- throw new Error(`${EDITION_DESCRIPTOR_FILE}: evidenceTrustPolicy must be one of ${Object.values(EvidenceTrustState).join(' | ')}`);
171
+ if (sealed && value.provenance.channel !== SEALED_EDITION_CHANNEL) {
172
+ throw new Error(
173
+ `${EDITION_DESCRIPTOR_FILE}: a sealed edition records provenance.channel ${SEALED_EDITION_CHANNEL} (a seal is ${SEALED_EDITION_CHANNEL}-only); "${value.distributionId}" records ${JSON.stringify(value.provenance.channel)}`,
174
+ );
138
175
  }
139
- // The reader holds the writer's rule: a release edition that is not armed is
140
- // not an edition this plane writes, so it is not one it reads.
141
- if (value.provenance.channel === EditionChannel.Release && value.evidenceTrustPolicy !== EvidenceTrustState.Armed) {
176
+ // The reader holds the writer's rule (contract evidenceTrust.statesByKind): `not-armed` only on a resolved develop edition.
177
+ const admitted = admittedEvidenceTrustStates(value.kind, value.provenance.channel);
178
+ if (!admitted.includes(value.evidenceTrustPolicy)) {
142
179
  throw new Error(
143
- `${EDITION_DESCRIPTOR_FILE}: a release edition requires an armed evidence trust policy; "${value.distributionId}" records ${JSON.stringify(value.evidenceTrustPolicy)}`,
180
+ `${EDITION_DESCRIPTOR_FILE}: a ${value.kind} ${value.provenance.channel} edition records evidenceTrustPolicy ${admitted.join(' | ')}; ` +
181
+ `"${value.distributionId}" records ${JSON.stringify(value.evidenceTrustPolicy)}`,
144
182
  );
145
183
  }
184
+ if (sealed) {
185
+ const sealOf = parseSealOf(value.sealOf);
186
+ if (sealOf.distributionId !== value.distributionId) {
187
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: sealOf ${value.sealOf} is an edition of "${sealOf.distributionId}", not of "${value.distributionId}"`);
188
+ }
189
+ if (sealOf.channel !== value.provenance.channel) {
190
+ throw new Error(
191
+ `${EDITION_DESCRIPTOR_FILE}: sealOf ${value.sealOf} is a ${sealOf.channel} edition, but the seal records provenance.channel ${value.provenance.channel}; a seal keeps the channel of the edition it seals`,
192
+ );
193
+ }
194
+ }
146
195
  exactKeys(value.engine, ['image'], `${EDITION_DESCRIPTOR_FILE} engine`);
147
196
  assertEngineImage(value.engine.image);
148
197
  return value;
@@ -300,7 +349,7 @@ function assertOutsideCommittedTrees(outDir) {
300
349
  }
301
350
  }
302
351
 
303
- /** Write the descriptor and the locks of an edition of `channel` to `outDir`; returns the descriptor. */
352
+ /** Write the descriptor and the locks of a RESOLVED edition of `channel` to `outDir`; returns the descriptor. */
304
353
  export function writeEditionArtifact({
305
354
  outDir,
306
355
  channel,
@@ -327,7 +376,7 @@ export function writeEditionArtifact({
327
376
  }
328
377
  }
329
378
  assertRosteredChartSetLock(chartSetLock, EDITION_CHART_SET_FILE);
330
- const descriptor = editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage });
379
+ const descriptor = editionDescriptor({ kind: EditionKind.Resolved, distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage });
331
380
  mkdirSync(outDir, { recursive: true });
332
381
  for (const [file, value] of [[EDITION_DESCRIPTOR_FILE, descriptor], ...byFile]) {
333
382
  writeFileSync(join(outDir, file), serializeJson(value));
@@ -349,6 +398,9 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
349
398
  throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
350
399
  }
351
400
  const existing = parseEditionDescriptor(readStrictJsonFile(descriptorPath));
401
+ if (existing.kind !== EditionKind.Resolved) {
402
+ throw new Error(`the existing artifact is a ${existing.kind} edition; only a resolved edition is reused for a snapshot, refusing to reuse it`);
403
+ }
352
404
  assertCanonicalDistributionId(distributionId);
353
405
  if (existing.distributionId !== distributionId) {
354
406
  throw new Error(`the existing artifact's distributionId is ${JSON.stringify(existing.distributionId)}, expected ${JSON.stringify(distributionId)}; refusing to reuse it`);
@@ -386,3 +438,39 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
386
438
  }
387
439
  }
388
440
  }
441
+
442
+ /**
443
+ * Write the SEALED edition of one exact resolved edition to `outDir`: every layer of the resolved artifact other than
444
+ * its descriptor, byte for byte (`layers`: title -> bytes, already proven to be the layers of `sealOf`), plus a sealed
445
+ * descriptor. The seal keeps the resolved edition's distribution, locks, channel, pins and engine image; it records
446
+ * `sealOf`, the run that sealed it, and an evidence trust state its kind admits (contract: armed). Returns the descriptor.
447
+ */
448
+ export function writeSealedEditionArtifact({ outDir, resolved, sealOf, layers, workflow, evidenceTrustPolicy }) {
449
+ assertOutsideCommittedTrees(outDir);
450
+ if (resolved.kind !== EditionKind.Resolved) {
451
+ throw new Error(`${sealOf} is a ${resolved.kind} edition; a seal seals exactly one resolved edition`);
452
+ }
453
+ if (layers.has(EDITION_DESCRIPTOR_FILE)) {
454
+ throw new Error(`the layers of a seal exclude the resolved ${EDITION_DESCRIPTOR_FILE}; the seal writes its own`);
455
+ }
456
+ const missing = resolved.locks.filter((file) => !layers.has(file));
457
+ if (missing.length > 0) {
458
+ throw new Error(`${sealOf} lacks ${missing.join(', ')}, which its descriptor lists`);
459
+ }
460
+ const descriptor = editionDescriptor({
461
+ kind: EditionKind.Sealed,
462
+ sealOf,
463
+ distributionId: resolved.distributionId,
464
+ channel: resolved.provenance.channel,
465
+ pins: resolved.provenance.pins,
466
+ workflow,
467
+ evidenceTrustPolicy,
468
+ engineImage: resolved.engine.image,
469
+ });
470
+ mkdirSync(outDir, { recursive: true });
471
+ writeFileSync(join(outDir, EDITION_DESCRIPTOR_FILE), serializeJson(descriptor));
472
+ for (const [file, bytes] of layers) {
473
+ writeFileSync(join(outDir, file), bytes);
474
+ }
475
+ return descriptor;
476
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ // `xema edition ceiling`: the biomes a dev deployment may run, resolved at one distributions commit.
3
+ //
4
+ // usage: xema edition ceiling --distribution <id> --at <distributions sha> --producer-commit <owner/repo>=<sha> [...]
5
+ // --data-root <dir> --sources-root <dir> --kernel <dir>
6
+ //
7
+ // stdout: a `DevelopCeilingSchema` document (`@xemahq/contracts/develop-ceiling`): schema, distributionsCommit,
8
+ // distributionId and biomes sorted by id, each { id, origin, version, repo, services, runtimeComponents,
9
+ // serviceProcessFacts, trustCeiling? }. No images and no seal.
10
+ //
11
+ // THE SELECTION is the distribution definition `<id>/xema-distribution.json` and the committed `biome-index.json`
12
+ // and `platform-service-index.json`, all read from the data root (the distributions checkout) AT `--at`, resolved
13
+ // by the release resolver itself (`composeManifest` + `resolveDistributionWithIndex`): id, origin, repo and
14
+ // trustCeiling of each biome are the resolution's, never re-derived here.
15
+ //
16
+ // THE PROCESS FACTS (version, services, runtimeComponents, serviceProcessFacts) are the producer's own: its
17
+ // `xema-biome.json` and generated `xema-biome.index.json` AT the producer commit given by `--producer-commit`,
18
+ // projected by `manifestToIndexEntry`, the function that builds `biome-index.json`. A service new on the
19
+ // producer's develop inside a selected biome is admitted; a biome the selection does not name is never read.
20
+ // A selected biome whose producer has no stated commit, or whose manifest is absent at that commit, is refused:
21
+ // there is no fallback to the committed index's facts.
22
+
23
+ import { DevelopCeilingSchema } from '@xemahq/contracts/develop-ceiling';
24
+
25
+ import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
26
+ import { developGraphMembers } from './develop-graph.mjs';
27
+ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
28
+ import { WorkspaceRoot, configureEditionWorkspaceFromArgv, dataRoot, isEntryModule, sourcePath } from './edition-workspace.mjs';
29
+ import { readGitJsonAtCommit } from './git-json.mjs';
30
+ import {
31
+ BIOME_GENERATED_INDEX_FILENAME,
32
+ compareStrings,
33
+ loadKernelDistributionSchemas,
34
+ manifestToIndexEntry,
35
+ serializeJson,
36
+ } from './lib.mjs';
37
+ import { composeManifest, lockedServiceProcessFacts, parseEditionManifest, resolveDistributionWithIndex } from './resolve-distribution.mjs';
38
+
39
+ const USAGE =
40
+ 'usage: xema edition ceiling --distribution <id> --at <distributions sha> --producer-commit <owner/repo>=<sha> [--producer-commit ...] --data-root <dir> --sources-root <dir> --kernel <dir>';
41
+ const GIT_COMMIT = /^[0-9a-f]{40}$/;
42
+ const PRODUCER_COMMIT = /^([A-Za-z0-9._-]+\/[A-Za-z0-9._-]+)=([0-9a-f]{40})$/;
43
+ const MANIFEST_FILE = 'xema-biome.json';
44
+
45
+ /** The ceiling named a producer or a commit this verb cannot read: nothing is guessed. */
46
+ export class CeilingRefused extends Error {
47
+ constructor(message) {
48
+ super(message);
49
+ this.name = 'CeilingRefused';
50
+ }
51
+ }
52
+
53
+ /** `owner/name` -> the sources-root handle (`repos/<name>`) of the first-party repository it is, per the develop graph. */
54
+ function producersByHandle(members) {
55
+ return new Map(
56
+ members.map(({ repo, repositoryUrl }) => [
57
+ repo,
58
+ repositoryOwnerName(canonicalPublicHttpsRepositoryUrl(repositoryUrl, `firstPartySources["${repo}"].repositoryUrl`), `firstPartySources["${repo}"].repositoryUrl`),
59
+ ]),
60
+ );
61
+ }
62
+
63
+ /** One selected biome's facts, read from its producer's manifest and ledger at the producer commit. */
64
+ async function producerFacts({ selected, producer, commit, repoDir }) {
65
+ const manifestDirectory = selected.path.slice(`${selected.repo}/`.length);
66
+ const manifestPath = `${manifestDirectory}/${MANIFEST_FILE}`;
67
+ const ledgerPath = `${manifestDirectory}/${BIOME_GENERATED_INDEX_FILENAME}`;
68
+ const rel = `${producer}/${manifestPath}`;
69
+ let raw;
70
+ let generatedIndex;
71
+ try {
72
+ raw = readGitJsonAtCommit(repoDir, manifestPath, commit);
73
+ generatedIndex = readGitJsonAtCommit(repoDir, ledgerPath, commit);
74
+ } catch (error) {
75
+ throw new CeilingRefused(`biome "${selected.id}": ${rel} or its ${BIOME_GENERATED_INDEX_FILENAME} cannot be read at ${producer}@${commit}: ${error.message}`);
76
+ }
77
+ const entry = await manifestToIndexEntry({ raw, generatedIndex, rel, repo: selected.repo, path: selected.path });
78
+ if (entry.id !== selected.id) {
79
+ throw new CeilingRefused(`${rel} at ${commit} declares biome "${entry.id}", but the selection names "${selected.id}" there`);
80
+ }
81
+ return entry;
82
+ }
83
+
84
+ /**
85
+ * @param {object} request
86
+ * @param {string} request.distributionId
87
+ * @param {string} request.distributionsCommit 40-hex commit of the data root checkout
88
+ * @param {ReadonlyMap<string, string>} request.producerCommits `owner/repo` -> the commit its facts are read at
89
+ * @param {object} request.schemas the kernel's distribution exports
90
+ * @returns {Promise<object>} the validated ceiling
91
+ */
92
+ export async function composeCeiling({ distributionId, distributionsCommit, producerCommits, schemas }) {
93
+ assertCanonicalDistributionId(distributionId);
94
+ if (!GIT_COMMIT.test(distributionsCommit)) {throw new Error(`--at must be a 40-hex commit; got ${JSON.stringify(distributionsCommit)}`);}
95
+ const read = (path) => readGitJsonAtCommit(dataRoot(), path, distributionsCommit);
96
+ const manifest = parseEditionManifest(schemas.parseDistribution, distributionId, read(`${distributionId}/xema-distribution.json`), `${distributionsCommit}:${distributionId}/xema-distribution.json`);
97
+ const composed = composeManifest(manifest);
98
+ if (composed.declaredSources.length > 0) {
99
+ throw new CeilingRefused(`distribution "${distributionId}" declares external sources[]; a ceiling is resolved from the committed indexes alone`);
100
+ }
101
+ const { lock } = await resolveDistributionWithIndex(distributionId, {
102
+ inputs: {
103
+ composed,
104
+ biomeIndex: read('biome-index.json'),
105
+ platformServiceIndex: read('platform-service-index.json'),
106
+ sourcesAvailable: false,
107
+ storePublisherTrust: undefined,
108
+ },
109
+ });
110
+ const producers = producersByHandle(developGraphMembers());
111
+ const biomes = [];
112
+ for (const selected of lock.biomes) {
113
+ const producer = producers.get(selected.repo);
114
+ if (producer === undefined) {
115
+ throw new CeilingRefused(`biome "${selected.id}" is selected from "${selected.repo}", which is not a first-party repository of the develop graph`);
116
+ }
117
+ const commit = producerCommits.get(producer);
118
+ if (commit === undefined) {
119
+ throw new CeilingRefused(`biome "${selected.id}" is selected from ${producer}, but no --producer-commit states the commit its facts are read at`);
120
+ }
121
+ const entry = await producerFacts({ selected, producer, commit, repoDir: sourcePath(selected.repo) });
122
+ biomes.push({
123
+ id: selected.id,
124
+ origin: selected.origin,
125
+ version: entry.version,
126
+ repo: selected.repo,
127
+ services: entry.services,
128
+ runtimeComponents: entry.runtimeComponents,
129
+ serviceProcessFacts: lockedServiceProcessFacts(entry),
130
+ ...(selected.trustCeiling === undefined ? {} : { trustCeiling: selected.trustCeiling }),
131
+ });
132
+ }
133
+ biomes.sort((a, b) => compareStrings(a.id, b.id));
134
+ return DevelopCeilingSchema.parse({ schema: 1, distributionsCommit, distributionId, biomes });
135
+ }
136
+
137
+ /** `--distribution`, `--at` and the repeatable `--producer-commit <owner/repo>=<sha>` of the argv left after the root flags. */
138
+ export function parseCeilingArgs(argv) {
139
+ const options = { producerCommits: new Map() };
140
+ for (let i = 0; i < argv.length; i += 2) {
141
+ const flag = argv[i];
142
+ const value = argv[i + 1];
143
+ if (value === undefined || (value.startsWith('--') && value.length > 2)) {throw new Error(`${flag} requires a value\n${USAGE}`);}
144
+ if (flag === '--producer-commit') {
145
+ const match = PRODUCER_COMMIT.exec(value);
146
+ if (match === null) {throw new Error(`--producer-commit must be <owner/repo>=<40-hex commit>; got ${JSON.stringify(value)}`);}
147
+ if (options.producerCommits.has(match[1])) {throw new Error(`--producer-commit given twice for ${match[1]}`);}
148
+ options.producerCommits.set(match[1], match[2]);
149
+ } else if (flag === '--distribution' || flag === '--at') {
150
+ const key = flag.slice(2);
151
+ if (options[key] !== undefined) {throw new Error(`${flag} given twice`);}
152
+ options[key] = value;
153
+ } else {
154
+ throw new Error(`unknown argument ${flag}\n${USAGE}`);
155
+ }
156
+ }
157
+ for (const required of ['distribution', 'at']) {
158
+ if (options[required] === undefined) {throw new Error(`--${required} is required\n${USAGE}`);}
159
+ }
160
+ return options;
161
+ }
162
+
163
+ async function main() {
164
+ const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [WorkspaceRoot.Data, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
165
+ const options = parseCeilingArgs(rest);
166
+ const ceiling = await composeCeiling({
167
+ distributionId: options.distribution,
168
+ distributionsCommit: options.at,
169
+ producerCommits: options.producerCommits,
170
+ schemas: await loadKernelDistributionSchemas(),
171
+ });
172
+ process.stdout.write(serializeJson(ceiling));
173
+ }
174
+
175
+ if (isEntryModule(import.meta.url)) {
176
+ main().catch((error) => {
177
+ console.error(`[edition-ceiling] ${error.message}`);
178
+ process.exitCode = 1;
179
+ });
180
+ }
@@ -4,11 +4,18 @@
4
4
  // `<dataRoot>/edition-workspace.json`; there is no default for any of them, and
5
5
  // a file with a missing, unknown or malformed key is refused. `evidenceTrustPolicy`
6
6
  // declares the data root's evidence gating; its `required` flag has no default.
7
+ // `editionPublishers` names the exact workflow that publishes an edition of each
8
+ // kind on each channel (the shape is edition-contract.json signers.publishers): a
9
+ // customer data root declares its own; a publisher it does not declare is refused
10
+ // when an operation needs it, with no default.
7
11
 
8
12
  import { join } from 'node:path';
9
13
 
10
14
  import { canonicalPublicHttpsRepositoryUrl, repositoryOwnerName } from './canonical-repository-url.mjs';
15
+ import { EDITION_PUBLISHER_CHANNELS } from './edition-constants.mjs';
16
+ import { assertPublisher } from './edition-publisher.mjs';
11
17
  import { dataRoot } from './edition-workspace.mjs';
18
+ import { CANONICAL_IDENTIFIER } from './evidence-grammars.mjs';
12
19
  import { isUnderSourcePrefix } from './git-source-auth.mjs';
13
20
  import { readStrictJsonFile } from './strict-json.mjs';
14
21
  import {
@@ -52,6 +59,8 @@ const KEYS = Object.freeze([
52
59
  'dataRootRepositoryUrl',
53
60
  'sourceAuthUsername',
54
61
  'evidenceTrustPolicy',
62
+ 'firstPartyBundleSigner',
63
+ 'editionPublishers',
55
64
  ...Object.keys(SECTIONS),
56
65
  ]);
57
66
 
@@ -166,6 +175,32 @@ function requireEvidenceTrustPolicy(config) {
166
175
  return Object.freeze({ policy: value.policy, pin: value.pin });
167
176
  }
168
177
 
178
+ /**
179
+ * The OPTIONAL `firstPartyBundleSigner` declaration: exactly `{policyId}`, naming the one `public-key` row of the
180
+ * evidence trust policy that signs the data root's first-party OCI bundles. No default: a data root that does not
181
+ * declare it cannot seal a first-party OCI bundle row.
182
+ */
183
+ function optionalFirstPartyBundleSigner(document) {
184
+ if (document.firstPartyBundleSigner === undefined) {return {};}
185
+ const value = document.firstPartyBundleSigner;
186
+ const where = `${EDITION_WORKSPACE_CONFIG_FILE}: "firstPartyBundleSigner"`;
187
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
188
+ throw new EditionWorkspaceConfigError(`${where} must be exactly {"policyId": "<canonical id>"}; got ${JSON.stringify(value)}`);
189
+ }
190
+ const keys = Object.keys(value);
191
+ const unknown = keys.filter((key) => key !== 'policyId');
192
+ if (unknown.length > 0) {
193
+ throw new EditionWorkspaceConfigError(`${where} has unknown key ${unknown.map((key) => `"${key}"`).join(', ')}; it is exactly {"policyId": "<canonical id>"}`);
194
+ }
195
+ if (!Object.hasOwn(value, 'policyId')) {
196
+ throw new EditionWorkspaceConfigError(`${where} is missing "policyId"; it is exactly {"policyId": "<canonical id>"}`);
197
+ }
198
+ if (typeof value.policyId !== 'string' || !CANONICAL_IDENTIFIER.test(value.policyId)) {
199
+ throw new EditionWorkspaceConfigError(`${where}.policyId must be a canonical policy id; got ${JSON.stringify(value.policyId)}`);
200
+ }
201
+ return { firstPartyBundleSigner: Object.freeze({ policyId: value.policyId }) };
202
+ }
203
+
169
204
  /**
170
205
  * Every first-party repository URL is canonical and under the workspace's
171
206
  * `sourceRepositoryUrlPrefix`: the develop graph sends the source token there.
@@ -212,6 +247,44 @@ function optionalDataRootRepositoryUrl(document, prefix) {
212
247
  return { dataRootRepositoryUrl: canonical };
213
248
  }
214
249
 
250
+ /**
251
+ * The OPTIONAL `editionPublishers`: `{<kind>: {<channel>: {repository, workflow, ref}}}`, only the kinds and channels
252
+ * edition-contract.json signers.publishers.channelsByKind names, each publisher in the contract's shape. A data root
253
+ * may declare a subset; an empty kind, an unknown kind or channel, or a malformed publisher is refused.
254
+ */
255
+ function optionalEditionPublishers(document) {
256
+ const value = document.editionPublishers;
257
+ if (value === undefined) {return {};}
258
+ const where = `${EDITION_WORKSPACE_CONFIG_FILE}: "editionPublishers"`;
259
+ const isObject = (entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry);
260
+ if (!isObject(value) || Object.keys(value).length === 0) {
261
+ throw new EditionWorkspaceConfigError(`${where} must be a non-empty {<kind>: {<channel>: {repository, workflow, ref}}}`);
262
+ }
263
+ const parsed = {};
264
+ for (const [kind, byChannel] of Object.entries(value)) {
265
+ const channels = EDITION_PUBLISHER_CHANNELS[kind];
266
+ if (channels === undefined) {
267
+ throw new EditionWorkspaceConfigError(`${where} names kind "${kind}"; edition-contract.json signers.publishers.channelsByKind names ${Object.keys(EDITION_PUBLISHER_CHANNELS).join(' | ')}`);
268
+ }
269
+ if (!isObject(byChannel) || Object.keys(byChannel).length === 0) {
270
+ throw new EditionWorkspaceConfigError(`${where}.${kind} must be a non-empty {<channel>: {repository, workflow, ref}}`);
271
+ }
272
+ parsed[kind] = {};
273
+ for (const [channel, publisher] of Object.entries(byChannel)) {
274
+ if (!channels.includes(channel)) {
275
+ throw new EditionWorkspaceConfigError(`${where}.${kind} names channel "${channel}"; a ${kind} edition has a publisher on ${channels.join(' | ')} only`);
276
+ }
277
+ try {
278
+ parsed[kind][channel] = Object.freeze({ ...assertPublisher(publisher, `${where}.${kind}.${channel}`) });
279
+ } catch (error) {
280
+ throw new EditionWorkspaceConfigError(error.message);
281
+ }
282
+ }
283
+ Object.freeze(parsed[kind]);
284
+ }
285
+ return { editionPublishers: Object.freeze(parsed) };
286
+ }
287
+
215
288
  /** Validate a parsed config document; returns the frozen, typed config. */
216
289
  export function parseEditionWorkspaceConfig(document) {
217
290
  if (document === null || typeof document !== 'object' || Array.isArray(document)) {
@@ -238,6 +311,8 @@ export function parseEditionWorkspaceConfig(document) {
238
311
  ...optionalDataRootRepositoryUrl(document, sourceRepositoryUrlPrefix),
239
312
  sourceAuthUsername: requireSourceAuthUsername(document),
240
313
  evidenceTrustPolicy: requireEvidenceTrustPolicy(document),
314
+ ...optionalFirstPartyBundleSigner(document),
315
+ ...optionalEditionPublishers(document),
241
316
  });
242
317
  }
243
318
 
@@ -281,3 +356,19 @@ export function workspaceSection(name, needed) {
281
356
  }
282
357
  return section;
283
358
  }
359
+
360
+ /**
361
+ * The publisher `{repository, workflow, ref}` of an edition of `kind` on `channel`, as this data root declares it
362
+ * (edition-workspace.json editionPublishers.<kind>.<channel>). One it does not declare is refused by name; there is
363
+ * no default publisher and no fallback.
364
+ */
365
+ export function editionPublisher(kind, channel) {
366
+ const publisher = editionWorkspaceConfig().editionPublishers?.[kind]?.[channel];
367
+ if (publisher === undefined) {
368
+ throw new EditionWorkspaceConfigError(
369
+ `${EDITION_WORKSPACE_CONFIG_FILE} in ${dataRoot()} declares no "editionPublishers"."${kind}"."${channel}": the exact workflow ` +
370
+ `{repository, workflow, ref} that publishes a ${kind} ${channel} edition; there is no default publisher`,
371
+ );
372
+ }
373
+ return publisher;
374
+ }
@@ -8,6 +8,7 @@ import { EditionChannel } from './edition-channel.mjs';
8
8
  import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
9
9
  import { parseEditionContract } from './edition-contract-parse.mjs';
10
10
  import editionContract from './edition-contract.json' with { type: 'json' };
11
+ import { EditionKind, EvidenceTrustState } from './edition-kind.mjs';
11
12
 
12
13
  /**
13
14
  * The vocabulary itself lives in edition-contract.json, the ONE source:
@@ -21,6 +22,14 @@ export const EDITION_CONTRACT = Object.freeze(structuredClone(parseEditionContra
21
22
  /** The descriptor layer's file name; its `org.opencontainers.image.title`. */
22
23
  export const EDITION_DESCRIPTOR_FILE = EDITION_CONTRACT.descriptorFile;
23
24
  export const EDITION_DESCRIPTOR_SCHEMA = EDITION_CONTRACT.descriptorSchema;
25
+ /** The form of a sealed edition's `sealOf` (`<registry>/<path>@sha256:<64 hex>`): the contract's pattern, never restated. */
26
+ export const SEAL_OF_PATTERN = new RegExp(EDITION_CONTRACT.sealOf.pattern);
27
+ /** The one channel a seal is of (contract sealOf.channel, D99: release-only): the channel of the resolved edition it seals and of the sealed edition. */
28
+ export const SEALED_EDITION_CHANNEL = EDITION_CONTRACT.sealOf.channel;
29
+ /** The OIDC issuer of every edition publisher's keyless certificate (contract signers.oidcIssuer); never overridden. */
30
+ export const EDITION_SIGNER_OIDC_ISSUER = EDITION_CONTRACT.signers.oidcIssuer;
31
+ /** The channels on which an edition of `kind` has a publisher (contract signers.publishers.channelsByKind); its values are the data root's. */
32
+ export const EDITION_PUBLISHER_CHANNELS = EDITION_CONTRACT.signers.publishers.channelsByKind;
24
33
  /** The ONE artifactType of every edition artifact, develop or release. */
25
34
  export const EDITION_ARTIFACT_TYPE = EDITION_CONTRACT.artifactType;
26
35
  /** The repository name prefix: `<registry root>/<channel segment>/<prefix><id>`. */
@@ -31,10 +40,19 @@ export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
31
40
  Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
32
41
  );
33
42
 
34
- export { EditionChannel };
43
+ export { EditionChannel, EditionKind, EvidenceTrustState };
35
44
 
36
- /** Whether the data root's editions are evidence-gated, as the descriptor records it. */
37
- export const EvidenceTrustState = Object.freeze({ Armed: 'armed', NotArmed: 'not-armed' });
45
+ /**
46
+ * The evidence trust states an edition of `kind` on `channel` may record (contract `evidenceTrust.statesByKind`):
47
+ * `not-armed` only for a resolved develop edition; every other edition is armed or is not written.
48
+ */
49
+ export function admittedEvidenceTrustStates(kind, channel) {
50
+ const states = EDITION_CONTRACT.evidenceTrust.statesByKind[kind]?.[channel];
51
+ if (states === undefined) {
52
+ throw new Error(`edition kind ${JSON.stringify(kind)} on channel ${JSON.stringify(channel)} is not one edition-contract.json names`);
53
+ }
54
+ return states;
55
+ }
38
56
 
39
57
  /** The owner-relative segment of a develop image repository: `<host>/<owner>/<segment>/<name>`. */
40
58
  export const DEVELOP_IMAGE_NAMESPACE_SEGMENT = EDITION_CONTRACT.imageNamespaceSegment;