@xemahq/xema 0.58.0 → 0.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
19
19
  import { EDITION_CHART_SET_FILE, assertRosteredChartSetLock } from './edition-chart-set.mjs';
20
20
  import {
21
21
  CATALOGUE_PIN_KEY,
22
+ CustomerPinForm,
22
23
  DEFINITION_PIN_KEY,
23
24
  DIGEST_PIN_KEYS,
24
25
  EDITION_DESCRIPTOR_FILE,
@@ -31,11 +32,13 @@ import {
31
32
  EditionLockFile,
32
33
  SEALED_EDITION_CHANNEL,
33
34
  SEAL_OF_PATTERN,
35
+ VENDOR_MEMBER_PIN_PATTERN,
34
36
  admittedEvidenceTrustStates,
37
+ customerPinFormAdmitted,
35
38
  } from './edition-constants.mjs';
36
39
  import { dataRoot, editionsRoot } from './edition-workspace.mjs';
37
40
  import { serializeJson, sha256Hex } from './lib.mjs';
38
- import { sortedSnapshotPins } from './pin-source.mjs';
41
+ import { isSourcePinKey, sortedSnapshotPins } from './pin-source.mjs';
39
42
  import { readStrictJsonFile } from './strict-json.mjs';
40
43
 
41
44
  /** Which GitHub Actions run produced the artifact; recorded in its descriptor. */
@@ -119,6 +122,45 @@ const exactKeys = (value, keys, where) => {
119
122
  }
120
123
  };
121
124
 
