@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
@@ -66,6 +66,7 @@ import {
66
66
  evidenceDirectoryForDigest,
67
67
  sha256Digest,
68
68
  } from './image-evidence.mjs';
69
+ import { evidenceSubjectKey } from './image-subject-identity.mjs';
69
70
  import { loadKernelDistributionSchemas } from './lib.mjs';
70
71
  import {
71
72
  assertProducerInput,
@@ -124,6 +125,29 @@ function assertContainedRelativePath(value, label) {
124
125
  *
125
126
  * @returns {{ evidenceDirectory: string, reusable: boolean, reason: string }}
126
127
  */
128
+ /**
129
+ * The key of an index entry: its subject's evidence identity, which for a composed artifact names the generation, so it is read
130
+ * from the statement the entry points at (the index states only kind and id). An entry whose statement cannot be read keeps
131
+ * `kind\0id`; the unusable statement is reported below.
132
+ */
133
+ export function entryKey(entry, absoluteDirectory) {
134
+ try {
135
+ const { predicate } = JSON.parse(
136
+ readBoundedFile(join(absoluteDirectory, assertContainedRelativePath(entry.evidence.statementPath, 'statementPath'))).toString('utf8'),
137
+ );
138
+ return evidenceSubjectKey({
139
+ kind: entry.subjectKind,
140
+ id: entry.subjectId,
141
+ imageRef: predicate.artifact.imageRepository,
142
+ contentHash: predicate.source.sourceTreeHash,
143
+ });
144
+ } catch {
145
+ return `${entry.subjectKind}\0${entry.subjectId}`;
146
+ }
147
+ }
148
+
149
+ const printable = (key) => key.replaceAll('\0', ':');
150
+
127
151
  export async function assessCommittedEvidence({
128
152
  input,
129
153
  repositoryRoot,
@@ -190,7 +214,12 @@ export async function assessCommittedEvidence({
190
214
  }
191
215
  const expectedByKey = new Map(
192
216
  expectedStatements.map((statement) => [
193
- `${statement.predicate.source.kind}:${statement.predicate.source.id}`,
217
+ evidenceSubjectKey({
218
+ kind: statement.predicate.source.kind,
219
+ id: statement.predicate.source.id,
220
+ imageRef: statement.predicate.artifact.imageRepository,
221
+ contentHash: statement.predicate.source.sourceTreeHash,
222
+ }),
194
223
  statement,
195
224
  ]),
196
225
  );
@@ -200,20 +229,20 @@ export async function assessCommittedEvidence({
200
229
  if (entry === null || typeof entry !== 'object') {
201
230
  return unusable('producer-evidence.json carries a non-object entry');
202
231
  }
203
- const key = `${entry.subjectKind}:${entry.subjectId}`;
232
+ const key = entryKey(entry, absoluteDirectory);
204
233
  if (seen.has(key)) {
205
- return unusable(`producer-evidence.json duplicates subject ${key}`);
234
+ return unusable(`producer-evidence.json duplicates subject ${printable(key)}`);
206
235
  }
207
236
  seen.add(key);
208
237
  const expected = expectedByKey.get(key);
209
238
  if (expected === undefined) {
210
239
  return unusable(
211
- `committed evidence carries subject ${key}, which this input does not evidence`,
240
+ `committed evidence carries subject ${printable(key)}, which this input does not evidence`,
212
241
  );
213
242
  }
214
243
  const descriptor = entry.evidence;
215
244
  if (descriptor === null || typeof descriptor !== 'object') {
216
- return unusable(`subject ${key} has no evidence descriptor`);
245
+ return unusable(`subject ${printable(key)} has no evidence descriptor`);
217
246
  }
218
247
  let statementBytes;
219
248
  let bundleBytes;
@@ -223,7 +252,7 @@ export async function assessCommittedEvidence({
223
252
  absoluteDirectory,
224
253
  assertContainedRelativePath(
225
254
  descriptor.statementPath,
226
- `subject ${key} statementPath`,
255
+ `subject ${printable(key)} statementPath`,
227
256
  ),
228
257
  ),
229
258
  );
@@ -232,25 +261,25 @@ export async function assessCommittedEvidence({
232
261
  absoluteDirectory,
233
262
  assertContainedRelativePath(
234
263
  descriptor.bundlePath,
235
- `subject ${key} bundlePath`,
264
+ `subject ${printable(key)} bundlePath`,
236
265
  ),
237
266
  ),
238
267
  );
239
268
  } catch (error) {
240
- return unusable(`subject ${key} artifacts are unreadable: ${error.message}`);
269
+ return unusable(`subject ${printable(key)} artifacts are unreadable: ${error.message}`);
241
270
  }
242
271
  if (
243
272
  sha256Digest(statementBytes) !== descriptor.statementDigest ||
244
273
  sha256Digest(bundleBytes) !== descriptor.bundleDigest
245
274
  ) {
246
275
  return unusable(
247
- `subject ${key} artifacts do not match the digests producer-evidence.json records`,
276
+ `subject ${printable(key)} artifacts do not match the digests producer-evidence.json records`,
248
277
  );
249
278
  }
250
279
  try {
251
280
  validateProducedBundle(statementBytes, bundleBytes);
252
281
  } catch (error) {
253
- return unusable(`subject ${key} bundle is malformed: ${error.message}`);
282
+ return unusable(`subject ${printable(key)} bundle is malformed: ${error.message}`);
254
283
  }
255
284
  let committed;
256
285
  try {
@@ -259,7 +288,7 @@ export async function assessCommittedEvidence({
259
288
  );
260
289
  } catch (error) {
261
290
  return unusable(
262
- `subject ${key} statement does not parse under the kernel schema: ${error.message}`,
291
+ `subject ${printable(key)} statement does not parse under the kernel schema: ${error.message}`,
263
292
  );
264
293
  }
265
294
  // producerRun is deliberately fresh on every execution (run id, attempt,
@@ -269,24 +298,24 @@ export async function assessCommittedEvidence({
269
298
  // validate the existing run's own signature and lifetime independently.
270
299
  if (!producerStatementMatchesInput(committed, expected)) {
271
300
  return unusable(
272
- `subject ${key} committed statement is not bound to this producer input ` +
301
+ `subject ${printable(key)} committed statement is not bound to this producer input ` +
273
302
  '(authority, source, build, artifact or platform set changed)',
274
303
  );
275
304
  }
276
305
  const expiresAt = Date.parse(committed.predicate.producerRun.expiresAt);
277
306
  if (!Number.isFinite(expiresAt)) {
278
- return unusable(`subject ${key} statement has an unreadable expiresAt`);
307
+ return unusable(`subject ${printable(key)} statement has an unreadable expiresAt`);
279
308
  }
280
309
  const remainingSeconds = Math.floor((expiresAt - now) / 1000);
281
310
  if (remainingSeconds <= 0) {
282
311
  return unusable(
283
- `subject ${key} evidence EXPIRED at ${committed.predicate.producerRun.expiresAt} — ` +
312
+ `subject ${printable(key)} evidence EXPIRED at ${committed.predicate.producerRun.expiresAt} — ` +
284
313
  'it can never verify again and must be re-produced',
285
314
  );
286
315
  }
287
316
  if (remainingSeconds < MIN_REMAINING_TTL_SECONDS) {
288
317
  return unusable(
289
- `subject ${key} evidence expires in ${remainingSeconds}s, under the ` +
318
+ `subject ${printable(key)} evidence expires in ${remainingSeconds}s, under the ` +
290
319
  `${MIN_REMAINING_TTL_SECONDS}s reuse floor — refreshing it now`,
291
320
  );
292
321
  }
@@ -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,25 +178,44 @@ 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';
186
+ export const PREVIOUS_LOCK_FLAG = '--previous-lock';
187
+ export const PREVIOUS_LOCK_SHA256_FLAG = '--previous-lock-sha256';
188
+ export const FIRST_SEAL_FLAG = '--first-seal';
181
189
 
182
190
  const COMMANDS = Object.freeze({
183
191
  resolve: {
184
192
  positional: true,
185
- values: ['--pins', '--out', '--registry', '--engine-image'],
186
- 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
+ // --images-from: the everyday job's image build record (images-from-record.mjs) instead of the registry probe; vendor only.
194
+ // --previous-lock with --previous-lock-sha256 (the digest of the latest signed, published edition's images.lock layer, passed by the workflow under
195
+ // the per-edition concurrency group) or --first-seal: the previous seal whose composed artifacts carry their generation forward; vendor only.
196
+ values: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG, IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG],
197
+ switches: ['--local', FIRST_SEAL_FLAG],
198
+ required: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
199
+ customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG, COSIGN_FLAG, ENGINE_IMAGE_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, FIRST_SEAL_FLAG], cosign: true },
200
+ // A vendor resolve finds its engine itself; the old flag is refused by name, never read as a customer flag.
201
+ removedFromVendor: { [ENGINE_IMAGE_FLAG]: `use ${ENGINE_REPOSITORY_FLAG} and ${ENGINE_SIGNER_FLAG}` },
202
+ },
203
+ engine: {
204
+ positional: true,
205
+ values: ['--pins', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
206
+ switches: [],
207
+ required: ['--pins', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
189
208
  },
190
209
  target: {
191
210
  positional: true,
192
- values: ['--pins', '--registry', '--engine-image'],
211
+ values: ['--pins', '--registry', '--engine-image', IMAGES_FROM_FLAG],
193
212
  switches: [],
194
213
  required: ['--pins', '--registry', '--engine-image'],
195
- customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [] },
214
+ customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [IMAGES_FROM_FLAG] },
196
215
  },
197
216
  'freeze-graph': {
198
217
  positional: false,
199
- values: ['--data-sha', '--kernel-sha', '--deploy-sha', '--editions-root-url', '--overrides', '--out'],
218
+ values: ['--data-sha', '--kernel-sha', '--deploy-sha', '--editions-root-url', '--overrides', '--member-pins', '--out'],
200
219
  switches: [],
201
220
  required: ['--data-sha', '--kernel-sha', '--deploy-sha', '--out'],
202
221
  },
@@ -219,6 +238,12 @@ const COMMANDS = Object.freeze({
219
238
  switches: [],
220
239
  required: ['--branch', '--registry', '--cosign', '--out'],
221
240
  },
241
+ 'image-record': {
242
+ positional: true,
243
+ values: ['--selection', '--pins', '--out'],
244
+ switches: [],
245
+ required: ['--selection', '--pins', '--out'],
246
+ },
222
247
  'verify-image-record': {
223
248
  positional: false,
224
249
  values: ['--image', '--producer', '--registry', '--cosign'],
@@ -256,7 +281,8 @@ export function parseEditionVerbArgs(channel, argv) {
256
281
  if (spec === undefined) {
257
282
  throw new Error(`unknown command ${JSON.stringify(command)}; expected ${Object.keys(commands).join(' | ')}`);
258
283
  }
259
- const customerValues = spec.customer?.values ?? [];
284
+ // The flags only the customer form takes: a flag the vendor form takes too (`--cosign` of a develop resolve) does not make it one.
285
+ const customerValues = (spec.customer?.values ?? []).filter((flag) => !spec.values.includes(flag));
260
286
  const options = {};
261
287
  const positional = [];
262
288
  for (let i = 0; i < rest.length; i += 1) {
@@ -283,13 +309,20 @@ export function parseEditionVerbArgs(channel, argv) {
283
309
  }
284
310
  }
285
311
  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) {
312
+ // `--cosign` of a customer form verifies a release catalogue; a vendor verb (develop `coverage`, `resolve`, `engine`) takes its own `--cosign`.
313
+ if (customer && spec.customer?.cosign === true && options[COSIGN_FLAG] !== undefined && channel !== EditionChannel.Release) {
288
314
  throw new Error(`${COSIGN_FLAG} verifies a release catalogue's evidence; a ${channel} ${command} takes none`);
289
315
  }
290
316
  if (channel === EditionChannel.Release && options[DEFINITION_FLAG] === undefined) {
291
317
  throw new Error(`a ${channel} ${command} requires ${DEFINITION_FLAG}`);
292
318
  }
319
+ // A removed vendor flag with no other sign of a customer form (no --definition, no customer-only flag) is a stale vendor call.
320
+ const removedFlags = Object.keys(spec.removedFromVendor ?? {});
321
+ if (!customerValues.some((flag) => !removedFlags.includes(flag) && options[flag] !== undefined)) {
322
+ for (const flag of removedFlags) {
323
+ if (options[flag] !== undefined) {throw new Error(`${flag} was removed from a vendor ${command}: ${spec.removedFromVendor[flag]}`);}
324
+ }
325
+ }
293
326
  if (customer) {
294
327
  if (options[DEFINITION_FLAG] === undefined) {throw new Error(`a customer ${command} requires ${DEFINITION_FLAG}`);}
295
328
  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();
@@ -0,0 +1,29 @@
1
+ // develop-current-resolution — the members that keep the CURRENT develop resolution instead of a signed build record.
2
+ //
3
+ // A member keeps its current resolution (develop HEAD freeze plus the registry probe) only when the data root declares
4
+ // it: `currentResolution: "<ticket>"` on its `firstPartySources` entry in edition-workspace.json, the ticket that
5
+ // removes the exception (today xema-docs, whose develop lane is unsigned by owner decision and which leaves the
6
+ // edition). It is a declared exception, not a fallback: absent means none, and every other member without a record
7
+ // still refuses.
8
+
9
+ import { compareStrings } from './lib.mjs';
10
+
11
+ /** Whether a member (or its `firstPartySources` entry) keeps its current resolution. */
12
+ export const keepsCurrentResolution = (member) => Object.hasOwn(member, 'currentResolution');
13
+
14
+ /** The graph `members` that declare a current resolution, repo -> ticket, keys sorted: what a selection records. */
15
+ export function currentResolutionOf(members) {
16
+ return Object.fromEntries(
17
+ members
18
+ .filter(keepsCurrentResolution)
19
+ .map(({ repo, currentResolution }) => [repo, currentResolution])
20
+ .sort(([a], [b]) => compareStrings(a, b)),
21
+ );
22
+ }
23
+
24
+ /** Why a selection's `currentResolution` (absent = none) is not exactly the one the graph `members` declare; null when it is. */
25
+ export function currentResolutionProblem(declared, members) {
26
+ const expected = currentResolutionOf(members);
27
+ if (JSON.stringify(declared ?? {}) === JSON.stringify(expected)) {return null;}
28
+ return `"currentResolution" is ${JSON.stringify(declared)}, not ${JSON.stringify(expected)}, the members the data root declares keep their current resolution`;
29
+ }
@@ -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(