@xemahq/xema 0.58.0 → 0.59.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.
package/edition/README.md CHANGED
@@ -83,6 +83,8 @@ xema edition develop <freeze-sources|resolve|target|check-existing> ...
83
83
  # develop tag = sha256 of { cli, engine, pins, sourceImages }; target and check-existing take --engine-image, and a customer's --source-image
84
84
  xema edition develop coverage <id> --pins <file> --cosign <abs path> [--json] # read-only forecast (policy held to its pin, this CLI's own verifier code): is each develop image producer-signed? exit 0 covered, 1 not, 2 error
85
85
  xema edition develop select-records --branch develop --registry <root> --cosign <abs path> --out <file> [--at <ISO-8601 UTC>] [--published-pins <file>] # newest signed build record per graph member (the newest COMMIT on develop, every record verified; --published-pins keeps a published commit the records are not ahead of, part 07 passes it) -> { pins, snapshot, behind, images, currentResolution }, written once (a member whose firstPartySources entry declares `currentResolution: "<ticket>"`, xema-docs until XEMA-2373, is skipped with no registry read, listed in currentResolution and printed as `xema-docs: current resolution (leaves the edition, XEMA-2373)`) (behind = { <repo>: { head, commits } }: develop's head and the commits the pinned one trails it by; stdout gets one `<short name>: record <commit>, <N> commits behind HEAD` line per member, always `commits`, short name = the repo's last path segment and unambiguous; images[] entries carry `member`); any bad record refuses the run (needs dataRootRepositoryUrl in edition-workspace.json; anyone who can delete registry tags can remove newer records and this then pins the newest remaining valid one, and the guard is part 07's: deploy-latest's owner refuses a snapshot that goes backwards)
86
+ xema edition develop follow --definition-at <owner>/<repo>,<path>,<dataRoot> --data-sha <sha> --kernel-sha <sha> --deploy-sha <upper bound> --engine-repository <registry>/<path> --registry <root> --cosign <abs path> (--first | --current-pins <file>) --out <file> --seals-out <dir> --previous-seal-signer <identity> (--previous-lock <lock> --previous-lock-sha256 <hex> | --first-seal) # the COMPLETE frozen pins a dev deployment's follow policy selects, written once: members by select-records' newest-commit rule over their signed build records with the current pins as the published pins (a diverged member is refused by name), pinned as freeze-graph --member-pins pins them; the deploy root the newest first-parent commit at or below --deploy-sha whose engine sha-<12 hex> is published (unpublished skipped, none within the walk refused by name); the data/kernel/deploy roots by freeze-graph's rules and, from current pins, never behind or beside them (ancestry in each root's own checkout, which needs full history); declared sources at their develop heads, newer-wins against the current pin by the same ancestry rule. The data root checkout is the definition (its declared repository, clean at --data-sha, data root and distribution directory as named); the kernel checkout is at --kernel-sha. Beside the pins, --seals-out (written once) freezes both previous seals: the offering's newest signed develop edition's images lock, read once under --previous-seal-signer, and the customer's own previous lock (or first seal), passed through; seals.json names each file and its sha256. Prints the summary; --first says it is a first follow
87
+ xema edition develop resolve --definition-at <owner>/<repo>,<path>,<dataRoot> --offering <owner>/<repo>,<path>,<dataRoot> --pins <file> --out <dir> --registry <root> --engine-repository <registry>/<path> --engine-signer <identity> --cosign <abs path> --offering-previous-lock <lock> --offering-previous-lock-sha256 <hex> (--previous-lock <lock> --previous-lock-sha256 <hex> | --first-seal) [--source-image ...] # catalogue-free customer resolve: --pins is follow's frozen pins plus "definition-repository" (the definition's commit); the vendor step resolves the offering at the vendor pins under the offering's id and previous seal (the frozen offering lock), the customer step reads the definition at its commit and seals its own composed rows against the customer's previous lock. Reads no heads, pushes nothing; the same frozen inputs give the same bytes
86
88
  xema edition develop image-record <id> --selection <file> --pins <file> --out <file> # the edition's image build record (what `resolve --images-from` reads) from a select-records selection: subjects are the checked-out snapshot's first-party image subjects; a current-resolution member's (xema-docs) is probed and goes in `images`, every other takes the selection's image of its develop repository (selected commit must equal the snapshot's) and goes in `satellites`; platforms read by digest; any gap refuses and writes nothing; written once
87
89
  xema edition develop verify-image-record --image <name>@sha256:<digest> --producer <owner/repo> --registry <root> --cosign <abs path> # is the digest bound to the producer by an authenticated develop build record (shared verify core with select-records). Records are read newest run first (listBuildRecordRuns sorts by compareBuildRecordRuns: run, then attempt, highest first), a verified record of a workflow other than the producer's `.github/workflows/ci.yaml` is skipped, and the answer is the newest ci.yaml record listing the exact image -> { image, digest, producer, commit }. `commit` is the commit of the newest verified ci.yaml record (by run number, then attempt) that lists the digest; it can be an older commit when a later run targeted it, and for an image the producer reused by content hash it is NOT the commit that built it. This is safe because the digest is what binds the bytes. Refuses when no ci.yaml record lists the image, the record names another producer, or cosign refuses the identity