125
+ /**
126
+ * The closed customer pin rule (edition-contract.json provenancePins.customerForms). A descriptor whose pins carry the
127
+ * definition pin is a customer edition, and it carries exactly one form: the CATALOGUE form (the catalogue pin, the
128
+ * definition pin and sources/<name> commits, nothing else), or the CATALOGUE-FREE form (no catalogue pin, at least one
129
+ * vendor member pin), each only on the kinds and channels the contract admits it on. A descriptor without the definition
130
+ * pin is not a customer edition and the rule does not apply to it.
131
+ */
132
+ export function assertCustomerPinForm(kind, channel, pins, distributionId) {
133
+ if (!(DEFINITION_PIN_KEY in pins)) {
134
+ // only a customer edition records a vendor side: every other edition carries neither form's marker
135
+ if (CATALOGUE_PIN_KEY in pins) {
136
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: the ${kind} ${channel} edition "${distributionId}" carries the catalogue pin without the definition pin: only a customer edition (the definition pin) records the catalogue it was resolved against`);
137
+ }
138
+ return;
139
+ }
140
+ const where = `${EDITION_DESCRIPTOR_FILE}: the customer ${kind} ${channel} edition "${distributionId}"`;
141
+ if (CATALOGUE_PIN_KEY in pins) {
142
+ if (!customerPinFormAdmitted(CustomerPinForm.Catalogue, kind, channel)) {
143
+ throw new Error(`${where} carries the catalogue pin, but the contract admits the catalogue form on no ${kind} ${channel} edition`);
144
+ }
145
+ const stray = Object.keys(pins).filter((key) => key !== CATALOGUE_PIN_KEY && key !== DEFINITION_PIN_KEY && !isSourcePinKey(key));
146
+ if (stray.length > 0) {
147
+ throw new Error(`${where} carries the catalogue pin and also ${stray.map((key) => JSON.stringify(key)).join(', ')}: the catalogue form pins only the catalogue, the definition and sources/<name>`);
148
+ }
149
+ return;
150
+ }
151
+ if (!customerPinFormAdmitted(CustomerPinForm.CatalogueFree, kind, channel)) {
152
+ throw new Error(`${where} carries no catalogue pin: only a ${catalogueFreeAdmissions()} customer edition may be resolved catalogue-free`);
153
+ }
154
+ if (!Object.keys(pins).some((key) => VENDOR_MEMBER_PIN_PATTERN.test(key))) {
155
+ throw new Error(`${where} carries neither the catalogue pin nor a vendor member pin: a catalogue-free edition records the vendor member pins it was resolved from`);
156
+ }
157
+ }
158
+
159
+ const catalogueFreeAdmissions = () =>
160
+ Object.values(EditionKind)
161
+ .flatMap((kind) => Object.values(EditionChannel).filter((channel) => customerPinFormAdmitted(CustomerPinForm.CatalogueFree, kind, channel)).map((channel) => `${kind} ${channel}`))
162
+ .join(' or ');
163
+
122
164
  /**
123
165
  * The strict parse of a descriptor: exact keys, the closed kind enum (no default, never inferred), `sealOf` exactly on a
124
166
  * sealed edition, closed vocabularies, the evidence trust state the contract admits for the kind on the channel, an engine
@@ -164,6 +206,7 @@ export function parseEditionDescriptor(value) {
164
206
  );
165
207
  }
166
208
  }
209
+ assertCustomerPinForm(value.kind, value.provenance.channel, pins, value.distributionId);
167
210
  exactKeys(workflow, ['repository', 'runId'], `${EDITION_DESCRIPTOR_FILE} provenance.workflow`);
168
211
  if (!workflow.repository || !workflow.runId || typeof workflow.repository !== 'string' || typeof workflow.runId !== 'string') {
169
212
  throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.workflow is {repository, runId}, both non-empty strings`);
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { EditionChannel } from './edition-channel.mjs';
8
8
  import { EDITION_CHART_SET_FILE } from './edition-chart-set.mjs';
9
- import { parseEditionContract } from './edition-contract-parse.mjs';
9
+ import { CustomerPinForm, parseEditionContract } from './edition-contract-parse.mjs';
10
10
  import editionContract from './edition-contract.json' with { type: 'json' };
11
11
  import { EditionKind, EvidenceTrustState } from './edition-kind.mjs';
12
12
 
@@ -40,7 +40,7 @@ export const EDITION_REPOSITORY_SEGMENTS = Object.freeze(
40
40
  Object.fromEntries(Object.values(EditionChannel).map((channel) => [channel, EDITION_CONTRACT.repositories[channel].namespaceSegment])),
41
41
  );
42
42
 
43
- export { EditionChannel, EditionKind, EvidenceTrustState };
43
+ export { CustomerPinForm, EditionChannel, EditionKind, EvidenceTrustState };
44
44
 
45
45
  /**
46
46
  * The evidence trust states an edition of `kind` on `channel` may record (contract `evidenceTrust.statesByKind`):
@@ -72,9 +72,29 @@ if (!(DEFINITION_PIN_KEY in EDITION_CONTRACT.provenancePins)) {
72
72
  throw new Error('edition-contract.json provenancePins does not declare the definition pin');
73
73
  }
74
74
 
75
+ /**
76
+ * The commit of the customer definition repository a catalogue-free customer edition's definition was read at: a pin of
77
+ * the catalogue-free form only (provenancePins.customerForms["catalogue-free"].pins names it), a 40-hex commit.
78
+ */
79
+ export const DEFINITION_REPOSITORY_PIN_KEY = 'definition-repository';
80
+ if (!EDITION_CONTRACT.provenancePins.customerForms['catalogue-free'].pins.includes(`"${DEFINITION_REPOSITORY_PIN_KEY}"`)) {
81
+ throw new Error(`edition-contract.json provenancePins.customerForms["catalogue-free"].pins does not name the "${DEFINITION_REPOSITORY_PIN_KEY}" pin`);
82
+ }
83
+
75
84
  /** The provenance pins that are sha256 digests, not commits. */
76
85
  export const DIGEST_PIN_KEYS = Object.freeze([CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY]);
77
86
 
87
+ /**
88
+ * The key of a vendor member (develop graph repository) pin, `repos/<x>` or `submodules/<x>`: the one statement is the
89
+ * contract's (provenancePins.customerForms.vendorMemberPinPattern); the snapshot pin namespaces read it from here.
90
+ */
91
+ export const VENDOR_MEMBER_PIN_PATTERN = new RegExp(EDITION_CONTRACT.provenancePins.customerForms.vendorMemberPinPattern);
92
+
93
+ /** Whether the contract admits customer pin form `form` (CustomerPinForm) on an edition of `kind` and `channel`. */
94
+ export function customerPinFormAdmitted(form, kind, channel) {
95
+ return EDITION_CONTRACT.provenancePins.customerForms[form].kinds[kind]?.includes(channel) === true;
96
+ }
97
+
78
98
  /** The lock files an edition can carry. */
79
99
  export const EditionLockFile = Object.freeze({
80
100
  Distribution: 'distribution.lock.json',
@@ -91,6 +91,47 @@ function assertDescriptorVocabulary(contract) {
91
91
  }
92
92
  }
93
93
 
94
+ /** The customer pin forms (provenancePins.customerForms), one key per form. */
95
+ export const CustomerPinForm = Object.freeze({ Catalogue: 'catalogue', CatalogueFree: 'catalogue-free' });
96
+
97
+ /**
98
+ * `provenancePins`: exactly the catalogue and definition pin statements and `customerForms`, the closed statement of how a
99
+ * customer edition records its vendor side. The catalogue form is admitted on every kind and channel a kind has (a customer
100
+ * edition that is not catalogue-free still requires the catalogue pin), the catalogue-free form on a subset of them; the
101
+ * vendor member pin pattern is an anchored regular expression.
102
+ */
103
+ function assertProvenancePins(contract) {
104
+ const pins = contract.provenancePins;
105
+ const texts = ['catalogue', 'definition'];
106
+ if (!isObject(pins) || !sameSet(Object.keys(pins), [...texts, 'customerForms']) || !texts.every((key) => typeof pins[key] === 'string' && pins[key] !== '')) {
107
+ throw new Error('edition-contract.json: "provenancePins" must be exactly {catalogue, definition, customerForms}');
108
+ }
109
+ const forms = pins.customerForms;
110
+ const formNames = Object.values(CustomerPinForm);
111
+ if (!isObject(forms) || !sameSet(Object.keys(forms), ['names', 'vendorMemberPinPattern', ...formNames]) || typeof forms.names !== 'string' || forms.names === '') {
112
+ throw new Error(`edition-contract.json: provenancePins.customerForms must be exactly {names, vendorMemberPinPattern, ${formNames.join(', ')}}`);
113
+ }
114
+ assertAnchoredPattern(forms.vendorMemberPinPattern, 'provenancePins.customerForms.vendorMemberPinPattern');
115
+ const channelsOf = { [EditionKind.Resolved]: Object.values(EditionChannel), [EditionKind.Sealed]: [contract.sealOf.channel] };
116
+ for (const name of formNames) {
117
+ const form = forms[name];
118
+ if (!isObject(form) || !sameSet(Object.keys(form), ['pins', 'kinds']) || typeof form.pins !== 'string' || form.pins === '' || !isObject(form.kinds)) {
119
+ throw new Error(`edition-contract.json: provenancePins.customerForms.${name} must be exactly {pins, kinds}`);
120
+ }
121
+ for (const [kind, channels] of Object.entries(form.kinds)) {
122
+ const own = channelsOf[kind];
123
+ if (own === undefined || !Array.isArray(channels) || channels.length === 0 || new Set(channels).size !== channels.length || !channels.every((channel) => own.includes(channel))) {
124
+ throw new Error(`edition-contract.json: provenancePins.customerForms.${name}.kinds.${kind} must be a non-empty set of the channels of a ${kind} edition`);
125
+ }
126
+ }
127
+ }
128
+ for (const [kind, own] of Object.entries(channelsOf)) {
129
+ if (!sameSet(forms[CustomerPinForm.Catalogue].kinds[kind], own)) {
130
+ throw new Error(`edition-contract.json: provenancePins.customerForms.catalogue.kinds.${kind} must name every channel of a ${kind} edition (${own.join(' | ')}): the catalogue form is the default form`);
131
+ }
132
+ }
133
+ }
134
+
94
135
  const SEGMENT = /^[a-z0-9][a-z0-9.-]*$/;
95
136
 
96
137
  /** The strict parse of the contract; returns it unchanged. */
@@ -133,5 +174,6 @@ export function parseEditionContract(contract) {
133
174
  throw new Error('edition-contract.json: the channels\' namespace segments must differ');
134
175
  }
135
176
  assertDescriptorVocabulary(contract);
177
+ assertProvenancePins(contract);
136
178
  return contract;
137
179
  }
@@ -35,7 +35,24 @@
35
35
  },
36
36
  "provenancePins": {
37
37
  "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against",
38
- "definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit"
38
+ "definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit",
39
+ "customerForms": {
40
+ "names": "a customer edition is one whose pins carry the definition pin. It records its vendor side in exactly one form, closed per kind and channel: the catalogue form, or the catalogue-free form. Every other edition carries neither form's marker: no catalogue pin. The kinds and channels each form admits are enforced by the writers and this CLI's readers (the descriptor parse); the catalogue-free form being develop only also rests on only the develop resolve identity being able to sign one. The deploy root's admission does not enforce the forms: it re-vendors this contract and reads neither provenancePins nor the forms",
41
+ "catalogue": {
42
+ "pins": "the catalogue pin, the definition pin, and a sources/<name> commit per source the definition declares; nothing else. The distribution lock names the catalogue artifact by catalogueDigest, never by catalogueContentDigest",
43
+ "kinds": {
44
+ "resolved": ["develop", "release"],
45
+ "sealed": ["release"]
46
+ }
47
+ },
48
+ "catalogue-free": {
49
+ "pins": "no catalogue pin: the vendor side was resolved from the vendor members in the same call, so the edition records the vendor member pins (keys matching vendorMemberPinPattern, at least one) as 40-hex commits, beside the definition pin, the \"definition-repository\" commit (the commit of the customer definition repository the definition was read at), the sources/<name> commits and the other 40-hex pins of the resolve. The catalogue the vendor step wrote is named in the distribution lock only, by catalogueContentDigest (sha256 of its exact catalogue.json bytes, kernel edition protocol v37), never by catalogueDigest: no catalogue artifact exists",
50
+ "kinds": {
51
+ "resolved": ["develop"]
52
+ }
53
+ },
54
+ "vendorMemberPinPattern": "^(?:repos|submodules)/[a-z0-9][a-z0-9-]*$"
55
+ }
39
56
  },
40
57
  "imageNamespaceSegment": "dev",
41
58
  "evidenceTrust": {
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
+ import { X509Certificate, createPublicKey } from 'node:crypto';
2
3
  import {
3
4
  mkdtempSync,
4
5
  readFileSync,
@@ -126,6 +127,76 @@ export function validatePinnedTrustedRoot(policyRoot, policy) {
126
127
  return { path: trustedRoot, bytes: rootBytes };
127
128
  }
128
129
 
130
+ /** The one transparency log a CLASSIC (`.sig` / `.att`) cosign signature is entered in: Rekor v1 of the public good instance. */
131
+ const CLASSIC_REKOR_BASE_URL = 'https://rekor.sigstore.dev';
132
+ const STRICT_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
133
+
134
+ const pemOf = (label, der) => `-----BEGIN ${label}-----\n${der.toString('base64').match(/.{1,64}/gu).join('\n')}\n-----END ${label}-----\n`;
135
+ const isOpen = (entry) => entry?.validFor?.end === undefined;
136
+
137
+ function derOf(rawBytes, what) {
138
+ if (typeof rawBytes !== 'string' || rawBytes.length === 0 || !STRICT_BASE64.test(rawBytes)) {
139
+ throw new Error(`Sigstore trusted root: ${what} rawBytes is not base64 DER`);
140
+ }
141
+ return Buffer.from(rawBytes, 'base64');
142
+ }
143
+
144
+ function publicKeyPem(entry, what) {
145
+ const der = derOf(entry?.publicKey?.rawBytes, what);
146
+ try {
147
+ createPublicKey({ key: der, format: 'der', type: 'spki' });
148
+ } catch {
149
+ throw new Error(`Sigstore trusted root: ${what} is not a DER SubjectPublicKeyInfo`);
150
+ }
151
+ return pemOf('PUBLIC KEY', der);
152
+ }
153
+
154
+ /**
155
+ * The classic cosign trust material of the pinned Sigstore trusted root, derived deterministically from its bytes (the
156
+ * output of `validatePinnedTrustedRoot`): what cosign's classic verification reads from `SIGSTORE_ROOT_FILE`,
157
+ * `SIGSTORE_REKOR_PUBLIC_KEY` and `SIGSTORE_CT_LOG_PUBLIC_KEY_FILE` when it cannot take `--trusted-root` (cosign 2.6
158
+ * `verify-attestation` accepts `--trusted-root` only with `--new-bundle-format`, which classic `.att` attestations are
159
+ * not). Only material still open is kept (no `validFor.end`): the Fulcio certificate chains of every open certificate
160
+ * authority (at least one), exactly one open Rekor v1 log (`https://rekor.sigstore.dev`; later logs carry new-format
161
+ * entries only) and exactly one open CT log. Any other count is refused.
162
+ *
163
+ * @returns {{fulcioPem: string, rekorPem: string, ctLogPem: string}}
164
+ */
165
+ export function classicCosignTrustMaterial(rootBytes) {
166
+ const root = JSON.parse(decodeExactUtf8(rootBytes, 'Sigstore trusted root'));
167
+ const listOf = (key) => (Array.isArray(root?.[key]) ? root[key] : []);
168
+ const authorities = listOf('certificateAuthorities').filter(isOpen);
169
+ if (authorities.length === 0) {
170
+ throw new Error('Sigstore trusted root: no open Fulcio certificate authority (certificateAuthorities without validFor.end)');
171
+ }
172
+ const fulcioPem = authorities
173
+ .flatMap((authority, index) => {
174
+ const certificates = authority?.certChain?.certificates;
175
+ if (!Array.isArray(certificates) || certificates.length === 0) {
176
+ throw new Error(`Sigstore trusted root: open certificate authority ${index} has no certificate chain`);
177
+ }
178
+ return certificates.map((certificate) => {
179
+ const der = derOf(certificate?.rawBytes, `certificate authority ${index} certificate`);
180
+ try {
181
+ new X509Certificate(der);
182
+ } catch {
183
+ throw new Error(`Sigstore trusted root: certificate authority ${index} holds a certificate that is not DER X.509`);
184
+ }
185
+ return pemOf('CERTIFICATE', der);
186
+ });
187
+ })
188
+ .join('');
189
+ const rekor = listOf('tlogs').filter((log) => log?.baseUrl === CLASSIC_REKOR_BASE_URL && isOpen(log?.publicKey));
190
+ if (rekor.length !== 1) {
191
+ throw new Error(`Sigstore trusted root: ${rekor.length} open Rekor v1 logs (${CLASSIC_REKOR_BASE_URL}); exactly one is required`);
192
+ }
193
+ const ctLogs = listOf('ctlogs').filter((log) => isOpen(log?.publicKey));
194
+ if (ctLogs.length !== 1) {
195
+ throw new Error(`Sigstore trusted root: ${ctLogs.length} open CT logs (ctlogs without validFor.end); exactly one is required`);
196
+ }
197
+ return { fulcioPem, rekorPem: publicKeyPem(rekor[0], 'the Rekor v1 log key'), ctLogPem: publicKeyPem(ctLogs[0], 'the CT log key') };
198
+ }
199
+
129
200
  function assertTimeWindow({ issuedAt, expiresAt, now, maxTtlSeconds, label }) {
130
201
  const issued = Date.parse(issuedAt);
131
202
  const expires = Date.parse(expiresAt);
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { developGraphMembers } from './develop-graph.mjs';
25
25
  import { workspaceSection } from './edition-config.mjs';
26
+ import { VENDOR_MEMBER_PIN_PATTERN } from './edition-constants.mjs';
26
27
  import { compareStrings } from './lib.mjs';
27
28
  import { PinRole } from './workspace-sections.mjs';
28
29
 
@@ -55,7 +56,8 @@ export const dataPinKey = () => pinKeyOf(PinRole.Data);
55
56
  export const kernelPinKey = () => pinKeyOf(PinRole.Kernel);
56
57
  export const deployPinKey = () => pinKeyOf(PinRole.Deploy);
57
58
  const SOURCE_PIN_PREFIX = 'sources/';
58
- const GRAPH_PIN_PATTERN = /^(?:repos|submodules)\/[a-z0-9][a-z0-9-]*$/;
59
+ // A graph pin key is a vendor member pin key: the pattern is the edition contract's (customerForms.vendorMemberPinPattern).
60
+ const GRAPH_PIN_PATTERN = VENDOR_MEMBER_PIN_PATTERN;
59
61
 
60
62
  /** The snapshot key of one declared `sources[]` entry. */
61
63
  export function sourcePinKey(name) {
@@ -3,8 +3,9 @@
3
3
  // selection can both subclass it. A registry, cosign or git that could not answer is a plain Error instead.
4
4
 
5
5
  export class RecordRefused extends Error {
6
- constructor(repo, message) {
7
- super(`select-records: ${repo}: ${message}`);
6
+ /** `context` labels the verb the refusal surfaces in (select-records by default, `follow` for its sources). */
7
+ constructor(repo, message, context = 'select-records') {
8
+ super(`${context}: ${repo}: ${message}`);
8
9
  this.name = 'RecordRefused';
9
10
  this.repo = repo;
10
11
  }
@@ -20,6 +20,7 @@ import { isDeepStrictEqual } from 'node:util';
20
20
 
21
21
  import { adapterHostRow, assertAdapterBundlesMatchHost, fetchRegistryManifest } from './adapter-bundled-versions.mjs';
22
22
  import { AUTHORIZATION_LOCK_FILENAME, writeAuthorizationLock } from './authorization-base.mjs';
23
+ import { catalogueLockFieldOf } from './catalogue-lock-field.mjs';
23
24
  import {
24
25
  assertCustomerLockWithinCatalogueCeiling,
25
26
  CatalogueCeilingError,
@@ -213,8 +214,29 @@ export function assertVendorRowsAreCatalogueRows({ catalogue, imageLock, lock, m
213
214
  }
214
215
  }
215
216
 
216
- /** The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes and returns subjects), the merged lock parsed once. */
217
- export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn }) {
217
+ /**
218
+ * The customer's OWN lineage of a previous images lock: `previousLock` (the customer edition's own last images lock, its
219
+ * id already held to this edition's) narrowed to the composed-process rows every owner of which is one of this lock's
220
+ * own (customer-sourced) biomes. The vendor rows of a customer lock are the catalogue's (assertVendorRowsAreCatalogueRows)
221
+ * and carry the vendor's generations, never the customer's, so they never enter the customer's own carry-forward.
222
+ * Null (a first seal) stays null.
223
+ */
224
+ export function ownComposedPreviousLock(previousLock, lock, enums) {
225
+ if (previousLock === null) {return null;}
226
+ const ownBiomes = new Set(lock.biomes.filter((biome) => biome.source !== undefined).map((biome) => biome.id));
227
+ const own = (subject) =>
228
+ subject.kind === enums.RuntimeImageSubjectKind.ComposedProcess &&
229
+ subject.owners.length > 0 &&
230
+ subject.owners.every((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome && ownBiomes.has(owner.biomeId));
231
+ return { ...previousLock, subjects: previousLock.subjects.filter(own) };
232
+ }
233
+
234
+ /**
235
+ * The customer's image lock: vendor rows copied (and mirrored), own rows resolved (and sealed by `sealOwn`, which takes
236
+ * and returns subjects), the merged lock parsed once. `previousLock` (the customer's own previous seal, null for a first
237
+ * seal) carries the own composed artifacts' generations forward (ownComposedPreviousLock).
238
+ */
239
+ export async function resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn, previousLock = null }) {
218
240
  const vendor = projectVendorImages(catalogue, lock, schemas);
219
241
  const vendorSubjects = mirrorVendorSubjects(vendor.subjects, composed.mirror);
220
242
  const ownBiomes = lock.biomes.filter((biome) => biome.source !== undefined);
@@ -232,7 +254,7 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
232
254
  buildStepContextDirectories: [],
233
255
  };
234
256
  // The own rows are ROWS, not a lock: the one parse is of the merged lock below.
235
- own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock });
257
+ own = await resolveImageRows(id, { inputs: { ...inputs, runtimeImageSourceIndex, composed }, lock: ownLock, previousLock: ownComposedPreviousLock(previousLock, lock, schemas) });
236
258
  // A source that builds no image (a web biome) has no row to seal.
237
259
  if (sealOwn !== undefined && own.subjects.length > 0) {own = { ...own, subjects: await sealOwn({ distributionId: id, subjects: own.subjects }) };}
238
260
  }
@@ -258,12 +280,14 @@ export async function resolveCustomerImages({ id, catalogue, lock, composed, sch
258
280
 
259
281
  /**
260
282
  * The distribution and authorization locks of the customer edition `rawDefinition`
261
- * against `catalogue`. `catalogueDigest` is the digest of `catalogue`, written into the lock. `adoptKernel` (the catalogue's kernel from npm, proved its contract authority; returns the kernel schemas) and `materialize` are injectable for the test suites.
283
+ * against `catalogue`. Exactly one of `catalogueDigest` (a published catalogue artifact) and `catalogueContentDigest` (the
284
+ * catalogue bytes of a catalogue-free resolve) names `catalogue`; it is written into the lock under its own key. `adoptKernel` (the catalogue's kernel from npm, proved its contract authority; returns the kernel schemas) and `materialize` are injectable for the test suites.
262
285
  */
263
286
  export async function resolveCustomerDistribution({
264
287
  rawDefinition,
265
288
  catalogue,
266
289
  catalogueDigest,
290
+ catalogueContentDigest,
267
291
  sourceImages,
268
292
  pins,
269
293
  materialize,
@@ -271,7 +295,7 @@ export async function resolveCustomerDistribution({
271
295
  fetchManifest = fetchRegistryManifest,
272
296
  developGitBundles,
273
297
  }) {
274
- if (catalogueDigest === undefined) {throw new CatalogueError('a customer resolve names the digest of its catalogue');}
298
+ const catalogueField = catalogueLockFieldOf({ catalogueDigest, catalogueContentDigest }, { what: 'a customer resolve', ErrorType: CatalogueError });
275
299
  // Only a develop edition carries a git-sourced OCI bundle (develop-git-bundles.mjs); a release never verifies one.
276
300
  if (developGitBundles !== undefined && catalogue.channel !== EditionChannel.Develop) {
277
301
  throw new Error(`a git-sourced bundle verifier is a develop resolve's; the catalogue "${catalogue.offeringId}" is ${catalogue.channel}`);
@@ -317,14 +341,19 @@ export async function resolveCustomerDistribution({
317
341
  await assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(catalogue.biomes), fetchManifest });
318
342
  const authorizationLock = await composeAuthorizationBase(id, lock, index);
319
343
  // A customer lock names the catalogue it was resolved against; seal and verify refuse any other.
320
- return { id, schemas, composed, inputs, lock: { ...lock, catalogueDigest }, authorizationLock, storePublisherAnchorKeys };
344
+ return { id, schemas, composed, inputs, lock: { ...lock, [catalogueField.key]: catalogueField.digest }, authorizationLock, storePublisherAnchorKeys };
321
345
  }
322
346
 
323
- /** Resolve the customer edition of `channel` against `catalogue`; returns the four locks. `sealOwn` seals the customer's OWN image rows only (a develop edition); vendor rows arrive sealed. */
347
+ /**
348
+ * Resolve the customer edition of `channel` against `catalogue`; returns the four locks. `sealOwn` seals the customer's OWN
349
+ * image rows only (a develop edition); vendor rows arrive sealed. `previousSeal` answers the customer's own previous seal
350
+ * (resolvePreviousSeal under this edition's id: null for a first seal), read only after the catalogue's kernel is adopted
351
+ * (it parses with that kernel); its own composed artifacts carry their generation forward. Absent: a first seal.
352
+ */
324
353
  export async function resolveCustomerEdition(request) {
325
354
  const { id, schemas, composed, inputs, lock, authorizationLock, storePublisherAnchorKeys } = await resolveCustomerDistribution(request);
326
355
  const { catalogue } = request;
327
- const imageLock = await resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn: request.sealOwn });
356
+ const imageLock = await resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn: request.sealOwn, previousLock: request.previousSeal === undefined ? null : await request.previousSeal(id) });
328
357
  // The chart-set is copied whole, so it carries the coordinates of the channel it was published on:
329
358
  // the edition being written states its channel, and a chart-set of the other one is refused here.
330
359
  const chartSetLock = assertRosteredChartSetLock(catalogue.chartSet, `the catalogue's chart-set of "${catalogue.offeringId}"`);
@@ -158,6 +158,9 @@ export function parseCustomerResolveArgs(args) {
158
158
  if (!Object.values(EditionChannel).includes(values.get(CHANNEL_FLAG))) {
159
159
  throw new Error(`${CHANNEL_FLAG} must be one of ${Object.values(EditionChannel).join(' | ')}`);
160
160
  }
161
+ if (values.get(CHANNEL_FLAG) === EditionChannel.Develop) {
162
+ throw new Error(`${DEFINITION_FLAG} with ${CHANNEL_FLAG} develop is refused here: this verb takes no previous seal, so the customer's own composed rows would restart at generation 1; use xema edition develop resolve ${DEFINITION_FLAG} <file> --previous-lock <file> --previous-lock-sha256 <hex> | --first-seal`);
163
+ }
161
164
  return { values, sourceImages: parseSourceImages(sourceImages) };
162
165
  }
163
166
 
@@ -947,16 +947,24 @@ export function structureSubjects(structure) {
947
947
  * entrypoint, owned by the biomes whose services it carries. Neither the kernel
948
948
  * nor the SDK fixes WHERE a repository builds it, so this plane does, once:
949
949
  *
950
- * - the image directory is `infra/composed-process/` at the repository root,
951
- * holding its own `Dockerfile` and an `image-build-inputs.json` whose
950
+ * - the image directory is the per-class directory
951
+ * `infra/composed-process/<class>/` at the repository root (`<class>` a
952
+ * kernel `ComposedImageClass`; an edition addresses one composed subject
953
+ * per repository, of the kernel's `DEFAULT_COMPOSED_IMAGE_CLASS`). A commit
954
+ * that predates the per-class layout declares it at the legacy root
955
+ * `infra/composed-process/`, which stays readable because committed release
956
+ * locks still pin such commits. Both at once, or a declaration in another
957
+ * class directory, is refused (`composedProcessDeclarationDir`). The
958
+ * directory holds its own `Dockerfile` and an `image-build-inputs.json` whose
952
959
  * `workspacePackages` lists every member service's workspace package
953
960
  * directory — the Dockerfile-only image shape `hashImageBuildInputs`
954
961
  * already reads (`opencode-worker` is the other one). Its tag is therefore
955
962
  * an ordinary `xema-image-input-sha256-v2` key, computed by the one
956
963
  * implementation every producer uses, with `SERVICE` = the subject id and
957
964
  * `SERVICE_PATH` = this directory;
958
- * - the DECLARATION is that `image-build-inputs.json` existing at the pinned
959
- * commit. A repository that declares none has no composed image, and an
965
+ * - the DECLARATION is that `image-build-inputs.json` existing in that
966
+ * directory at the pinned commit. A repository that declares none (and has
967
+ * no `infra/composed-process/` tree at all) has no composed image, and an
960
968
  * edition carries no composed subject for it — which the deploy plane
961
969
  * reports as "the producer builds no composed-process image at that
962
970
  * commit". That is an honest absence, not a skipped subject: a DECLARED
@@ -1045,8 +1053,8 @@ function declaredImageClass(declared, subject, enums) {
1045
1053
  return parsed.data;
1046
1054
  }
1047
1055
 
1048
- function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
1049
- const inputsPath = `${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
1056
+ function composedProcessCarriage(structure, enums, repo, sourceCommit, locate, declarationDir) {
1057
+ const inputsPath = `${declarationDir}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
1050
1058
  const { dir } = locate(repo);
1051
1059
  let members;
1052
1060
  try {
@@ -1098,6 +1106,60 @@ function composedProcessCarriage(structure, enums, repo, sourceCommit, locate) {
1098
1106
  .sort((a, b) => compareStrings(a.biomeId, b.biomeId));
1099
1107
  }
1100
1108
 
1109
+ function gitPathExistsAtCommit(dir, sourceCommit, repoPath) {
1110
+ try {
1111
+ execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}:${repoPath}`], {
1112
+ stdio: ['ignore', 'ignore', 'ignore'],
1113
+ });
1114
+ return true;
1115
+ } catch {
1116
+ return false;
1117
+ }
1118
+ }
1119
+
1120
+ /**
1121
+ * The repo-relative directory holding a repository's composed-process
1122
+ * DECLARATION at one commit, or null when it declares none. The default class's
1123
+ * directory and the legacy root are the two places an edition reads; a
1124
+ * declaration it cannot address (both present, another class, an
1125
+ * `infra/composed-process/` tree without a recognised one) is refused, because
1126
+ * a declaration the resolver cannot find must never read as "declares nothing".
1127
+ */
1128
+ function composedProcessDeclarationDir(dir, sourceCommit, enums) {
1129
+ const defaultClass = enums.DEFAULT_COMPOSED_IMAGE_CLASS;
1130
+ const classDir = `${COMPOSED_PROCESS_IMAGE_PATH}/${defaultClass}`;
1131
+ const declares = (candidate) =>
1132
+ gitPathExistsAtCommit(dir, sourceCommit, `${candidate}/${COMPOSED_PROCESS_DECLARATION_FILE}`);
1133
+ const inClassDir = declares(classDir);
1134
+ const atLegacyRoot = declares(COMPOSED_PROCESS_IMAGE_PATH);
1135
+ if (inClassDir && atLegacyRoot) {
1136
+ throw new ComposedProcessInputsError(
1137
+ `ambiguous composed-process declaration at ${sourceCommit}: both "${classDir}/${COMPOSED_PROCESS_DECLARATION_FILE}" ` +
1138
+ `and the legacy "${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}" exist`,
1139
+ );
1140
+ }
1141
+ const otherClasses = enums.ComposedImageClassSchema.options
1142
+ .filter((candidate) => candidate !== defaultClass)
1143
+ .filter((candidate) => declares(`${COMPOSED_PROCESS_IMAGE_PATH}/${candidate}`));
1144
+ if (otherClasses.length > 0) {
1145
+ throw new ComposedProcessInputsError(
1146
+ `composed-process declaration at ${sourceCommit} is in the "${otherClasses.join('", "')}" class ` +
1147
+ `director${otherClasses.length > 1 ? 'ies' : 'y'}, but an edition addresses one composed-process subject per ` +
1148
+ `repository, of the default class "${defaultClass}"; a "${otherClasses[0]}" image has no subject naming yet`,
1149
+ );
1150
+ }
1151
+ if (inClassDir) {return classDir;}
1152
+ if (atLegacyRoot) {return COMPOSED_PROCESS_IMAGE_PATH;}
1153
+ if (gitPathExistsAtCommit(dir, sourceCommit, COMPOSED_PROCESS_IMAGE_PATH)) {
1154
+ throw new ComposedProcessInputsError(
1155
+ `"${COMPOSED_PROCESS_IMAGE_PATH}" exists at ${sourceCommit} but declares no composed-process image the edition can ` +
1156
+ `read: looked at "${classDir}/${COMPOSED_PROCESS_DECLARATION_FILE}" and ` +
1157
+ `"${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}"`,
1158
+ );
1159
+ }
1160
+ return null;
1161
+ }
1162
+
1101
1163
  /**
1102
1164
  * Read each repository's composed-process DECLARATION at the commit the
1103
1165
  * edition builds its services from. Only repositories that contribute a
@@ -1119,7 +1181,6 @@ export function readComposedProcessDeclarations(
1119
1181
  const declarations = [];
1120
1182
  for (const [repo, sourceCommit] of [...commits].sort(([a], [b]) => compareStrings(a, b))) {
1121
1183
  const { dir, base } = locate(repo);
1122
- const declarationPath = `${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
1123
1184
  // ABSENCE IS ONLY AN ANSWER ABOUT A COMMIT THAT WAS READ. A repository
1124
1185
  // missing from this layout, or one lacking the pinned commit, would
1125
1186
  // otherwise read as "declares nothing", and `dist-check` would grade a lock
@@ -1135,21 +1196,17 @@ export function readComposedProcessDeclarations(
1135
1196
  'before resolving',
1136
1197
  );
1137
1198
  }
1138
- try {
1139
- execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}:${declarationPath}`], {
1140
- stdio: ['ignore', 'ignore', 'ignore'],
1141
- });
1142
- } catch {
1143
- continue; // declares no composed-process image at this commit
1144
- }
1199
+ const declarationDir = composedProcessDeclarationDir(dir, sourceCommit, enums);
1200
+ if (declarationDir === null) {continue;} // declares no composed-process image at this commit
1201
+ const declarationPath = `${declarationDir}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
1145
1202
  const sourceId = sourceIds.get(repo);
1146
1203
  const repoRelative = relative(base, dir).split(sep).join('/');
1147
1204
  declarations.push({
1148
1205
  repo,
1149
1206
  sourceId,
1150
1207
  sourceCommit,
1151
- carriage: composedProcessCarriage(structure, enums, repo, sourceCommit, locate),
1152
- path: `${repoRelative}/${COMPOSED_PROCESS_IMAGE_PATH}`,
1208
+ carriage: composedProcessCarriage(structure, enums, repo, sourceCommit, locate, declarationDir),
1209
+ path: `${repoRelative}/${declarationDir}`,
1153
1210
  sourceManifestPath: `${repoRelative}/${declarationPath}`,
1154
1211
  sourceManifestHash: exactSourceManifestHash(dir, declarationPath, sourceCommit),
1155
1212
  });
@@ -2126,12 +2183,21 @@ export async function resolvePreviousSeal({ distributionId, previousLockPath, pr
2126
2183
  : `--previous-lock and --previous-lock-sha256 go together (one was given); give ${PREVIOUS_SEAL_USAGE}`,
2127
2184
  );
2128
2185
  }
2129
- const bytes = readFileSync(previousLockPath);
2186
+ const bytes = readPreviousLockBytes(previousLockPath, previousLockSha256);
2187
+ return parsePreviousLock(distributionId, parseStrictJson(bytes.toString('utf8'), previousLockPath), '--previous-lock');
2188
+ }
2189
+
2190
+ /**
2191
+ * The bytes of a previous lock file, held to the sha256 (hex) they were given with: the one check of the pair
2192
+ * (`flags` names the pair in the refusal). The parse, with the kernel and against the edition's id, is the caller's.
2193
+ */
2194
+ export function readPreviousLockBytes(path, sha256, flags = { lock: '--previous-lock', sha256: '--previous-lock-sha256' }) {
2195
+ const bytes = readFileSync(path);
2130
2196
  const actual = createHash('sha256').update(bytes).digest('hex');
2131
- if (actual !== previousLockSha256) {
2132
- throw new Error(`--previous-lock ${previousLockPath} has sha256 ${actual}, not the given --previous-lock-sha256 ${previousLockSha256}`);
2197
+ if (actual !== sha256) {
2198
+ throw new Error(`${flags.lock} ${path} has sha256 ${actual}, not the given ${flags.sha256} ${sha256}`);
2133
2199
  }
2134
- return parsePreviousLock(distributionId, readStrictJsonFile(previousLockPath), '--previous-lock');
2200
+ return bytes;
2135
2201
  }
2136
2202
 
2137
2203
  /** The in-repo lane's previous lock: the committed file at `outPath`, parsed before it is overwritten; null (first seal) when there is none. */