@xemahq/xema 0.53.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 (47) 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 +916 -860
  13. package/dist/edition/verify-image-evidence.bundle.provenance.json +495 -489
  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 +5 -2
  39. package/edition/images-from-record.mjs +362 -0
  40. package/edition/lib.mjs +1 -19
  41. package/edition/locked-bundle-signer.mjs +15 -3
  42. package/edition/operation-grant-admission.mjs +51 -9
  43. package/edition/resolve-distribution.mjs +95 -82
  44. package/edition/resolve-images.mjs +12 -5
  45. package/edition/shallow-checkout.mjs +18 -0
  46. package/edition/workspace-sections.mjs +23 -3
  47. package/package.json +7 -3
@@ -11,6 +11,7 @@ import { readEditionSourcePins } from './edition-source-pins.mjs';
11
11
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, configuredWorkspace, editionsRoot, isEntryModule, platformServiceIndexPath, runtimeImageSourceIndexPath, sourcesRoot } from './edition-workspace.mjs';
12
12
  import { holdSource, withSourceGitEnv } from './git-source-auth.mjs';
13
13
  import { applyPinSource, snapshotPinSource } from './pin-source.mjs';
14
+ import { shallowCheckout, shallowFetch } from './shallow-checkout.mjs';
14
15
  import { readStrictJsonFile } from './strict-json.mjs';
15
16
 
16
17
  const REPO_PATH = /^(repos|submodules)\/([a-z0-9][a-z0-9-]*)$/;