88
90
  xema edition develop check-overrides --overrides <json> --data-root <dir> # data root only
@@ -184,6 +186,8 @@ A write of `images.lock.json` (`resolve-images`, `xema edition develop resolve`)
184
186
 
185
187
  - `--previous-seal-signer <certificate identity>` (develop resolve, vendor form): the CLI lists the edition's own repository (`<registry root>/develop-editions/xema-edition-<id>`), reads every snapshot tag, and takes the newest edition by its `org.opencontainers.image.created` annotation (inside the signed manifest). An edition with no signature was never published and is passed over; the newest signed one must verify, offline through the pinned cosign, for exactly that identity and the GitHub OIDC issuer, or the resolve is refused (it never steps back to an older edition). Its images.lock layer is fetched by digest. The run must be under the per-edition concurrency group, so the edition read is the one the previous run pushed.
186
188
  - `--previous-lock <lock> --previous-lock-sha256 <hex>`: the same layer, taken by the caller; the CLI refuses unless the bytes hash to the digest.
189
+
190
+ A customer develop resolve (`--definition`, or the catalogue-free form) takes `--previous-lock`/`--previous-lock-sha256` or `--first-seal` (never `--previous-seal-signer`), under the customer's id, and it feeds only the customer's own composed rows; the catalogue rows are the catalogue's. In the catalogue-free form the vendor rows carry forward from the offering's own seal, `--offering-previous-lock`/`--offering-previous-lock-sha256` (frozen by `develop follow`), under the offering's id. A release customer resolve takes none.
187
191
  - `--first-seal`: generation 1, only for a distribution that has never had an edition. The CLI never infers it: a 404 for the repository (a registry may hide a repository from a credential) and a repository with no signed edition (retention) are refused, naming `--first-seal`.
188
192
 
189
193
  The in-repo resolve and pin/unpin take the committed `images.lock.json` at the target path as the previous lock (first seal when absent).
@@ -243,13 +247,15 @@ A held commit is authorized, never trusted, and nothing overrides it: the commit
243
247
  A customer edition is resolved against a catalogue pinned in `catalogue.pin.json` beside the definition file. The catalogue is the ceiling: it supplies availability, pins and digests and never adds a component. It also names the kernel: `kernel` is `{name, version, integrity}` (`@xemahq/contracts`, an exact `x.y.z`, the npm `sha512-` integrity of that tarball), and a catalogue without it is refused. Its `ceiling.storePublisherTrust` is the offering lock's Store publishers (or `null`); a definition selects among them with `storePublisherAnchorKeys`, the resolve copies the selected entries into the customer lock, and the kernel's `customerLockCeilingIssues` refuses, at resolve, seal and verify, an entry the definition does not select, a key the catalogue does not vouch for, or an entry that differs from the catalogue's.
244
248
 
245
249
  ```bash
246
- xema edition resolve --definition <file> --channel <release|develop> \
250
+ xema edition resolve --definition <file> --channel release \
247
251
  --data-root <dir> --sources-root <dir> --cache-dir <dir> --out <dir> \
248
252
  --source-image <source>=<registry>/<prefix> [--catalogue-username <u> --catalogue-token-env <VAR>]
249
253
  xema edition release resolve --definition <file> --cosign <abs path> ... # release: E4 evidence verified before the catalogue is read
250
254
  xema edition resolve-digests <id> --definition <file> --data-root <dir> --editions-root <dir> --sources-root <dir> --cache-dir <dir>
251
255
  ```
252
256
 
257
+ `--channel develop` is refused on this verb: it takes no previous seal, so a customer's own composed rows would restart at generation 1. Resolve a develop customer edition with `xema edition develop resolve --definition <file> (--previous-lock <file> --previous-lock-sha256 <hex> | --first-seal) ...`.
258
+
253
259
  To use a customer's own registry, copy the catalogue artifact and vendor images by digest with `oras cp`, pin the mirrored catalogue in `catalogue.pin.json`, and declare `imageRegistryMirror: {complete: true, map: [{source, target}]}` in the definition.
254
260
 
255
261
  A customer verb takes no `--kernel` (it is refused) and needs no kernel checkout and no credential for the kernel. After it fetches its catalogue it downloads `@xemahq/contracts@<catalogue.kernel.version>` from registry.npmjs.org into `<--cache-dir>/kernel-npm/`, holds the tarball to the catalogue's npm integrity before extracting it, and proves the package is the catalogue's contract authority (final protocol identity carrying `contractAuthority.contentDigest`) before it loads any schema.
@@ -19,6 +19,7 @@
19
19
  import { execFileSync } from 'node:child_process';
20
20
  import { relative, sep } from 'node:path';
21
21
 
22
+ import { CatalogueLockField, catalogueLockFieldOf } from './catalogue-lock-field.mjs';
22
23
  import { assertCustomerLockWithinCatalogueCeiling } from './catalogue.mjs';
23
24
  import { readGitJsonAtCommit } from './lib.mjs';
24
25
  import { assertLockBundleSigners } from './locked-bundle-signer.mjs';
@@ -198,21 +199,28 @@ async function catalogueFacts({ schemas, biome, imageLock, catalogue }) {
198
199
 
199
200
  /**
200
201
  * Customer mode: the lock must say which catalogue produced its hashes, and it must be
201
- * the catalogue supplied. A lock without `catalogueDigest`, or with another one, was not
202
- * resolved against this catalogue, so its hashes cannot be checked on this catalogue's authority.
202
+ * the catalogue supplied, in the same field: `catalogueDigest` (a published catalogue artifact)
203
+ * or `catalogueContentDigest` (the catalogue bytes of a catalogue-free resolve), exactly one supplied
204
+ * and the lock carrying that one only. A lock without it, with another digest, or naming the catalogue
205
+ * in the other field, was not resolved against this catalogue, so its hashes cannot be checked on
206
+ * this catalogue's authority.
203
207
  */
204
- function assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, storePublisherAnchorKeys }) {
205
- if (catalogueDigest === undefined) {
206
- throw new BiomeContentHashError(`customer mode needs the digest of catalogue "${catalogue.offeringId}"`);
208
+ function assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys }) {
209
+ const { key, digest } = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what: `customer mode for catalogue "${catalogue.offeringId}"`, ErrorType: BiomeContentHashError });
210
+ const other = key === CatalogueLockField.Artifact ? CatalogueLockField.Content : CatalogueLockField.Artifact;
211
+ if (lock[other] !== undefined) {
212
+ throw new BiomeContentHashError(
213
+ `distribution "${lock.distributionId}": the lock carries ${other}, but the supplied catalogue is named by ${key} ${digest}`,
214
+ );
207
215
  }
208
- if (lock.catalogueDigest === undefined) {
216
+ if (lock[key] === undefined) {
209
217
  throw new BiomeContentHashError(
210
- `distribution "${lock.distributionId}": the lock carries no catalogueDigest, so it was not resolved against a catalogue; supplied catalogue ${catalogueDigest}`,
218
+ `distribution "${lock.distributionId}": the lock carries no ${key}, so it was not resolved against a catalogue; supplied catalogue ${digest}`,
211
219
  );
212
220
  }
213
- if (lock.catalogueDigest !== catalogueDigest) {
221
+ if (lock[key] !== digest) {
214
222
  throw new BiomeContentHashError(
215
- `distribution "${lock.distributionId}": the lock was resolved against catalogue ${lock.catalogueDigest}, not the supplied ${catalogueDigest}`,
223
+ `distribution "${lock.distributionId}": the lock was resolved against catalogue ${lock[key]}, not the supplied ${digest} (${key})`,
216
224
  );
217
225
  }
218
226
  // The lock is within the catalogue's ceiling: the kernel's one check, its Store publishers against the definition's selection.
@@ -229,13 +237,14 @@ function assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, s
229
237
  * @param {(repo: string) => {dir: string, base: string}} request.locate repo handle -> checkout
230
238
  * @param {(repo: string) => string} request.vendoredCommit the vendored-source pin of a first-party repo
231
239
  * @param {object} [request.catalogue] customer mode: the digest-pinned catalogue vendor biomes are taken from
232
- * @param {string} [request.catalogueDigest] customer mode: the digest of that catalogue; the lock must carry the same one
240
+ * @param {string} [request.catalogueDigest] customer mode, a published catalogue: the digest of its artifact; the lock must carry the same one
241
+ * @param {string} [request.catalogueContentDigest] customer mode, catalogue-free: the digest of the catalogue bytes; exclusive with catalogueDigest, the lock must carry the same one
233
242
  * @param {readonly string[]} [request.storePublisherAnchorKeys] customer mode: the definition's Store publisher selection (absent = none)
234
243
  * @param {Map<string, string>} [request.contentCommits] a candidate edition's recorded override: repo -> the commit its first-party biomes' content is read at
235
244
  * @returns {Promise<Map<string, string>>} biome id -> BiomeContentHash
236
245
  */
237
- export async function computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits }) {
238
- if (catalogue !== undefined) {assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, storePublisherAnchorKeys });}
246
+ export async function computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits }) {
247
+ if (catalogue !== undefined) {assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys });}
239
248
  const hashes = new Map();
240
249
  for (const biome of lock.biomes) {
241
250
  const facts =
@@ -291,9 +300,9 @@ export async function biomeSourceTreeIds({ schemas, lock, imageLock, locate, ven
291
300
  }
292
301
 
293
302
  /** Seal: the unsealed lock + the recomputed hashes -> the kernel's `sealDistributionLock`. */
294
- export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById, bundleSigners, contentCommits, gitSourcedBundle }) {
303
+ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, sourceFactsById, bundleSigners, contentCommits, gitSourcedBundle }) {
295
304
  assertLockBundleSigners({ lock: unsealed, selection: bundleSigners, schemas, gitSourcedBundle });
296
- const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits });
305
+ const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, sourceFactsById, contentCommits });
297
306
  admitDistributionLock(schemas, unsealed);