@@ -188,7 +189,7 @@ export function fetchCrossRepoVendoredCommits({ workspaceRoot, plan, index, work
188
189
  if (!commits) {continue;}
189
190
  withSourceGitEnv({ name: entry.repo, url: entry.repositoryUrl }, { workspace, env }, (gitEnv) => {
190
191
  for (const commit of commits) {
191
- runGit(['-C', resolve(workspaceRoot, entry.repo), 'fetch', '--quiet', '--depth=1', 'origin', commit], gitEnv);
192
+ shallowFetch({ runGit, gitEnv, target: resolve(workspaceRoot, entry.repo), commit });
192
193
  console.log(`[source-checkout] ${entry.repo} also holds cross-repo vendored commit ${commit}`);
193
194
  }
194
195
  });
@@ -210,10 +211,7 @@ export function checkoutIndexedSources({ workspaceRoot, index, platformServiceIn
210
211
  }
211
212
  withSourceGitEnv({ name: entry.repo, url: entry.repositoryUrl }, { workspace, env }, (gitEnv) => {
212
213
  mkdirSync(dirname(target), { recursive: true });
213
- runGit(['init', '--quiet', target], gitEnv);
214
- runGit(['-C', target, 'remote', 'add', 'origin', entry.repositoryUrl], gitEnv);
215
- runGit(['-C', target, 'fetch', '--quiet', '--depth=1', 'origin', entry.sourceCommit], gitEnv);
216
- runGit(['-C', target, 'checkout', '--quiet', '--detach', entry.sourceCommit], gitEnv);
214
+ shallowCheckout({ runGit, gitEnv, target, url: entry.repositoryUrl, commit: entry.sourceCommit });
217
215
  const actual = execFileSync('git', ['-C', target, 'rev-parse', 'HEAD'], {
218
216
  env: gitEnv,
219
217
  encoding: 'utf8',
@@ -223,11 +221,11 @@ export function checkoutIndexedSources({ workspaceRoot, index, platformServiceIn
223
221
  }
224
222
  if (sourceAxisCommits.has(entry.repo)) {
225
223
  const commit = sourceAxisCommits.get(entry.repo);
226
- runGit(['-C', target, 'fetch', '--quiet', '--depth=1', 'origin', commit], gitEnv);
224
+ shallowFetch({ runGit, gitEnv, target, commit });
227
225
  console.log(`[source-checkout] ${entry.repo} also holds source-axis commit ${commit}`);
228
226
  }
229
227
  for (const commit of extraCommits.get(entry.repo) ?? []) {
230
- runGit(['-C', target, 'fetch', '--quiet', '--depth=1', 'origin', commit], gitEnv);
228
+ shallowFetch({ runGit, gitEnv, target, commit });
231
229
  console.log(`[source-checkout] ${entry.repo} also holds platform-service commit ${commit}`);
232
230
  }
233
231
  // A repository an edition holds: its main history (containment in origin/main is checked from it,
@@ -235,7 +233,7 @@ export function checkoutIndexedSources({ workspaceRoot, index, platformServiceIn
235
233
  if (heldCommits.has(entry.repo)) {
236
234
  runGit(['-C', target, 'fetch', '--quiet', 'origin', '+refs/heads/main:refs/remotes/origin/main'], gitEnv);
237
235
  for (const commit of heldCommits.get(entry.repo)) {
238
- runGit(['-C', target, 'fetch', '--quiet', '--depth=1', 'origin', commit], gitEnv);
236
+ shallowFetch({ runGit, gitEnv, target, commit });
239
237
  console.log(`[source-checkout] ${entry.repo} also holds edition-held commit ${commit}`);
240
238
  }
241
239
  }
@@ -0,0 +1,59 @@
1
+ // The one rule for a check that runs every independent assertion before it refuses.
2
+ //
3
+ // A REFUSAL is the answer "no" to the input: a lock the data does not admit, a commit that is not pinned, an image that is
4
+ // not built. It is a `RefusalError` and is collected so one run names them all. Anything else is a DEFECT (a TypeError, a
5
+ // missing file, a registry that answered 5xx, an unnamed Error): it is never collected, never reported as a refusal, and
6
+ // propagates unchanged with its stack.
7
+ //
8
+ // Nothing refused returns; exactly one refusal is rethrown unchanged (its type and message are the contract of whoever
9
+ // catches it); several become ONE error of the caller's class that joins their messages and keeps them as `.errors`.
10
+
11
+ /** An error that is a REFUSAL of the input. Extend it for a named refusal; throw it directly for an unnamed one. */
12
+ export class RefusalError extends Error {}
13
+
14
+ /** The kernel's own bundle-signer refusals, named as the kernel names them (their classes load lazily from the built kernel). */
15
+ export const KernelRefusalName = Object.freeze({
16
+ UnknownPolicy: 'LockedBundleSignerUnknownPolicyError',
17
+ AmbiguousPolicy: 'LockedBundleSignerAmbiguousPolicyError',
18
+ Mismatch: 'LockedBundleSignerMismatchError',
19
+ });
20
+ const KERNEL_REFUSAL_NAMES = new Set(Object.values(KernelRefusalName));
21
+
22
+ /** Whether a thrown value is a refusal. Everything else (a TypeError, a failed system call, an unnamed Error) is a defect. */
23
+ export const isRefusal = (error) => error instanceof RefusalError || (error instanceof Error && KERNEL_REFUSAL_NAMES.has(error.name));
24
+
25
+ /** Run `fn`; a refusal is pushed on `errors` and `undefined` returned, anything else is rethrown unchanged. */
26
+ export function captureRefusal(errors, fn, refusal) {
27
+ try {
28
+ return fn();
29
+ } catch (error) {
30
+ if (!refusal(error)) {throw error;}
31
+ errors.push(error);
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Run `fn` on every item, keeping each refusal, then refuse once all were seen: one refusal is rethrown unchanged, several
38
+ * are one `CollectedError(errors)`. A defect stops the run at once and is rethrown as it was thrown.
39
+ *
40
+ * @param {Iterable<T>} items
41
+ * @param {(item: T) => void} fn
42
+ * @param {(error: unknown) => boolean} refusal which thrown values are refusals (`isRefusal`)
43
+ * @param {new (errors: readonly Error[]) => Error} CollectedError the class that carries several
44
+ */
45
+ export function collectRefusals(items, fn, refusal, CollectedError) {
46
+ const errors = [];
47
+ for (const item of items) {captureRefusal(errors, () => fn(item), refusal);}
48
+ throwCollectedRefusals(errors, CollectedError);
49
+ }
50
+
51
+ /** Nothing collected returns, one is rethrown unchanged, several become one `CollectedError`. */
52
+ export function throwCollectedRefusals(errors, CollectedError) {
53
+ if (errors.length === 0) {return;}
54
+ if (errors.length === 1) {throw errors[0];}
55
+ throw new CollectedError(errors);
56
+ }
57
+
58
+ /** The message of an error that stands for several: each refusal's message, separated by a blank line. */
59
+ export const joinRefusalMessages = (errors) => errors.map((error) => error.message).join('\n\n');
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // `xema edition contract-authority`: the full kernel contract authority of the `@xemahq/contracts` this CLI
3
+ // ships, as canonical JSON on stdout — the `--contract-authority <file>` a third-party publisher's public-input
4
+ // run passes (Third-party sources, README).
5
+ //
6
+ // usage: xema edition contract-authority
7
+ // stdout: the KernelContractAuthority object, canonical JSON, one line.
8
+ //
9
+ // The kernel's release derives the object at publish time, `sourceCommit` = the commit as the kernel release states it (carried, never proven here: only contentDigest and
10
+ // sourcePaths are proven against the package), and ships
11
+ // it as `dist/edition-protocol/contract-authority.json` (exported by name). This verb resolves that export from
12
+ // this CLI's own resolution, parses it with the kernel's KernelContractAuthoritySchema, refuses it unless its
13
+ // contentDigest is the bundled kernel's edition protocol digest, and then proves the bundled package IS that
14
+ // authority (assertKernelPackageIsCatalogueAuthority). It fails closed: a kernel with no file (a dev build, or a
15
+ // release older than the file) is refused by name, with no fallback to any derived or remembered authority.
16
+
17
+ import { readFileSync, realpathSync } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import { sep } from 'node:path';
20
+
21
+ import { KernelContractAuthoritySchema } from '@xemahq/contracts/distribution';
22
+
23
+ import { canonicalJson } from './canonical-evidence-json.mjs';
24
+ import { readKernelEditionProtocol } from './edition-protocol.mjs';
25
+ import { configureEditionWorkspaceFromArgv, isEntryModule, stateKernelPackageDir } from './edition-workspace.mjs';
26
+ import { KERNEL_PACKAGE_NAME, assertKernelPackageIsCatalogueAuthority, shippedKernelPackageDir } from './kernel-package.mjs';
27
+ import { loadKernelDistributionSchemas } from './lib.mjs';
28
+
29
+ export const CONTRACT_AUTHORITY_EXPORT = '@xemahq/contracts/edition-protocol/contract-authority.json';
30
+ export const CONTRACT_AUTHORITY_USAGE = 'usage: xema edition contract-authority';
31
+
32
+ export class BundledContractAuthorityError extends Error {
33
+ constructor(message) {
34
+ super(message);
35
+ this.name = 'BundledContractAuthorityError';
36
+ }
37
+ }
38
+
39
+ const ownRequire = createRequire(import.meta.url);
40
+
41
+ /**
42
+ * The ONE refusal of a kernel that carries no contract-authority.json, for the verb and for every edition test
43
+ * fixture that reads the authority through this module: the file is emitted only at release, so a dev build (an
44
+ * aggregator-linked kernel) never carries one, by design.
45
+ */
46
+ function missingContractAuthority(kernelDir, detail) {
47
+ return new BundledContractAuthorityError(
48
+ `the ${KERNEL_PACKAGE_NAME} at ${kernelDir} carries no edition-protocol/contract-authority.json (${detail}): the file is emitted ` +
49
+ `only at release, so a dev build of the kernel never has one. The verb, and the edition evidence tests, need a published ` +
50
+ `${KERNEL_PACKAGE_NAME} (install a released @xemahq/xema, or run the tests against the published kernel the CLI's lockfile installs)`,
51
+ );
52
+ }
53
+
54
+ /** The bundled kernel's contract-authority.json, resolved by its package export from this CLI's own resolution. */
55
+ export function resolveBundledContractAuthorityFile({ resolve = (specifier) => ownRequire.resolve(specifier), kernelDir = shippedKernelPackageDir() } = {}) {
56
+ try {
57
+ return resolve(CONTRACT_AUTHORITY_EXPORT);
58
+ } catch (error) {
59
+ if (error?.code !== 'MODULE_NOT_FOUND' && error?.code !== 'ERR_PACKAGE_PATH_NOT_EXPORTED') {throw error;}
60
+ throw missingContractAuthority(kernelDir, error.code);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Read, parse and prove the contract authority at `file` against the kernel package at `kernelDir`: the file must
66
+ * be inside that package, parse as KernelContractAuthoritySchema, carry the package's edition protocol digest, and
67
+ * the package must be that authority. Returns the parsed authority.
68
+ */
69
+ export async function readBundledContractAuthority({
70
+ kernelDir = shippedKernelPackageDir(),
71
+ file = resolveBundledContractAuthorityFile({ kernelDir }),
72
+ loadSchemas = loadKernelDistributionSchemas,
73
+ } = {}) {
74
+ let real;
75
+ try {
76
+ real = realpathSync(file);
77
+ } catch (error) {
78
+ if (error?.code === 'ENOENT') {throw missingContractAuthority(kernelDir, `${file} does not exist`);}
79
+ throw new BundledContractAuthorityError(`${file} cannot be read: ${error.message}`);
80
+ }
81
+ if (!real.startsWith(`${realpathSync(kernelDir)}${sep}`)) {
82
+ throw new BundledContractAuthorityError(`${real} is not inside the kernel package this CLI ships (${kernelDir})`);
83
+ }
84
+ let raw;
85
+ try {
86
+ raw = JSON.parse(readFileSync(real, 'utf8'));
87
+ } catch (error) {
88
+ throw new BundledContractAuthorityError(`${real} is not JSON: ${error.message}`);
89
+ }
90
+ const parsed = KernelContractAuthoritySchema.safeParse(raw);
91
+ if (!parsed.success) {
92
+ const issues = parsed.error.issues.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`).join('; ');
93
+ throw new BundledContractAuthorityError(`${real} is not a kernel contract authority: ${issues}`);
94
+ }
95
+ const authority = parsed.data;
96
+ const bundledDigest = readKernelEditionProtocol(kernelDir).contentDigest;
97
+ if (authority.contentDigest !== bundledDigest) {
98
+ throw new BundledContractAuthorityError(
99
+ `${real} states contentDigest ${authority.contentDigest}, but the bundled kernel's edition protocol digest is ${bundledDigest}`,
100
+ );
101
+ }
102
+ await assertKernelPackageIsCatalogueAuthority({ catalogue: { contractAuthority: authority }, kernelPackageDir: kernelDir, loadSchemas });
103
+ return authority;
104
+ }
105
+
106
+ async function main() {
107
+ const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [], { refuseOthers: true });
108
+ if (rest.length > 0) {throw new Error(CONTRACT_AUTHORITY_USAGE);}
109
+ const kernelDir = shippedKernelPackageDir();
110
+ stateKernelPackageDir(kernelDir);
111
+ const authority = await readBundledContractAuthority({ kernelDir });
112
+ process.stdout.write(`${canonicalJson(authority)}\n`);
113
+ }
114
+
115
+ if (isEntryModule(import.meta.url)) {
116
+ try {
117
+ await main();
118
+ } catch (error) {
119
+ console.error(`[contract-authority] ${error.message}`);
120
+ process.exitCode = 1;
121
+ }
122
+ }
@@ -14,17 +14,17 @@ function requireText(value, what) {
14
14
  }
15
15
 
16
16
  /**
17
+ * The flags that pin a signer's exact identity, issuer, workflow repository and ref. They carry no workflow commit: use
18
+ * {@link certificateConstraintArgs} unless the signing workflow's own commit is, by design, not known to the verifier (a
19
+ * `workflow_run` follower runs the default branch's copy), and then bind the built commit by a signed annotation.
20
+ *
17
21
  * @param {{ certificateIdentity: string, oidcIssuer: string, githubWorkflow: { repository: string, ref: string } }} signer
18
- * @param {string} workflowSha the 40-hex commit the signing workflow ran at
19
- * @returns {string[]} the cosign flags that pin identity, issuer, workflow repository, ref and sha
22
+ * @returns {string[]}
20
23
  */
21
- export function certificateConstraintArgs(signer, workflowSha) {
24
+ export function certificateIdentityArgs(signer) {
22
25
  if (signer === null || typeof signer !== 'object') {
23
26
  throw new Error('certificate constraints: the signer must be an object');
24
27
  }
25
- if (typeof workflowSha !== 'string' || !WORKFLOW_SHA.test(workflowSha)) {
26
- throw new Error(`certificate constraints: the workflow sha must be 40 lowercase hex characters; got ${JSON.stringify(workflowSha)}`);
27
- }
28
28
  return [
29
29
  '--certificate-identity',
30
30
  requireText(signer.certificateIdentity, 'certificateIdentity'),
@@ -34,7 +34,17 @@ export function certificateConstraintArgs(signer, workflowSha) {
34
34
  requireText(signer.githubWorkflow?.repository, 'githubWorkflow.repository'),
35
35
  '--certificate-github-workflow-ref',
36
36
  requireText(signer.githubWorkflow?.ref, 'githubWorkflow.ref'),
37
- '--certificate-github-workflow-sha',
38
- workflowSha,
39
37
  ];
40
38
  }
39
+
40
+ /**
41
+ * @param {{ certificateIdentity: string, oidcIssuer: string, githubWorkflow: { repository: string, ref: string } }} signer
42
+ * @param {string} workflowSha the 40-hex commit the signing workflow ran at
43
+ * @returns {string[]} the cosign flags that pin identity, issuer, workflow repository, ref and sha
44
+ */
45
+ export function certificateConstraintArgs(signer, workflowSha) {
46
+ if (typeof workflowSha !== 'string' || !WORKFLOW_SHA.test(workflowSha)) {
47
+ throw new Error(`certificate constraints: the workflow sha must be 40 lowercase hex characters; got ${JSON.stringify(workflowSha)}`);
48
+ }
49
+ return [...certificateIdentityArgs(signer), '--certificate-github-workflow-sha', workflowSha];
50
+ }
@@ -178,21 +178,35 @@ export function customerRootsOf(command) {
178
178
  // customer form: the flags only it takes, the further flags it requires, and the vendor flags it refuses.
179
179
  const CREDENTIAL_FLAGS = Object.freeze([CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG]);
180
180
  const REPEATABLE = Object.freeze([SOURCE_IMAGE_FLAG]);
181
+ // A vendor develop resolve finds and proves its engine itself (develop-engine-image.mjs); a customer resolve is handed one.
182
+ const ENGINE_IMAGE_FLAG = '--engine-image';
183
+ const ENGINE_REPOSITORY_FLAG = '--engine-repository';
184
+ const ENGINE_SIGNER_FLAG = '--engine-signer';
185
+ export const IMAGES_FROM_FLAG = '--images-from';
181
186
 
182
187
  const COMMANDS = Object.freeze({
183
188
  resolve: {
184
189
  positional: true,
185
- values: ['--pins', '--out', '--registry', '--engine-image'],
190
+ // --images-from: the everyday job's image build record (images-from-record.mjs) instead of the registry probe; vendor only.
191
+ values: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG, IMAGES_FROM_FLAG],
186
192
  switches: ['--local'],
187
- required: ['--pins', '--out', '--registry', '--engine-image'],
188
- customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [], cosign: true },
193
+ required: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
194
+ customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG, ENGINE_IMAGE_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG], cosign: true },
195
+ // A vendor resolve finds its engine itself; the old flag is refused by name, never read as a customer flag.
196
+ removedFromVendor: { [ENGINE_IMAGE_FLAG]: `use ${ENGINE_REPOSITORY_FLAG} and ${ENGINE_SIGNER_FLAG}` },
197
+ },
198
+ engine: {
199
+ positional: true,
200
+ values: ['--pins', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
201
+ switches: [],
202
+ required: ['--pins', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
189
203
  },
190
204
  target: {
191
205
  positional: true,
192
- values: ['--pins', '--registry', '--engine-image'],
206
+ values: ['--pins', '--registry', '--engine-image', IMAGES_FROM_FLAG],
193
207
  switches: [],
194
208
  required: ['--pins', '--registry', '--engine-image'],
195
- customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [] },
209
+ customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG] },
196
210
  },
197
211
  'freeze-graph': {
198
212
  positional: false,
@@ -256,7 +270,8 @@ export function parseEditionVerbArgs(channel, argv) {
256
270
  if (spec === undefined) {
257
271
  throw new Error(`unknown command ${JSON.stringify(command)}; expected ${Object.keys(commands).join(' | ')}`);
258
272
  }
259
- const customerValues = spec.customer?.values ?? [];
273
+ // The flags only the customer form takes: a flag the vendor form takes too (`--cosign` of a develop resolve) does not make it one.
274
+ const customerValues = (spec.customer?.values ?? []).filter((flag) => !spec.values.includes(flag));
260
275
  const options = {};
261
276
  const positional = [];
262
277
  for (let i = 0; i < rest.length; i += 1) {
@@ -283,13 +298,20 @@ export function parseEditionVerbArgs(channel, argv) {
283
298
  }
284
299
  }
285
300
  const customer = spec.customer !== undefined && customerValues.some((flag) => options[flag] !== undefined);
286
- // `--cosign` of a customer form verifies a release catalogue; a vendor verb (develop `coverage`) takes its own `--cosign`.
287
- if (spec.customer?.cosign === true && options[COSIGN_FLAG] !== undefined && channel !== EditionChannel.Release) {
301
+ // `--cosign` of a customer form verifies a release catalogue; a vendor verb (develop `coverage`, `resolve`, `engine`) takes its own `--cosign`.
302
+ if (customer && spec.customer?.cosign === true && options[COSIGN_FLAG] !== undefined && channel !== EditionChannel.Release) {
288
303
  throw new Error(`${COSIGN_FLAG} verifies a release catalogue's evidence; a ${channel} ${command} takes none`);
289
304
  }
290
305
  if (channel === EditionChannel.Release && options[DEFINITION_FLAG] === undefined) {
291
306
  throw new Error(`a ${channel} ${command} requires ${DEFINITION_FLAG}`);
292
307
  }
308
+ // A removed vendor flag with no other sign of a customer form (no --definition, no customer-only flag) is a stale vendor call.
309
+ const removedFlags = Object.keys(spec.removedFromVendor ?? {});
310
+ if (!customerValues.some((flag) => !removedFlags.includes(flag) && options[flag] !== undefined)) {
311
+ for (const flag of removedFlags) {
312
+ if (options[flag] !== undefined) {throw new Error(`${flag} was removed from a vendor ${command}: ${spec.removedFromVendor[flag]}`);}
313
+ }
314
+ }
293
315
  if (customer) {
294
316
  if (options[DEFINITION_FLAG] === undefined) {throw new Error(`a customer ${command} requires ${DEFINITION_FLAG}`);}
295
317
  for (const refused of spec.customer.refused) {
@@ -29,6 +29,7 @@
29
29
 
30
30
  import { basename } from 'node:path';
31
31
 
32
+ import { RefusalError } from './collected-refusals.mjs';
32
33
  import { readDevelopChartRoster } from './develop-chart-roster.mjs';
33
34
  import { assertRosteredChartSetLock } from './edition-chart-set.mjs';
34
35
  import { deployRoot } from './edition-workspace.mjs';
@@ -53,7 +54,7 @@ function deployPublisher() {
53
54
  const deployRepo = basename(deployPinKey());
54
55
  const publishers = chartSetPublishers().filter((publisher) => basename(publisher.repo) === deployRepo);
55
56
  if (publishers.length !== 1) {
56
- throw new Error(
57
+ throw new RefusalError(
57
58
  `${publishers.length} configured chart publishers have repository ${deployRepo} (the deploy pin role); exactly one must, ` +
58
59
  'since a develop chart set is that publisher\'s set at the deploy commit',
59
60
  );
@@ -72,13 +73,13 @@ function committedInfraPublisher() {
72
73
  const path = chartSetLockPath();
73
74
  const committed = readLock(path);
74
75
  if (committed === null) {
75
- throw new Error(`${path} is absent or unreadable; a develop edition carries the release infra chart set from it`);
76
+ throw new RefusalError(`${path} is absent or unreadable; a develop edition carries the release infra chart set from it`);
76
77
  }
77
78
  assertRosteredChartSetLock(committed, path);
78
79
  const indexes = infraPublisherIndexes();
79
80
  const found = committed.publishers.filter((publisher) => indexes.includes(publisher.index));
80
81
  if (indexes.length !== 1 || found.length !== 1) {
81
- throw new Error(
82
+ throw new RefusalError(
82
83
  `${path} carries ${found.length} infra publisher entries (${indexes.length} infra publishers configured); exactly one is required`,
83
84
  );
84
85
  }
@@ -91,7 +92,7 @@ async function published(read, what, { publisher, xemaDeploySha }) {
91
92
  return await read();
92
93
  } catch (error) {
93
94
  if (error?.status !== NOT_FOUND) {throw error;}
94
- throw new Error(`${what} is not published yet; ${publisher.repo} publish-charts for ${xemaDeploySha} not green`, { cause: error });
95
+ throw new RefusalError(`${what} is not published yet; ${publisher.repo} publish-charts for ${xemaDeploySha} not green`, { cause: error });
95
96
  }
96
97
  }
97
98
 
@@ -102,7 +103,7 @@ function assertIndexIsRoster(indexRoster, members, label) {
102
103
  const missing = expected.filter((line) => !actual.includes(line));
103
104
  const extra = actual.filter((line) => !expected.includes(line));
104
105
  if (missing.length > 0 || extra.length > 0) {
105
- throw new Error(
106
+ throw new RefusalError(
106
107
  `${label} does not carry the deploy checkout's develop roster: ` +
107
108
  `missing [${missing.join(', ')}], unexpected [${extra.join(', ')}]`,
108
109
  );
@@ -116,7 +117,7 @@ function assertIndexIsRoster(indexRoster, members, label) {
116
117
  */
117
118
  export async function resolveDevelopChartSet({ xemaDeploySha, deployDir = deployRoot(), readRoster = readDevelopChartRoster, client }) {
118
119
  if (!COMMIT_PATTERN.test(xemaDeploySha ?? '')) {
119
- throw new Error(`the deploy checkout SHA is not a 40-hex commit: ${JSON.stringify(xemaDeploySha)}`);
120
+ throw new RefusalError(`the deploy checkout SHA is not a 40-hex commit: ${JSON.stringify(xemaDeploySha)}`);
120
121
  }
121
122
  const publisher = deployPublisher();
122
123
  const infraPublisher = committedInfraPublisher();
@@ -22,6 +22,7 @@
22
22
  // admission of this edition for a biome it does not ship is a stale permission
23
23
  // and is refused; a declaration without a substantive consequence is refused.
24
24
 
25
+ import { RefusalError } from './collected-refusals.mjs';
25
26
  import { editionWorkspaceConfig } from './edition-config.mjs';
26
27
  import { buildCapabilityDomainOwners, serializeJson } from './lib.mjs';
27
28
 
@@ -40,7 +41,7 @@ export const DevelopEditionInvariantReason = Object.freeze({
40
41
  const MIN_CONSEQUENCE_LENGTH = 40;
41
42
 
42
43
  /** A develop candidate broke a plane invariant. `violations` carries every one, each with its reason. */
43
- export class DevelopEditionInvariantError extends Error {
44
+ export class DevelopEditionInvariantError extends RefusalError {
44
45
  /** @param {string} id @param {{ reason: string, detail: string }[]} violations */
45
46
  constructor(id, violations) {
46
47
  super(
@@ -1,6 +1,6 @@
1
1
  // develop-edition-resolve — resolve and seal one DEVELOP edition from a snapshot.
2
2
  //
3
- // The whole pipeline, in the order the release runs it, over the snapshot's
3
+ // The whole pipeline, in the order the release runs it (every check reports its refusal; see develop-resolve-report.mjs), over the snapshot's
4
4
  // injected inputs: distribution lock -> plane invariants (reservation table,
5
5
  // confinement; develop-edition-invariants.mjs) -> authorization base (composed, never
6
6
  // written) -> images lock -> develop seal, plus the chart-set lock: the frozen
@@ -12,15 +12,21 @@ import { biomeSourceTreeIds, readBiomeSourceFacts, sealLock } from './biome-cont
12
12
  import { buildCatalogue } from './catalogue.mjs';
13
13
  import { resolveDevelopChartSet } from './develop-chart-set.mjs';
14
14
  import { assertDevelopEditionInvariants } from './develop-edition-invariants.mjs';
15
+ import { resolveDevelopEngineImage } from './develop-engine-image.mjs';
15
16
  import { createDevelopImageProbe } from './develop-image-probe.mjs';
17
+ import { BLOCKED, DevelopResolveCheck, RESOLVE_CHECK_DEPENDENCIES, createResolveReport } from './develop-resolve-report.mjs';
16
18
  import { assertFrozenCheckouts, buildSnapshotInputs } from './develop-snapshot-inputs.mjs';
17
19
  import { EditionChannel } from './edition-constants.mjs';
18
20
  import { sealImages } from './edition-image-seal.mjs';
21
+ import { assertKernelEditionProtocol } from './edition-protocol.mjs';
19
22
  import { dataRoot, kernelPackageDir } from './edition-workspace.mjs';
23
+ import { resolveImagesFromRecord, sealImagesFromRecord } from './images-from-record.mjs';
20
24
  import { kernelPackageOfAuthority } from './kernel-package.mjs';
21
25
  import { loadKernelDistributionSchemas } from './lib.mjs';
26
+ import { assertLockBundleSigners, isBundleSignerRefusal } from './locked-bundle-signer.mjs';
27
+ import { admitDistributionLock, ungrantedOptionalLinesOfRefusal } from './operation-grant-admission.mjs';
22
28
  import { deployPinKey } from './pin-source.mjs';
23
- import { composeAuthorizationBase, resolveDistributionWithIndex } from './resolve-distribution.mjs';
29
+ import { admitResolvedLock, composeAuthorizationBase, prepareDistributionWithIndex } from './resolve-distribution.mjs';
24
30
  import { externalSourceTrees, resolveImages, sourceTreeLocator } from './resolve-images.mjs';
25
31
  import { createPinnedSourceAuthority } from './source-authority.mjs';
26
32
  import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs';
@@ -32,12 +38,15 @@ import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs'
32
38
  export const DEVELOP_EDITION_STEPS = Object.freeze({
33
39
  assertFrozenCheckouts,
34
40
  buildSnapshotInputs,
35
- resolveDistributionWithIndex,
41
+ assertKernelEditionProtocol,
42
+ prepareDistributionWithIndex,
43
+ resolveDevelopEngineImage,
36
44
  assertDevelopEditionInvariants,
37
45
  assertAdapterBundlesMatchHost,
38
46
  composeAuthorizationBase,
39
47
  resolveImages,
40
48
  sealImages,
49
+ sealImagesFromRecord,
41
50
  resolveDevelopChartSet,
42
51
  });
43
52
 
@@ -45,46 +54,117 @@ const stderrProgress = (line) => process.stderr.write(`${line}\n`);
45
54
 
46
55
  /**
47
56
  * The first half of every develop resolve: the frozen snapshot's inputs and the
48
- * distribution lock composed over them. `resolveDevelopEdition` and the
49
- * read-only `coverage` verb both start here, so the content hash each computes
50
- * for a subject is the same by construction.
57
+ * distribution lock composed over them, NOT yet admitted. `resolveDevelopEdition`
58
+ * admits it as one of its checks; `resolveDevelopSnapshot` admits it at once. Both
59
+ * start here, so the content hash each computes for a subject is the same by construction.
51
60
  */
52
- export async function resolveDevelopSnapshot({ id, pins, steps = DEVELOP_EDITION_STEPS, phase = () => {} }) {
61
+ async function prepareDevelopSnapshot({ id, pins, steps, phase }) {
53
62
  const schemas = await loadKernelDistributionSchemas();
54
63
  steps.assertFrozenCheckouts(pins);
55
64
  const inputs = await steps.buildSnapshotInputs({ id, pins, schemas });
56
65
  phase('snapshot inputs built');
57
- const { lock, index } = await steps.resolveDistributionWithIndex(id, { inputs });
58
- return { schemas, inputs, lock, index };
66
+ const prepared = await steps.prepareDistributionWithIndex(id, { inputs });
67
+ // The schemas the lock was composed with are the ones that admit it.
68
+ return { inputs, ...prepared };
69
+ }
70
+
71
+ /** The read-only `coverage` verb's snapshot: prepared as the resolve prepares it, then admitted (a refusal throws). */
72
+ export async function resolveDevelopSnapshot({ id, pins, steps = DEVELOP_EDITION_STEPS, phase = () => {} }) {
73
+ const snapshot = await prepareDevelopSnapshot({ id, pins, steps, phase });
74
+ admitResolvedLock(snapshot.schemas, snapshot.lock);
75
+ return snapshot;
59
76
  }
60
77
 
61
- export async function resolveDevelopEdition({ id, pins, env = process.env, sealOptions = {}, fetchKernelTarball, fetchManifest = fetchRegistryManifest, progress = stderrProgress, steps = DEVELOP_EDITION_STEPS }) {
78
+ /**
79
+ * @param {object} request
80
+ * @param {object} [request.imagesFrom] a parsed image build record (images-from-record.mjs): the digests are the record's,
81
+ * with no probe and no rehash, and `verifySatellites` (createSatelliteVerifier) admits its satellite entries
82
+ */
83
+ export async function resolveDevelopEdition({ id, pins, engine, cosignBinary, env = process.env, sealOptions = {}, fetchKernelTarball, fetchManifest = fetchRegistryManifest, progress = stderrProgress, steps = DEVELOP_EDITION_STEPS, imagesFrom, verifySatellites }) {
84
+ if ((imagesFrom === undefined) !== (verifySatellites === undefined)) {
85
+ throw new Error('resolveDevelopEdition takes an image build record and its satellite verifier together, or neither');
86
+ }
62
87
  const startedAt = Date.now();
63
88
  // One line per phase, with the time since the resolve began, so the step is never silent for long.
64
89
  const phase = (name) => progress(`[develop-edition] ${name} (+${Date.now() - startedAt}ms)`);
65
- const { schemas, inputs, lock, index } = await resolveDevelopSnapshot({ id, pins, steps, phase });
90
+ // Every check runs unless a check it needs was refused; all refusals are reported together, and nothing is sealed or written then.
91
+ const report = createResolveReport();
92
+ const checked = (check, fn, options) => report.run(check, RESOLVE_CHECK_DEPENDENCIES[check], fn, options);
93
+ const chartSetLock = await checked(DevelopResolveCheck.ChartSet, async () => {
94
+ const chartSet = await steps.resolveDevelopChartSet({ xemaDeploySha: pins[deployPinKey()] });
95
+ phase('chart set resolved');
96
+ return chartSet;
97
+ });
98
+ const engineImage = await checked(DevelopResolveCheck.EngineImage, async () => {
99
+ const image = await steps.resolveDevelopEngineImage({ pins, engine, cosignBinary, env });
100
+ phase('engine image resolved');
101
+ return image;
102
+ });
103
+ await checked(DevelopResolveCheck.EditionProtocol, () => steps.assertKernelEditionProtocol(kernelPackageDir()));
104
+ const snapshot = await checked(DevelopResolveCheck.DistributionLock, () => prepareDevelopSnapshot({ id, pins, steps, phase }), {
105
+ classify: (error) => (isBundleSignerRefusal(error) ? DevelopResolveCheck.BundleSigner : DevelopResolveCheck.DistributionLock),
106
+ });
107
+ const lockProduced = snapshot !== BLOCKED;
108
+ const { schemas, inputs, lock, index } = lockProduced ? snapshot : {};
109
+ if (lockProduced) {
110
+ await checked(DevelopResolveCheck.BundleSigner, () => assertLockBundleSigners({ lock, schemas }));
111
+ }
66
112
  // The plane invariants, against the graph this candidate froze (develop-edition-invariants.mjs).
67
- const graded = steps.assertDevelopEditionInvariants({ id, lock, biomeIndex: inputs.biomeIndex, schemas });
68
- phase(`reservation table asserted (${graded.reservedDomains} domains); confinement asserted (${graded.confinedBiomes} declaration(s))`);
69
- await steps.assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(index), fetchManifest });
70
- const authorizationLock = await steps.composeAuthorizationBase(id, lock, index);
71
- phase('distribution lock resolved');
113
+ await checked(DevelopResolveCheck.PlaneInvariants, () => {
114
+ const graded = steps.assertDevelopEditionInvariants({ id, lock, biomeIndex: inputs.biomeIndex, schemas });
115
+ phase(`reservation table asserted (${graded.reservedDomains} domains); confinement asserted (${graded.confinedBiomes} declaration(s))`);
116
+ });
117
+ await checked(DevelopResolveCheck.OperationAdmission, () => {
118
+ try {
119
+ admitDistributionLock(schemas, lock, { report: (line) => report.notice(line) });
120
+ } catch (error) {
121
+ ungrantedOptionalLinesOfRefusal(error).forEach((line) => report.notice(line));
122
+ throw error;
123
+ }
124
+ phase('operation requirements admitted');
125
+ });
126
+ await checked(DevelopResolveCheck.AdapterBundles, () => steps.assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(index), fetchManifest }));
127
+ const authorizationLock = await checked(DevelopResolveCheck.AuthorizationBase, async () => {
128
+ const composed = await steps.composeAuthorizationBase(id, lock, index);
129
+ phase('distribution lock resolved');
130
+ return composed;
131
+ });
72
132
  // Every subject's develop image is probed the moment its hash is known, in
73
133
  // parallel with the hashing of the rest; an absent image refuses here, naming
74
134
  // all of them, before any later phase runs.
75
- const probe = createDevelopImageProbe({
76
- distributionId: id,
77
- env,
78
- ...(sealOptions.distributionsRoot === undefined ? {} : { distributionsRoot: sealOptions.distributionsRoot }),
79
- ...(sealOptions.fetchImpl === undefined ? {} : { fetchImpl: sealOptions.fetchImpl }),
80
- ...(sealOptions.run === undefined ? {} : { run: sealOptions.run }),
81
- progress,
135
+ let recorded;
136
+ const imageLock = await checked(DevelopResolveCheck.DevelopImages, async () => {
137
+ if (imagesFrom !== undefined) {
138
+ const fromRecord = await resolveImagesFromRecord({ id, inputs, lock, record: imagesFrom, resolveImages: steps.resolveImages, verifySatellites });
139
+ ({ recorded } = fromRecord);
140
+ phase(`image subjects taken from the build record (${fromRecord.imageLock.subjects.length} subjects); satellites verified`);
141
+ return fromRecord.imageLock;
142
+ }
143
+ const probe = createDevelopImageProbe({
144
+ distributionId: id,
145
+ env,
146
+ ...(sealOptions.distributionsRoot === undefined ? {} : { distributionsRoot: sealOptions.distributionsRoot }),
147
+ ...(sealOptions.fetchImpl === undefined ? {} : { fetchImpl: sealOptions.fetchImpl }),
148
+ ...(sealOptions.run === undefined ? {} : { run: sealOptions.run }),
149
+ progress,
150
+ });
151
+ const resolved = await steps.resolveImages(id, { inputs, lock, onSubjectHashed: probe.observe });
152
+ phase(`image content hashes computed (${resolved.imageLock.subjects.length} subjects)`);
153
+ await probe.settle();
154
+ phase('every develop image is present');
155
+ return resolved.imageLock;
82
156
  });
83
- const { imageLock } = await steps.resolveImages(id, { inputs, lock, onSubjectHashed: probe.observe });
84
- phase(`image content hashes computed (${imageLock.subjects.length} subjects)`);
85
- await probe.settle();
86
- phase('every develop image is present');
87
- const sealedImageLock = await steps.sealImages({ channel: EditionChannel.Develop, imageLock, env, progress, thirdPartyPins: loadTrustedThirdPartySourcePins(dataRoot()), ...sealOptions });
157
+ try {
158
+ report.settle();
159
+ } catch (error) {
160
+ progress(error.message);
161
+ throw error;
162
+ }
163
+ if (report.noticeText() !== '') {progress(report.noticeText());}
164
+ const sealArguments = { imageLock, env, progress, thirdPartyPins: loadTrustedThirdPartySourcePins(dataRoot()), ...sealOptions };
165
+ const sealedImageLock = recorded === undefined
166
+ ? await steps.sealImages({ channel: EditionChannel.Develop, ...sealArguments })
167
+ : await steps.sealImagesFromRecord({ ...sealArguments, recorded });
88
168
  phase('images sealed');
89
169
  // The unsealed lock becomes the one v5 grammar only here, once the images it
90
170
  // owns are sealed: each biomeContentHash is recomputed from the frozen sources.
@@ -100,8 +180,6 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
100
180
  phase(`biome sources read (${sourceFactsById.size} biomes)`);
101
181
  const sealedLock = await sealLock({ ...biomeSources, unsealed: lock, sourceFactsById });
102
182
  phase('distribution lock sealed');
103
- const chartSetLock = await steps.resolveDevelopChartSet({ xemaDeploySha: pins[deployPinKey()] });
104
- phase('chart set resolved');
105
183
  const sourceTreeIds = await biomeSourceTreeIds({ ...biomeSources, lock, sourceFactsById });
106
184
  const catalogue = buildCatalogue({
107
185
  sourceTreeIds,
@@ -121,5 +199,5 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
121
199
  leaf: inputs.composed.leaf,
122
200
  });
123
201
  phase('catalogue built');
124
- return { distributionLock: sealedLock, imageLock: sealedImageLock, authorizationLock, chartSetLock, catalogue };
202
+ return { distributionLock: sealedLock, imageLock: sealedImageLock, authorizationLock, chartSetLock, catalogue, engineImage };
125
203
  }