298
307
  return schemas.sealDistributionLock(unsealed, hashes);
299
308
  }
@@ -302,8 +311,8 @@ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredC
302
311
  * Verify: recompute every biome's hash and REFUSE a mismatch, naming the biome.
303
312
  * The lock's own field is never trusted; a missing or extra biome is a mismatch.
304
313
  */
305
- export async function verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, bundleSigners, contentCommits, gitSourcedBundle }) {
306
- const expected = await computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, contentCommits });
314
+ export async function verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, bundleSigners, contentCommits, gitSourcedBundle }) {
315
+ const expected = await computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, catalogueContentDigest, storePublisherAnchorKeys, contentCommits });
307
316
  assertLockBundleSigners({ lock, selection: bundleSigners, schemas, gitSourcedBundle });
308
317
  const mismatched = lock.biomes
309
318
  .filter((biome) => biome.biomeContentHash !== expected.get(biome.id))
@@ -0,0 +1,41 @@
1
+ // catalogue-lock-field — how a customer edition's distribution lock names the catalogue it was resolved against
2
+ // (kernel edition protocol v37, DistributionLock): EXACTLY ONE of
3
+ //
4
+ // catalogueDigest the digest of a PUBLISHED catalogue artifact (the catalogue pin form);
5
+ // catalogueContentDigest sha256 of the exact catalogue.json bytes a catalogue-free resolve's vendor step wrote
6
+ // (the catalogue-free pin form: no catalogue artifact exists).
7
+ //
8
+ // The kernel refuses a lock carrying both; this CLI refuses a request naming both or neither, and a field that is
9
+ // not its pin form's, before anything is resolved or sealed.
10
+
11
+ /** The two lock fields, by what they digest. */
12
+ export const CatalogueLockField = Object.freeze({ Artifact: 'catalogueDigest', Content: 'catalogueContentDigest' });
13
+
14
+ /**
15
+ * `{ key, digest }` of the one catalogue field `request` names (CatalogueLockField). Throws `new ErrorType(message)`
16
+ * when it names both or neither; `what` names the caller in the message.
17
+ */
18
+ export function catalogueLockFieldOf(request, { what, ErrorType = Error }) {
19
+ const named = Object.values(CatalogueLockField).filter((key) => request?.[key] !== undefined);
20
+ if (named.length !== 1) {
21
+ throw new ErrorType(`${what} names ${named.length === 0 ? 'neither' : 'both'} ${CatalogueLockField.Artifact} (a published catalogue) ${named.length === 0 ? 'nor' : 'and'} ${CatalogueLockField.Content} (a catalogue-free resolve): exactly one`);
22
+ }
23
+ const [key] = named;
24
+ return { key, digest: request[key] };
25
+ }
26
+
27
+ /** The lock field of a customer edition's pin form: the catalogue-free form records the content digest, the catalogue form the artifact digest. */
28
+ export const catalogueLockFieldOfForm = (catalogueFree) => (catalogueFree ? CatalogueLockField.Content : CatalogueLockField.Artifact);
29
+
30
+ /**
31
+ * The one catalogue field of a customer edition's lock, `{ key, digest }`: exactly one named, and the one its pin form
32
+ * records (`catalogueFree`: the catalogue-free form). Throws `new ErrorType(message)` otherwise.
33
+ */
34
+ export function customerLockCatalogueField({ catalogueFree, catalogueDigest, catalogueContentDigest }, { what, ErrorType = Error }) {
35
+ const field = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what, ErrorType });
36
+ const formField = catalogueLockFieldOfForm(catalogueFree);
37
+ if (field.key !== formField) {
38
+ throw new ErrorType(`a ${catalogueFree ? 'catalogue-free' : 'catalogue-form'} customer edition names its catalogue by ${formField}, not ${field.key}`);
39
+ }
40
+ return field;
41
+ }
@@ -18,15 +18,16 @@ import { isAbsolute, join, resolve } from 'node:path';
18
18
  import { sealLock } from './biome-content.mjs';
19
19
  import { assertCatalogueConsumerArmed } from './catalogue-evidence.mjs';
20
20
  import { fetchCatalogue, parseCatalogueRef } from './catalogue-fetch.mjs';
21
+ import { customerLockCatalogueField } from './catalogue-lock-field.mjs';
21
22
  import { readCataloguePin } from './catalogue-pin.mjs';
22
- import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, COSIGN_FLAG, DEFINITION_FLAG, SOURCE_IMAGE_FLAG } from './customer-flags.mjs';
23
+ import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, COSIGN_FLAG, DEFINITION_AT_FLAG, DEFINITION_FLAG, OFFERING_FLAG, OFFERING_PREVIOUS_LOCK_FLAG, OFFERING_PREVIOUS_LOCK_SHA256_FLAG, SOURCE_IMAGE_FLAG } from './customer-flags.mjs';
23
24
  import { freezeCustomerSources, lsRemoteRef } from './develop-freeze.mjs';
24
25
  import { customerDevelopGitBundleVerifier } from './develop-git-bundles.mjs';
25
26
  import { isDevelopImageRef } from './develop-image-coordinate.mjs';
26
27
  import { assertCanonicalDistributionId } from './distribution-paths.mjs';
27
28
  import { LOCAL_WORKFLOW, assertEngineImage, assertExistingArtifactMatches, snapshotEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
28
29
  import { editionWorkspaceConfig } from './edition-config.mjs';
29
- import { CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY, EditionChannel, EditionKind, EditionLockFile } from './edition-constants.mjs';
30
+ import { CATALOGUE_PIN_KEY, CustomerPinForm, DEFINITION_PIN_KEY, EditionChannel, EditionKind, EditionLockFile, customerPinFormAdmitted } from './edition-constants.mjs';
30
31
  import { channelImageRef, sealImageSubjects } from './edition-image-seal.mjs';
31
32
  import { WorkspaceRoot } from './edition-workspace.mjs';
32
33
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
@@ -39,7 +40,7 @@ import { isSourcePinKey, sortedSnapshotPins, sourcePinKey } from './pin-source.m
39
40
  import { PREVIOUS_SEAL_SIGNER_FLAG } from './previous-seal-registry.mjs';
40
41
  import { assertSealedImageLock } from './release-edition-artifact.mjs';
41
42
  import { assertSourceImagesCoverDefinition, parseSourceImages, resolveCustomerEdition } from './resolve-customer-edition.mjs';
42
- import { externalSourceTrees, imageRefForSubject, sourceTreeLocator } from './resolve-images.mjs';
43
+ import { externalSourceTrees, imageRefForSubject, resolvePreviousSeal, sourceTreeLocator } from './resolve-images.mjs';
43
44
  import { readStrictJsonFile } from './strict-json.mjs';
44
45
 
45
46
  const SHA_PATTERN = /^[0-9a-f]{40}$/;
@@ -66,7 +67,11 @@ export function customerDefinitionDigest(rawDefinition) {
66
67
  * catalogue artifact and `definition` = the digest of the definition (customerDefinitionDigest).
67
68
  * A `catalogue` or `definition` entry in the file must equal that digest.
68
69
  */
69
- export function customerEditionPins({ channel, pins, declaredSourceNames, catalogueDigest, definitionDigest }) {
70
+ export function customerEditionPins({ channel, pins, declaredSourceNames, catalogueDigest, definitionDigest, vendorPins }) {
71
+ const catalogueFree = vendorPins !== undefined;
72
+ if (catalogueFree && !customerPinFormAdmitted(CustomerPinForm.CatalogueFree, EditionKind.Resolved, channel)) {
73
+ throw new CustomerEditionPinsError(`a ${channel} customer edition is never resolved catalogue-free`);
74
+ }
70
75
  if (typeof definitionDigest !== 'string') {throw new CustomerEditionPinsError('a customer edition pins the digest of its definition');}
71
76
  if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
72
77
  throw new CustomerEditionPinsError('--pins must be an object mapping sources/<name> -> 40-hex commit');
@@ -74,6 +79,7 @@ export function customerEditionPins({ channel, pins, declaredSourceNames, catalo
74
79
  const own = {};
75
80
  for (const [key, value] of Object.entries(pins)) {
76
81
  if (key === CATALOGUE_PIN_KEY) {
82
+ if (catalogueFree) {throw new CustomerEditionPinsError(`--pins names "${CATALOGUE_PIN_KEY}", but a catalogue-free edition pins no catalogue: the catalogue's digest goes into the distribution lock only`);}
77
83
  if (value !== catalogueDigest) {
78
84
  throw new CustomerEditionPinsError(`--pins "${CATALOGUE_PIN_KEY}" is ${JSON.stringify(value)}, but the pinned ${channel} catalogue is ${catalogueDigest}`);
79
85
  }
@@ -100,7 +106,13 @@ export function customerEditionPins({ channel, pins, declaredSourceNames, catalo
100
106
  for (const key of Object.keys(own)) {
101
107
  if (!declared.has(key)) {throw new CustomerEditionPinsError(`--pins names "${key}", but the definition declares no such source`);}
102
108
  }
103
- return sortedSnapshotPins({ ...own, [CATALOGUE_PIN_KEY]: catalogueDigest, [DEFINITION_PIN_KEY]: definitionDigest });
109
+ if (!catalogueFree) {return sortedSnapshotPins({ ...own, [CATALOGUE_PIN_KEY]: catalogueDigest, [DEFINITION_PIN_KEY]: definitionDigest });}
110
+ for (const key of Object.keys(vendorPins)) {
111
+ if (key === CATALOGUE_PIN_KEY || key === DEFINITION_PIN_KEY || key in own) {
112
+ throw new CustomerEditionPinsError(`the vendor pins of a catalogue-free edition name "${key}", which is the customer's own`);
113
+ }
114
+ }
115
+ return sortedSnapshotPins({ ...vendorPins, ...own, [DEFINITION_PIN_KEY]: definitionDigest });
104
116
  }
105
117
 
106
118
  /** The source names a raw definition declares (the pins are checked before the definition is composed). */
@@ -109,16 +121,22 @@ export function declaredSourceNamesOf(rawDefinition) {
109
121
  return Array.isArray(sources) ? sources.map((source) => source?.name) : [];
110
122
  }
111
123
 
112
- /** Resolve and seal the customer edition of `channel`; returns the four locks and the frozen provenance pins. */
113
- export async function resolveCustomerChannelEdition({ channel, rawDefinition, catalogue, catalogueDigest, pins, sourceImages, env = process.env, sealOptions = {}, adoptKernel, materialize, developGitBundles }) {
114
- const frozen = customerEditionPins({ channel, pins, declaredSourceNames: declaredSourceNamesOf(rawDefinition), catalogueDigest, definitionDigest: customerDefinitionDigest(rawDefinition) });
124
+ /**
125
+ * Resolve and seal the customer edition of `channel`; returns the four locks and the frozen provenance pins. The pin form
126
+ * decides the lock's catalogue field: catalogue-free (`vendorPins`) records `catalogueContentDigest`, the catalogue form
127
+ * `catalogueDigest`. `previousSeal(id)` answers the customer's own previous seal (resolvePreviousSeal; absent: a first seal).
128
+ */
129
+ export async function resolveCustomerChannelEdition({ channel, rawDefinition, catalogue, catalogueDigest, catalogueContentDigest, pins, vendorPins, previousSeal, sourceImages, env = process.env, sealOptions = {}, adoptKernel, materialize, developGitBundles }) {
130
+ const catalogueField = customerLockCatalogueField({ catalogueFree: vendorPins !== undefined, catalogueDigest, catalogueContentDigest }, { what: `a ${channel} customer resolve`, ErrorType: CustomerEditionPinsError });
131
+ const frozen = customerEditionPins({ channel, pins, declaredSourceNames: declaredSourceNamesOf(rawDefinition), catalogueDigest, definitionDigest: customerDefinitionDigest(rawDefinition), ...(vendorPins === undefined ? {} : { vendorPins }) });
115
132
  const resolved = await resolveCustomerEdition({
116
133
  rawDefinition,
117
134
  catalogue,
118
135
  channel,
119
136
  sourceImages,
120
137
  pins: frozen,
121
- catalogueDigest,
138
+ [catalogueField.key]: catalogueField.digest,
139
+ ...(previousSeal === undefined ? {} : { previousSeal }),
122
140
  ...(adoptKernel === undefined ? {} : { adoptKernel }),
123
141
  ...(materialize === undefined ? {} : { materialize }),
124
142
  ...(developGitBundles === undefined ? {} : { developGitBundles }),
@@ -136,7 +154,7 @@ export async function resolveCustomerChannelEdition({ channel, rawDefinition, ca
136
154
  throw new Error(`customer biome source "${repo}" is first-party to no checkout; vendor biomes are taken from the catalogue`);
137
155
  },
138
156
  catalogue,
139
- catalogueDigest,
157
+ [catalogueField.key]: catalogueField.digest,
140
158
  storePublisherAnchorKeys: resolved.storePublisherAnchorKeys,
141
159
  // A row the develop verifier kept carries its bundle and develop signer; the seal admits exactly those.
142
160
  gitSourcedBundle: developGitBundles === undefined ? GitSourcedBundle.Refused : GitSourcedBundle.DevelopVerified,
@@ -203,7 +221,18 @@ const COMMANDS = Object.freeze({
203
221
  values: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG, IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, PREVIOUS_SEAL_SIGNER_FLAG],
204
222
  switches: ['--local', FIRST_SEAL_FLAG],
205
223
  required: ['--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG],
206
- 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, PREVIOUS_SEAL_SIGNER_FLAG], cosign: true, developCosign: true },
224
+ 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_SEAL_SIGNER_FLAG], cosign: true, developCosign: true },
225
+ // The customer's own previous seal (customerPreviousSeal): --previous-lock with --previous-lock-sha256, or --first-seal, on develop only.
226
+ customerPreviousSeal: [PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, FIRST_SEAL_FLAG],
227
+ // The catalogue-free customer form (develop only, develop-catalogue-free-resolve.mjs): the vendor offering is resolved in
228
+ // the same call, so it names both definitions as triples, the vendor step's engine flags and the offering's frozen previous
229
+ // seal; --previous-lock/--first-seal are the customer's own.
230
+ catalogueFree: {
231
+ markers: [DEFINITION_AT_FLAG, OFFERING_FLAG],
232
+ values: [DEFINITION_AT_FLAG, OFFERING_FLAG, SOURCE_IMAGE_FLAG, OFFERING_PREVIOUS_LOCK_FLAG, OFFERING_PREVIOUS_LOCK_SHA256_FLAG],
233
+ required: [DEFINITION_AT_FLAG, OFFERING_FLAG, '--pins', '--out', '--registry', ENGINE_REPOSITORY_FLAG, ENGINE_SIGNER_FLAG, COSIGN_FLAG, OFFERING_PREVIOUS_LOCK_FLAG, OFFERING_PREVIOUS_LOCK_SHA256_FLAG],
234
+ refused: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, ENGINE_IMAGE_FLAG, IMAGES_FROM_FLAG, PREVIOUS_SEAL_SIGNER_FLAG],
235
+ },
207
236
  // developCosign: on develop, --cosign verifies the git-sourced OCI bundles under the data root's pinned policy (develop-git-bundles.mjs).
208
237
  // A vendor resolve finds its engine itself; the old flag is refused by name, never read as a customer flag.
209
238
  removedFromVendor: { [ENGINE_IMAGE_FLAG]: `use ${ENGINE_REPOSITORY_FLAG} and ${ENGINE_SIGNER_FLAG}` },
@@ -240,6 +269,14 @@ const COMMANDS = Object.freeze({
240
269
  switches: ['--json'],
241
270
  required: ['--pins', '--cosign'],
242
271
  },
272
+ follow: {
273
+ positional: false,
274
+ values: [DEFINITION_AT_FLAG, '--data-sha', '--kernel-sha', '--deploy-sha', ENGINE_REPOSITORY_FLAG, '--registry', COSIGN_FLAG, '--current-pins', '--out', '--seals-out', PREVIOUS_SEAL_SIGNER_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG],
275
+ // exactly one of --first and --current-pins (develop-follow.mjs followStartOf); the customer's own previous seal is
276
+ // exactly one of --previous-lock with --previous-lock-sha256, or --first-seal (customerPreviousLockInput)
277
+ switches: ['--first', FIRST_SEAL_FLAG],
278
+ required: [DEFINITION_AT_FLAG, '--data-sha', '--kernel-sha', '--deploy-sha', ENGINE_REPOSITORY_FLAG, '--registry', COSIGN_FLAG, '--out', '--seals-out', PREVIOUS_SEAL_SIGNER_FLAG],
279
+ },
243
280
  'select-records': {
244
281
  positional: false,
245
282
  values: ['--branch', '--registry', '--cosign', '--out', '--at', '--published-pins'],
@@ -281,6 +318,21 @@ function commandsOf(channel) {
281
318
  throw new Error(`unknown edition channel ${JSON.stringify(channel)}`);
282
319
  }
283
320
 
321
+ /** The catalogue-free customer form of a develop verb: no positional id (it is the definition's), no catalogue flags. */
322
+ function catalogueFreeArgs(command, spec, options, positional) {
323
+ for (const refused of spec.refused) {
324
+ if (options[refused] !== undefined) {
325
+ throw new Error(`a catalogue-free ${command} takes no ${refused}: it names the definition and the offering as ${DEFINITION_AT_FLAG} and ${OFFERING_FLAG}, pins no catalogue, and its vendor step finds its engine itself`);
326
+ }
327
+ }
328
+ if (positional.length !== 0) {throw new Error(`a catalogue-free ${command} takes no <distribution-id>: the id is the definition's`);}
329
+ for (const required of spec.required) {
330
+ if (options[required] === undefined) {throw new Error(`a catalogue-free ${command} requires ${required}`);}
331
+ }
332
+ if (!isAbsolute(options[COSIGN_FLAG])) {throw new Error(`${COSIGN_FLAG} must be an absolute path, got ${JSON.stringify(options[COSIGN_FLAG])}`);}
333
+ return { command, id: undefined, options, customer: false, catalogueFree: true };
334
+ }
335
+
284
336
  /** Parse `<command> [<id>] --flag value ...` of `channel` against the closed vocabulary of the command. */
285
337
  export function parseEditionVerbArgs(channel, argv) {
286
338
  const commands = commandsOf(channel);
@@ -291,13 +343,14 @@ export function parseEditionVerbArgs(channel, argv) {
291
343
  }
292
344
  // The flags only the customer form takes: a flag the vendor form takes too (`--cosign` of a develop resolve) does not make it one.
293
345
  const customerValues = (spec.customer?.values ?? []).filter((flag) => !spec.values.includes(flag));
346
+ const catalogueFreeValues = channel === EditionChannel.Develop ? (spec.catalogueFree?.values ?? []) : [];
294
347
  const options = {};
295
348
  const positional = [];
296
349
  for (let i = 0; i < rest.length; i += 1) {
297
350
  const arg = rest[i];
298
351
  if (spec.switches.includes(arg)) {
299
352
  options[arg] = true;
300
- } else if (spec.values.includes(arg) || customerValues.includes(arg)) {
353
+ } else if (spec.values.includes(arg) || customerValues.includes(arg) || catalogueFreeValues.includes(arg)) {
301
354
  const value = rest[i + 1];
302
355
  if (value === undefined || (value.startsWith('--') && value.length > 2)) {
303
356
  throw new Error(`${arg} requires a value`);
@@ -316,6 +369,12 @@ export function parseEditionVerbArgs(channel, argv) {
316
369
  positional.push(arg);
317
370
  }
318
371
  }
372
+ if (catalogueFreeValues.length > 0 && spec.catalogueFree.markers.some((flag) => options[flag] !== undefined)) {
373
+ return catalogueFreeArgs(command, spec.catalogueFree, options, positional);
374
+ }
375
+ for (const flag of catalogueFreeValues.filter((value) => !customerValues.includes(value) && !spec.values.includes(value))) {
376
+ if (options[flag] !== undefined) {throw new Error(`${flag} belongs to the catalogue-free form of ${command} (${spec.catalogueFree.markers.join(' and ')})`);}
377
+ }
319
378
  const customer = spec.customer !== undefined && customerValues.some((flag) => options[flag] !== undefined);
320
379
  // `--cosign` of a customer form verifies a release catalogue, and on a develop resolve the git-sourced OCI bundles
321
380
  // (`developCosign`); a vendor verb (develop `coverage`, `resolve`, `engine`) takes its own `--cosign`.
@@ -337,6 +396,10 @@ export function parseEditionVerbArgs(channel, argv) {
337
396
  for (const refused of spec.customer.refused) {
338
397
  if (options[refused] !== undefined) {throw new Error(`a customer ${command} takes no ${refused}`);}
339
398
  }
399
+ // The customer's own previous seal is a develop resolve's (customerPreviousSeal); a release customer resolve takes none.
400
+ for (const flag of spec.customerPreviousSeal ?? []) {
401
+ if (channel !== EditionChannel.Develop && options[flag] !== undefined) {throw new Error(`a ${channel} customer ${command} takes no ${flag}`);}
402
+ }
340
403
  if (positional.length !== 0) {throw new Error(`a customer ${command} takes no <distribution-id>: the id is the definition's`);}
341
404
  if (spec.customer.cosign === true && channel === EditionChannel.Release && options[COSIGN_FLAG] === undefined) {
342
405
  throw new Error(`a release ${command} requires ${COSIGN_FLAG} <absolute path of the cosign binary>`);
@@ -394,6 +457,23 @@ async function fetchPinnedCatalogue(channel, options, env, fetch = fetchCatalogu
394
457
  });
395
458
  }
396
459
 
460
+ /**
461
+ * The customer's own previous seal of a develop resolve, as `previousSeal(id)` (resolveCustomerChannelEdition): exactly
462
+ * one of `--previous-lock <path> --previous-lock-sha256 <hex>` (the customer edition's own last images lock, its bytes
463
+ * held to the sha256 and its id to the edition's) or `--first-seal`. No signer form: a resolve reads no "newest", so the
464
+ * same frozen inputs resolve the same bytes. A release customer resolve takes none (refused at the parse).
465
+ */
466
+ export function customerPreviousSeal(channel, options) {
467
+ if (channel !== EditionChannel.Develop) {return undefined;}
468
+ return (distributionId) =>
469
+ resolvePreviousSeal({
470
+ distributionId,
471
+ previousLockPath: options[PREVIOUS_LOCK_FLAG],
472
+ previousLockSha256: options[PREVIOUS_LOCK_SHA256_FLAG],
473
+ firstSeal: Boolean(options[FIRST_SEAL_FLAG]),
474
+ });
475
+ }
476
+
397
477
  /** A customer resolve: the artifact (descriptor and four locks, no catalogue: it is not an offering) goes to --out. */
398
478
  async function resolveCustomer(channel, options, env) {
399
479
  const { catalogue, digest } = await fetchPinnedCatalogue(channel, options, env);
@@ -407,6 +487,7 @@ async function resolveCustomer(channel, options, env) {
407
487
  catalogue,
408
488
  catalogueDigest: digest,
409
489
  pins: readStrictJsonFile(options['--pins']),
490
+ previousSeal: customerPreviousSeal(channel, options),
410
491
  sourceImages,
411
492
  env,
412
493
  // Develop, under a declared customer policy: the git-sourced OCI bundles are verified with --cosign (none: refused when a subject exists).
@@ -6,3 +6,10 @@ export const CATALOGUE_USERNAME_FLAG = '--catalogue-username';
6
6
  export const CATALOGUE_TOKEN_ENV_FLAG = '--catalogue-token-env';
7
7
  /** The absolute path of the cosign binary that verifies a release catalogue's E4 evidence (release only). */
8
8
  export const COSIGN_FLAG = '--cosign';
9
+ /** A definition named as `<owner>/<repo>,<path>,<dataRoot>` at a pinned commit (`develop follow`); `--definition` is a file. */
10
+ export const DEFINITION_AT_FLAG = '--definition-at';
11
+ /** The vendor offering a catalogue-free resolve resolves in the same call, as `<owner>/<repo>,<path>,<dataRoot>` of the vendor data checkout. */
12
+ export const OFFERING_FLAG = '--offering';
13
+ /** The offering's own previous seal a catalogue-free resolve's vendor step carries forward: the images lock `develop follow` froze, and its sha256. */
14
+ export const OFFERING_PREVIOUS_LOCK_FLAG = '--offering-previous-lock';
15
+ export const OFFERING_PREVIOUS_LOCK_SHA256_FLAG = '--offering-previous-lock-sha256';