@xemahq/xema 0.33.5 → 0.34.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
@@ -95,7 +95,7 @@ The edition library builds, validates and verifies evidence statements; it never
95
95
 
96
96
  ## Kernel protocol and pins
97
97
 
98
- The CLI is coupled to the kernel's edition protocol, not to the kernel package. `@xemahq/contracts` exports the protocol identity as data (`dist/edition-protocol/edition-protocol.json`: `{contentDigest, provisional, version}`). This CLI supports exactly protocol v5 and REFUSES an identity with `provisional: true`; every loader that executes the kernel, `xema edition check` first, and the evidence verifier compare only that identity and refuse an unsupported one, naming the supported and the declared versions. A protocol change needs a CLI release first; only then does the data root repin its kernel authority.
98
+ The CLI is coupled to the kernel's edition protocol, not to the kernel package. `@xemahq/contracts` exports the protocol identity as data (`dist/edition-protocol/edition-protocol.json`: `{contentDigest, provisional, version}`). This CLI supports exactly protocol v8 (a data root whose pinned kernel speaks v5 stays on CLI 0.33.x) and REFUSES an identity with `provisional: true`; every loader that executes the kernel, `xema edition check` first, and the evidence verifier compare only that identity and refuse an unsupported one, naming the supported and the declared versions. A protocol change needs a CLI release first; only then does the data root repin its kernel authority.
99
99
 
100
100
  - `xema edition evidence statement entitlement stage` writes entitlement predicate v2 (`lockDigest`, `catalogueDigest`, read from the edition's committed locks), so it takes `--editions-root` besides `--data-root` and `--kernel`.
101
101
  - The runtime image source index is v2 and carries operator-stated `producerWorkflowPins` (the workflow commit a producer certificate must be signed from, per repository). `xema edition index runtime-image-source [--producer-workflow-pin <owner/repo>=<40-hex>]...` preserves the committed pins exactly; each flag sets one repository's pin (repeatable; a repeated or non-GitHub `owner/repo` is refused; only `--trusted-write` writes an index, so the flag with any other mode, `--check-sources` included, is refused); a bootstrap index with none writes `[]`. A v1 index is refused, never migrated: delete it and bootstrap a new one with `--kernel-commit`.
@@ -125,7 +125,7 @@ A held commit is authorized, never trusted, and nothing overrides it: the commit
125
125
 
126
126
  ## Customer editions
127
127
 
128
- 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.
128
+ 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.
129
129
 
130
130
  ```bash
131
131
  xema edition resolve --definition <file> --channel <release|develop> \
@@ -19,6 +19,7 @@
19
19
  import { execFileSync } from 'node:child_process';
20
20
  import { relative, sep } from 'node:path';
21
21
 
22
+ import { assertCustomerLockWithinCatalogueCeiling } from './catalogue.mjs';
22
23
  import { readGitJsonAtCommit } from './lib.mjs';
23
24
  import {
24
25
  assertOperationGrantsAdmitted,
@@ -191,7 +192,7 @@ async function catalogueFacts({ schemas, biome, imageLock, catalogue }) {
191
192
  * the catalogue supplied. A lock without `catalogueDigest`, or with another one, was not
192
193
  * resolved against this catalogue, so its hashes cannot be checked on this catalogue's authority.
193
194
  */
194
- function assertLockNamesCatalogue({ lock, catalogue, catalogueDigest }) {
195
+ function assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, storePublisherAnchorKeys }) {
195
196
  if (catalogueDigest === undefined) {
196
197
  throw new BiomeContentHashError(`customer mode needs the digest of catalogue "${catalogue.offeringId}"`);
197
198
  }
@@ -205,6 +206,8 @@ function assertLockNamesCatalogue({ lock, catalogue, catalogueDigest }) {
205
206
  `distribution "${lock.distributionId}": the lock was resolved against catalogue ${lock.catalogueDigest}, not the supplied ${catalogueDigest}`,
206
207
  );
207
208
  }
209
+ // The lock is within the catalogue's ceiling: the kernel's one check, its Store publishers against the definition's selection.
210
+ assertCustomerLockWithinCatalogueCeiling({ schemas, catalogue, lock, storePublisherAnchorKeys });
208
211
  }
209
212
 
210
213
  /**
@@ -218,10 +221,11 @@ function assertLockNamesCatalogue({ lock, catalogue, catalogueDigest }) {
218
221
  * @param {(repo: string) => string} request.vendoredCommit the vendored-source pin of a first-party repo
219
222
  * @param {object} [request.catalogue] customer mode: the digest-pinned catalogue vendor biomes are taken from
220
223
  * @param {string} [request.catalogueDigest] customer mode: the digest of that catalogue; the lock must carry the same one
224
+ * @param {readonly string[]} [request.storePublisherAnchorKeys] customer mode: the definition's Store publisher selection (absent = none)
221
225
  * @returns {Promise<Map<string, string>>} biome id -> BiomeContentHash
222
226
  */
223
- export async function computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest }) {
224
- if (catalogue !== undefined) {assertLockNamesCatalogue({ lock, catalogue, catalogueDigest });}
227
+ export async function computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys }) {
228
+ if (catalogue !== undefined) {assertLockNamesCatalogue({ schemas, lock, catalogue, catalogueDigest, storePublisherAnchorKeys });}
225
229
  const hashes = new Map();
226
230
  for (const biome of lock.biomes) {
227
231
  const facts =
@@ -248,8 +252,8 @@ export async function biomeSourceTreeIds({ schemas, lock, imageLock, locate, ven
248
252
  }
249
253
 
250
254
  /** Seal: the unsealed lock + the recomputed hashes -> the kernel's `sealDistributionLock`. */
251
- export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest }) {
252
- const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest });
255
+ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys }) {
256
+ const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys });
253
257
  assertOperationGrantsAdmitted(schemas, unsealed);
254
258
  assertOrganizationSweepsAdmitted(schemas, unsealed);
255
259
  return schemas.sealDistributionLock(unsealed, hashes);
@@ -259,8 +263,8 @@ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredC
259
263
  * Verify: recompute every biome's hash and REFUSE a mismatch, naming the biome.
260
264
  * The lock's own field is never trusted; a missing or extra biome is a mismatch.
261
265
  */
262
- export async function verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest }) {
263
- const expected = await computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest });
266
+ export async function verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys }) {
267
+ const expected = await computeBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys });
264
268
  const mismatched = lock.biomes
265
269
  .filter((biome) => biome.biomeContentHash !== expected.get(biome.id))
266
270
  .map((biome) => `${biome.id} (lock ${String(biome.biomeContentHash)}, recomputed ${expected.get(biome.id)})`);
@@ -17,11 +17,13 @@ import { serializeJson } from './lib.mjs';
17
17
  import { verifiedPlatformServiceIndex } from './platform-service-ownership.mjs';
18
18
 
19
19
  export const CATALOGUE_FILE = EDITION_OFFERING_LAYERS.catalogue;
20
- export const CATALOGUE_SCHEMA = 'xema.edition-catalogue/v1';
20
+ // v2 (edition protocol v8): `ceiling.storePublisherTrust`, the Store publishers the offering
21
+ // vouches for, from which a customer definition selects (`storePublisherAnchorKeys`).
22
+ export const CATALOGUE_SCHEMA = 'xema.edition-catalogue/v2';
21
23
 
22
24
  const TOP_KEYS = ['schema', 'offeringId', 'channel', 'biomes', 'sourceTreeIds', 'platformServices', 'images', 'chartSet', 'contractAuthority', 'kernel', 'ceiling'];
23
25
  const IMAGES_KEYS = ['sourceHashAlgorithm', 'subjects', 'platformServiceBindings', 'webBiomes'];
24
- const CEILING_KEYS = ['trustPolicy', 'trustCeiling'];
26
+ const CEILING_KEYS = ['trustPolicy', 'trustCeiling', 'storePublisherTrust'];
25
27
  const KERNEL_KEYS = ['name', 'version', 'integrity'];
26
28
  const KERNEL_PACKAGE = '@xemahq/contracts';
27
29
  const EXACT_SEMVER = /^\d+\.\d+\.\d+$/;
@@ -140,8 +142,76 @@ export function buildCatalogue({
140
142
  chartSet: chartSetLock,
141
143
  contractAuthority,
142
144
  kernel,
143
- ceiling: { trustPolicy: leaf?.trustPolicy ?? null, trustCeiling: leaf?.trustCeiling ?? null },
145
+ ceiling: {
146
+ trustPolicy: leaf?.trustPolicy ?? null,
147
+ trustCeiling: leaf?.trustCeiling ?? null,
148
+ // The Store publishers the offering's lock vouches for: a customer edition copies only those it selects.
149
+ storePublisherTrust: distributionLock.storePublisherTrust ?? null,
150
+ },
151
+ });
152
+ }
153
+
154
+ /** A customer lock outside its catalogue's ceiling, or a selection or dependency the catalogue does not offer. */
155
+ export class CatalogueCeilingError extends Error {
156
+ constructor(message) {
157
+ super(message);
158
+ this.name = 'CatalogueCeilingError';
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The catalogue's declared ceiling, read by the kernel's grammars: `trustPolicy` is the offering's
164
+ * origin allow list, `trustCeiling` its trust-domain assignments, `storePublisherTrust` the Store
165
+ * publishers the offering vouches for (each a kernel `LockedStorePublisher`). `null` states none.
166
+ */
167
+ export function catalogueCeiling(catalogue, schemas) {
168
+ const read = (key, schema) => {
169
+ const value = catalogue.ceiling[key];
170
+ if (value === null) {return undefined;}
171
+ const parsed = schema.safeParse(value);
172
+ if (!parsed.success) {
173
+ throw new CatalogueError(
174
+ `${CATALOGUE_FILE}: ceiling.${key} of "${catalogue.offeringId}" is not the kernel's shape: ` +
175
+ parsed.error.issues.map((issue) => `[${issue.path.join('.')}] ${issue.message}`).join('; '),
176
+ );
177
+ }
178
+ return parsed.data;
179
+ };
180
+ return {
181
+ trustPolicy: read('trustPolicy', schemas.DistributionTrustPolicySchema),
182
+ trustCeiling: read('trustCeiling', schemas.ProcessTrustPolicySchema),
183
+ storePublisherTrust: read('storePublisherTrust', schemas.LockedStorePublisherSchema.array()),
184
+ };
185
+ }
186
+
187
+ /**
188
+ * The Store publishers a customer lock carries: the entries of the catalogue's parsed ceiling
189
+ * (`catalogueCeiling(...).storePublisherTrust`) whose anchorKey the definition selects
190
+ * (`storePublisherAnchorKeys`), copied as they are. No selection, or none of it offered, is no
191
+ * field; a key the catalogue does not vouch for is refused by the kernel's ceiling check, not here.
192
+ */
193
+ export function selectedStorePublisherTrust(base, storePublisherAnchorKeys) {
194
+ if (storePublisherAnchorKeys === undefined) {return undefined;}
195
+ const selected = new Set(storePublisherAnchorKeys);
196
+ const rows = (base ?? []).filter((row) => selected.has(row.anchorKey));
197
+ return rows.length > 0 ? rows : undefined;
198
+ }
199
+
200
+ /**
201
+ * The kernel's `customerLockCeilingIssues` is the one check of a customer lock against its catalogue:
202
+ * the biomes' trust ceilings, and the Store publishers (base = the catalogue's, selected = the
203
+ * definition's `storePublisherAnchorKeys`, customer = the lock's).
204
+ */
205
+ export function assertCustomerLockWithinCatalogueCeiling({ schemas, catalogue, lock, storePublisherAnchorKeys }) {
206
+ const ceiling = catalogueCeiling(catalogue, schemas);
207
+ const issues = schemas.customerLockCeilingIssues(ceiling.trustCeiling, lock.biomes, {
208
+ base: ceiling.storePublisherTrust,
209
+ selected: storePublisherAnchorKeys,
210
+ customer: lock.storePublisherTrust,
144
211
  });
212
+ if (issues.length > 0) {
213
+ throw new CatalogueCeilingError(`catalogue "${catalogue.offeringId}" (${catalogue.channel}): "${lock.distributionId}" is outside the catalogue's ceiling: ${issues.join('; ')}`);
214
+ }
145
215
  }
146
216
 
147
217
  /** Write `catalogue.json` into an artifact directory (deterministic serialization); returns its path. */
@@ -98,6 +98,7 @@ export async function resolveCustomerDevelopEdition({ rawDefinition, catalogue,
98
98
  },
99
99
  catalogue,
100
100
  catalogueDigest,
101
+ storePublisherAnchorKeys: resolved.storePublisherAnchorKeys,
101
102
  });
102
103
  return { ...resolved, distributionLock, pins: frozen };
103
104
  }
@@ -16,7 +16,7 @@
16
16
  import { createHash } from 'node:crypto';
17
17
  import { existsSync, readFileSync } from 'node:fs';
18
18
 
19
- import { assertEditionPinsAuthorized, effectiveRepositoryPins, EditionSourcePinsError, readEditionSourcePins } from './edition-source-pins.mjs';
19
+ import { assertEditionPinsAuthorized, effectiveRepositoryPins, EditionSourcePinsError, readKernelEditionSourcePins } from './edition-source-pins.mjs';
20
20
  import { biomeIndexPath, dataRoot, editionsRoot, platformServiceIndexPath, runtimeImageSourceIndexPath, sourcesRoot } from './edition-workspace.mjs';
21
21
  import { buildIndexesAtPins } from './indexes-at-pins.mjs';
22
22
  import { commandFor } from './remedy-command.mjs';
@@ -102,7 +102,7 @@ function committedInputs({ schemas, sourcePins }) {
102
102
  * pins and committed runtime index, so one process pays the rebuild once.
103
103
  */
104
104
  export async function editionPinnedInputs(id, { schemas }) {
105
- const sourcePins = readEditionSourcePins(id);
105
+ const sourcePins = readKernelEditionSourcePins(id, schemas.parseDistribution);
106
106
  if (sourcePins.length === 0) {return committedInputs({ schemas, sourcePins });}
107
107
  const indexText = readFileSync(runtimeImageSourceIndexPath(), 'utf8');
108
108
  const committed = schemas.parseRuntimeImageSourceIndex(readStrictJsonFile(runtimeImageSourceIndexPath()));
@@ -31,8 +31,8 @@ import { readBoundedNoFollowFile } from './no-follow-file.mjs';
31
31
  /** The edition protocols this library interprets: version -> content digest. */
32
32
  export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
33
33
  Object.freeze({
34
- version: 5,
35
- contentDigest: '326d8b62ba87073fa91cf726eb596f9371904f056db688729d8800cc94c2d646',
34
+ version: 8,
35
+ contentDigest: '5f878e9eaa2cd0ab39b645e4a67259b1e39383e4931e9849c4cc746f7ae43047',
36
36
  }),
37
37
  ]);
38
38
 
@@ -8,11 +8,12 @@
8
8
  // `until` states the condition under which the hold ends. Entries are sparse:
9
9
  // one exists only for a repository the edition holds back, so an entry equal to
10
10
  // the shared pin is refused (it is not a hold). `xema edition pin|unpin` are the
11
- // only supported way to change them. It is a
12
- // CLI key, not a kernel one: the kernel's `parseDistribution` is strict, so the
13
- // key is REMOVED before the kernel parse (the precedent is a customer
14
- // definition's `imageRegistryMirror`, customer-definition.mjs), by the one
15
- // splitter below. Every reader of an edition definition goes through it.
11
+ // only supported way to change them. The kernel's Distribution schema models and
12
+ // validates the key (`DistributionSourcePinsSchema`), so the definition reaches
13
+ // `parseDistribution` unchanged and a reader that parses with the kernel takes
14
+ // `sourcePins` from the parsed manifest (`readKernelEditionSourcePins`).
15
+ // `parseEditionSourcePins` remains for the one reader that runs before any kernel
16
+ // exists (the source checkout plan, `readEditionSourcePins`) and for the pin verbs.
16
17
  //
17
18
  // `effectiveRepositoryPins` is the ONLY computation of an edition's pins: the
18
19
  // committed pins untouched (the same array) when the edition holds nothing,
@@ -87,26 +88,27 @@ export function parseEditionSourcePins(raw, id) {
87
88
  });
88
89
  }
89
90
 
90
- /**
91
- * Split a raw edition definition into the document the kernel parses (without
92
- * the CLI key) and the parsed `sourcePins`. A value that is not an object is
93
- * returned as it is for the kernel to refuse.
94
- */
95
- export function splitEditionDefinition(raw, id) {
96
- if (raw === null || typeof raw !== 'object' || Array.isArray(raw) || !Object.hasOwn(raw, SOURCE_PINS_KEY)) {
97
- return { document: raw, sourcePins: [] };
98
- }
99
- const { [SOURCE_PINS_KEY]: declared, ...document } = raw;
100
- return { document, sourcePins: parseEditionSourcePins(declared, id) };
101
- }
102
-
103
- /** The `sourcePins` of edition `id`, read from the configured editions root. */
104
- export function readEditionSourcePins(id) {
91
+ /** The raw definition of edition `id` in the configured editions root. */
92
+ function readEditionDefinition(id) {
105
93
  const path = join(editionsRoot(), id, 'xema-distribution.json');
106
94
  if (!existsSync(path)) {
107
95
  throw new Error(`distribution "${id}" not found: ${path} does not exist`);
108
96
  }
109
- return splitEditionDefinition(readStrictJsonFile(path), id).sourcePins;
97
+ return readStrictJsonFile(path);
98
+ }
99
+
100
+ /** The `sourcePins` of a kernel-parsed Distribution manifest; absent is none. */
101
+ export const manifestSourcePins = (manifest) => manifest.sourcePins ?? [];
102
+
103
+ /** The `sourcePins` of edition `id`, parsed by the kernel's `parseDistribution` (the one validator). */
104
+ export function readKernelEditionSourcePins(id, parseDistribution) {
105
+ return manifestSourcePins(parseDistribution(readEditionDefinition(id)));
106
+ }
107
+
108
+ /** The `sourcePins` of edition `id` where no kernel is loaded yet (the source checkout plan). */
109
+ export function readEditionSourcePins(id) {
110
+ const raw = readEditionDefinition(id);
111
+ return raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? parseEditionSourcePins(raw[SOURCE_PINS_KEY], id) : [];
110
112
  }
111
113
 
112
114
  /** A repository URL without a trailing `.git`, so the two forms a pin may carry compare equal. */
@@ -108,6 +108,7 @@ export const kernelDistPath = (...segments) =>
108
108
  join(kernelPackageDir(), 'dist', ...segments);
109
109
 
110
110
  export const biomeIndexPath = () => join(dataRoot(), 'biome-index.json');
111
+ export const storePublisherIdentitiesPath = () => join(dataRoot(), 'store-publisher-identities.json');
111
112
  export const platformServiceIndexPath = () =>
112
113
  join(dataRoot(), 'platform-service-index.json');
113
114
  export const runtimeImageSourceIndexPath = () =>
@@ -18,7 +18,7 @@ import {
18
18
  import { TextDecoder } from 'node:util';
19
19
 
20
20
  import { assertCanonicalDistributionId } from './distribution-paths.mjs';
21
- import { effectiveRepositoryPins, splitEditionDefinition } from './edition-source-pins.mjs';
21
+ import { effectiveRepositoryPins, manifestSourcePins } from './edition-source-pins.mjs';
22
22
  import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
23
23
  import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
24
24
  import {
@@ -40,6 +40,8 @@ const MAX_CANONICAL_JSON_BYTES = 2 * 1024 * 1024;
40
40
  const MAX_STATEMENT_BYTES = 2 * 1024 * 1024;
41
41
  const MAX_SIGSTORE_BUNDLE_BYTES = 16 * 1024 * 1024;
42
42
  const MAX_TRUSTED_ROOT_BYTES = 8 * 1024 * 1024;
43
+ /** The kernel's `SignerKind.OidcCertificate`: the one signer kind this verifier runs (cosign keyless). */
44
+ const OIDC_CERTIFICATE_SIGNER = 'oidc-certificate';
43
45
  const COSIGN_ENV = Object.freeze({
44
46
  LANG: 'C',
45
47
  LC_ALL: 'C',
@@ -631,13 +633,21 @@ function assertEvidenceChannel(channel) {
631
633
  * certificate matching a row of that channel.
632
634
  */
633
635
  function policyCandidates(policy, predicateType, channel, imageRepository) {
634
- return policy.policies.filter(
636
+ const candidates = policy.policies.filter(
635
637
  (candidate) =>
636
638
  candidate.channel === channel &&
637
639
  candidate.predicateTypes.includes(predicateType) &&
638
640
  (imageRepository === undefined ||
639
641
  candidate.imageRepositories?.includes(imageRepository) === true),
640
642
  );
643
+ // The kernel policy also admits `public-key` signers; this verifier runs cosign keyless only, so such a row is refused, never skipped.
644
+ const unsupported = candidates.find((candidate) => candidate.kind !== OIDC_CERTIFICATE_SIGNER);
645
+ if (unsupported !== undefined) {
646
+ throw new Error(
647
+ `policy row "${unsupported.policyId}" is a ${JSON.stringify(unsupported.kind)} signer; this CLI verifies ${OIDC_CERTIFICATE_SIGNER} signers only`,
648
+ );
649
+ }
650
+ return candidates;
641
651
  }
642
652
 
643
653
  function cosignVersion(cosignBinary) {
@@ -1317,19 +1327,18 @@ export async function loadValidatedImageLocks(
1317
1327
  'images.lock.json kernel contract authority does not exactly match runtime-image-source-index.json',
1318
1328
  );
1319
1329
  }
1320
- // The definition carries the CLI-owned `sourcePins` (the repositories this edition holds at an exact
1321
- // commit); the kernel parses the document without it. Each subject is graded against the EFFECTIVE pin
1322
- // of THIS edition. Pure: no git, no sources root (this runs inside the built verifier).
1323
- const { document, sourcePins } = splitEditionDefinition(
1330
+ // The definition's `sourcePins` (the repositories this edition holds at an exact commit) is parsed by the
1331
+ // kernel with the rest of it. Each subject is graded against the EFFECTIVE pin of THIS edition. Pure: no
1332
+ // git, no sources root (this runs inside the built verifier).
1333
+ const distribution = schemas.parseDistribution(
1324
1334
  JSON.parse(
1325
1335
  readFileSync(
1326
1336
  join(editionsRoot, distributionId, 'xema-distribution.json'),
1327
1337
  'utf8',
1328
1338
  ),
1329
1339
  ),
1330
- distributionId,
1331
1340
  );
1332
- const distribution = schemas.parseDistribution(document);
1341
+ const sourcePins = manifestSourcePins(distribution);
1333
1342
  const repositoryPins = new Map(
1334
1343
  effectiveRepositoryPins(runtimeSourceIndex.repositoryPins, sourcePins, { id: distributionId, contractAuthority: runtimeSourceIndex.contractAuthority }).map((repository) => [
1335
1344
  repository.repo,
package/edition/lib.mjs CHANGED
@@ -778,6 +778,7 @@ export async function loadKernelDistributionSchemas() {
778
778
  distributionImagesPredicateChannelRefusal,
779
779
  admitOperationGrants,
780
780
  admitOrganizationSweeps,
781
+ contractRequiredServiceTargets,
781
782
  } = mod;
782
783
  let topology;
783
784
  try {
@@ -789,7 +790,7 @@ export async function loadKernelDistributionSchemas() {
789
790
  );
790
791
  }
791
792
  const { deriveTrustCeiling, defaultTrustDomain, FIRST_PARTY_TRUST_DOMAIN, ProcessTrustPolicySchema } = topology;
792
- const { DistributionTrustPolicySchema } = mod;
793
+ const { DistributionTrustPolicySchema, LockedStorePublisherSchema } = mod;
793
794
  if (
794
795
  typeof parseDistribution !== 'function' ||
795
796
  typeof parseDistributionLock !== 'function' ||
@@ -874,6 +875,7 @@ export async function loadKernelDistributionSchemas() {
874
875
  // the resolve refuse through it (`assertOperationGrantsAdmitted`).
875
876
  typeof admitOperationGrants !== 'function' ||
876
877
  typeof admitOrganizationSweeps !== 'function' ||
878
+ typeof contractRequiredServiceTargets !== 'function' ||
877
879
  typeof distributionImagesPredicateLockRefusal !== 'function' ||
878
880
  typeof distributionLockDigest !== 'function' ||
879
881
  typeof imageDeliveryEntitlementRefusal !== 'function' ||
@@ -884,7 +886,9 @@ export async function loadKernelDistributionSchemas() {
884
886
  typeof defaultTrustDomain !== 'function' ||
885
887
  typeof FIRST_PARTY_TRUST_DOMAIN !== 'string' ||
886
888
  typeof ProcessTrustPolicySchema?.safeParse !== 'function' ||
887
- typeof DistributionTrustPolicySchema?.safeParse !== 'function'
889
+ typeof DistributionTrustPolicySchema?.safeParse !== 'function' ||
890
+ // One Store publisher entry of a lock: the grammar a catalogue's `ceiling.storePublisherTrust` is read by.
891
+ typeof LockedStorePublisherSchema?.array !== 'function'
888
892
  ) {
889
893
  throw new Error(
890
894
  `Built kernel distribution module at ${kernelDistributionDist()} does not export ` +
@@ -894,7 +898,7 @@ export async function loadKernelDistributionSchemas() {
894
898
  `kernel contract-authority digest SSOT/compareLockedServiceResources/resolveNexusBindings/describeNexusBindingRefusal/canonicalNexusFacts/` +
895
899
  `positive closure (resolveBiomeClosure/platformInvariantRoots/DistributionResolutionMode/provenance)/` +
896
900
  `deriveWorkflowSubjects/deriveTrustCeiling (>= 0.71.0)/admitOperationGrants/admitOrganizationSweeps/` +
897
- `defaultTrustDomain/FIRST_PARTY_TRUST_DOMAIN/ProcessTrustPolicySchema/DistributionTrustPolicySchema/` +
901
+ `defaultTrustDomain/FIRST_PARTY_TRUST_DOMAIN/ProcessTrustPolicySchema/DistributionTrustPolicySchema/LockedStorePublisherSchema/` +
898
902
  `resolveFirstPartyCapabilityDomains/DistributionVisibility/` +
899
903
  `BiomeInstallPolicy/BiomeLaunchMode/ImageSourceHashAlgorithm/runtime-image enums — ` +
900
904
  `the dist is stale; rebuild kernel-contracts.`,
@@ -968,10 +972,12 @@ export async function loadKernelDistributionSchemas() {
968
972
  FIRST_PARTY_TRUST_DOMAIN,
969
973
  ProcessTrustPolicySchema,
970
974
  DistributionTrustPolicySchema,
975
+ LockedStorePublisherSchema,
971
976
  distributionLockDigest,
972
977
  distributionImagesPredicateChannelRefusal,
973
978
  admitOperationGrants,
974
979
  admitOrganizationSweeps,
980
+ contractRequiredServiceTargets,
975
981
  distributionImagesPredicateLockRefusal,
976
982
  imageDeliveryEntitlementRefusal,
977
983
  producerWorkflowPinRefusal,
@@ -25,16 +25,31 @@ export class OperationGrantAdmissionError extends Error {
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * One informational line per optional operation the kernel left ungranted
30
+ * (`ungrantedOptional`): the edition is valid, the caller runs without it.
31
+ */
32
+ export function ungrantedOptionalOperationLines(distributionId, ungrantedOptional) {
33
+ return ungrantedOptional.map(
34
+ ({ caller, operation, reason }) =>
35
+ `distribution "${distributionId}": optional operation ${operation} of ${caller.biome}/${caller.component} is not granted (${reason})`,
36
+ );
37
+ }
38
+
28
39
  /**
29
40
  * Admit every operation requirement of `lock` through the kernel, or throw
30
41
  * {@link OperationGrantAdmissionError} listing every refusal. Returns the
31
- * admitted grants.
42
+ * admitted grants. `report`, when given, receives each line of
43
+ * {@link ungrantedOptionalOperationLines}; an ungranted optional operation never fails.
32
44
  */
33
- export function assertOperationGrantsAdmitted(schemas, lock) {
34
- const { grants, refusals } = schemas.admitOperationGrants(lock);
45
+ export function assertOperationGrantsAdmitted(schemas, lock, { report } = {}) {
46
+ const { grants, refusals, ungrantedOptional } = schemas.admitOperationGrants(lock);
35
47
  if (refusals.length > 0) {
36
48
  throw new OperationGrantAdmissionError(lock.distributionId, refusals);
37
49
  }
50
+ if (report !== undefined) {
51
+ for (const line of ungrantedOptionalOperationLines(lock.distributionId, ungrantedOptional)) {report(line);}
52
+ }
38
53
  return grants;
39
54
  }
40
55
 
@@ -20,7 +20,13 @@ 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 { CATALOGUE_FILE, CatalogueError } from './catalogue.mjs';
23
+ import {
24
+ assertCustomerLockWithinCatalogueCeiling,
25
+ CatalogueCeilingError,
26
+ CatalogueError,
27
+ catalogueCeiling,
28
+ selectedStorePublisherTrust,
29
+ } from './catalogue.mjs';
24
30
  import {
25
31
  assertTrustCeilingAssignsCustomerBiomes,
26
32
  mirrorVendorSubjects,
@@ -36,45 +42,11 @@ import { pinnedSource, snapshotPinSource } from './pin-source.mjs';
36
42
  import { composeAuthorizationBase, composeManifest, resolveDistributionWithIndex, UnknownSelectionError } from './resolve-distribution.mjs';
37
43
  import { resolveImageRows } from './resolve-images.mjs';
38
44
 
39
- /** A selection or dependency the catalogue does not offer: the catalogue is the ceiling. */
40
- export class CatalogueCeilingError extends Error {
41
- constructor(message) {
42
- super(message);
43
- this.name = 'CatalogueCeilingError';
44
- }
45
- }
46
-
47
- /**
48
- * The catalogue's declared ceiling, read by the kernel's two grammars: `trustPolicy` is the
49
- * offering's origin allow list, `trustCeiling` its trust-domain assignments. `null` states none.
50
- */
51
- function catalogueCeiling(catalogue, schemas) {
52
- const read = (key, schema) => {
53
- const value = catalogue.ceiling[key];
54
- if (value === null) {return undefined;}
55
- const parsed = schema.safeParse(value);
56
- if (!parsed.success) {
57
- throw new CatalogueError(
58
- `${CATALOGUE_FILE}: ceiling.${key} of "${catalogue.offeringId}" is not the kernel's shape: ` +
59
- parsed.error.issues.map((issue) => `[${issue.path.join('.')}] ${issue.message}`).join('; '),
60
- );
61
- }
62
- return parsed.data;
63
- };
64
- return { trustPolicy: read('trustPolicy', schemas.DistributionTrustPolicySchema), trustCeiling: read('trustCeiling', schemas.ProcessTrustPolicySchema) };
65
- }
45
+ export { CatalogueCeilingError };
66
46
 
67
47
  /** A catalogue whose ceiling states no trust-domain assignments reserves no domain: the kernel's required ceiling argument is the empty policy. */
68
48
  const NO_CATALOGUE_TRUST_CEILING = Object.freeze({ assignments: [] });
69
49
 
70
- /** The kernel's `customerLockCeilingIssues` is the one check of a customer lock against the catalogue's ceiling. */
71
- function assertLockWithinCatalogueCeiling({ catalogue, definition, lock, schemas }) {
72
- const issues = schemas.customerLockCeilingIssues(catalogueCeiling(catalogue, schemas).trustCeiling, lock.biomes);
73
- if (issues.length > 0) {
74
- throw new CatalogueCeilingError(`catalogue "${catalogue.offeringId}" (${catalogue.channel}): "${definition.id}" is outside the catalogue's trustCeiling: ${issues.join('; ')}`);
75
- }
76
- }
77
-
78
50
  /**
79
51
  * The ceiling policy the customer's lock is written under: the catalogue's assignments, then
80
52
  * the definition's for what the catalogue leaves open. An assignment of the definition that
@@ -128,7 +100,9 @@ function withSourceImages(definition, sourceImages, pins) {
128
100
  /** The composition of a customer definition over the catalogue. */
129
101
  export function composeCustomerEdition({ schemas, definition, mirror, catalogue, sourceImages, pins }) {
130
102
  const ceiling = catalogueCeiling(catalogue, schemas);
131
- const manifest = schemas.parseDistribution({ ...definition, ...(definition.sources === undefined ? {} : { sources: withSourceImages(definition, sourceImages, pins) }) });
103
+ // `storePublisherAnchorKeys` selects from the catalogue (`selectedStorePublisherTrust`); the Distribution manifest is strict and has no such key.
104
+ const { storePublisherAnchorKeys: _selection, ...document } = definition;
105
+ const manifest = schemas.parseDistribution({ ...document, ...(definition.sources === undefined ? {} : { sources: withSourceImages(definition, sourceImages, pins) }) });
132
106
  if (definition.sources === undefined && sourceImages.size > 0) {
133
107
  throw new Error('--source-image given, but the definition declares no sources');
134
108
  }
@@ -295,7 +269,14 @@ export async function resolveCustomerDistribution({
295
269
  // `sourcesAvailable: false`: a customer machine holds no vendor source checkouts, so the platform
296
270
  // services' ownership cannot be re-derived here. It is verified where the catalogue is produced
297
271
  // (`buildCatalogue`), and the catalogue is digest-pinned.
298
- const inputs = { composed, biomeIndex: catalogue.biomes, platformServiceIndex: { schemaVersion: 1, services: catalogue.platformServices }, sourcesAvailable: false };
272
+ // A customer edition carries only the catalogue's Store publishers its definition selects, copied (`storePublisherTrust`).
273
+ const inputs = {
274
+ composed,
275
+ biomeIndex: catalogue.biomes,
276
+ platformServiceIndex: { schemaVersion: 1, services: catalogue.platformServices },
277
+ sourcesAvailable: false,
278
+ storePublisherTrust: selectedStorePublisherTrust(catalogueCeiling(catalogue, schemas).storePublisherTrust, definition.storePublisherAnchorKeys),
279
+ };
299
280
  let resolved;
300
281
  try {
301
282
  resolved = await resolveDistributionWithIndex(id, { inputs, ...(materialize === undefined ? {} : { materialize }) });
@@ -312,22 +293,24 @@ export async function resolveCustomerDistribution({
312
293
  lock.biomes.filter((biome) => biome.source !== undefined).map((biome) => biome.id),
313
294
  catalogueCeiling(catalogue, schemas).trustCeiling ?? NO_CATALOGUE_TRUST_CEILING,
314
295
  );
315
- assertLockWithinCatalogueCeiling({ catalogue, definition, lock, schemas });
296
+ const { storePublisherAnchorKeys } = definition;
297
+ assertCustomerLockWithinCatalogueCeiling({ schemas, catalogue, lock, storePublisherAnchorKeys });
316
298
  await assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(catalogue.biomes), fetchManifest });
317
299
  const authorizationLock = await composeAuthorizationBase(id, lock, index);
318
300
  // A customer lock names the catalogue it was resolved against; seal and verify refuse any other.
319
- return { id, schemas, composed, inputs, lock: { ...lock, catalogueDigest }, authorizationLock };
301
+ return { id, schemas, composed, inputs, lock: { ...lock, catalogueDigest }, authorizationLock, storePublisherAnchorKeys };
320
302
  }
321
303
 
322
304
  /** 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. */
323
305
  export async function resolveCustomerEdition(request) {
324
- const { id, schemas, composed, inputs, lock, authorizationLock } = await resolveCustomerDistribution(request);
306
+ const { id, schemas, composed, inputs, lock, authorizationLock, storePublisherAnchorKeys } = await resolveCustomerDistribution(request);
325
307
  const { catalogue } = request;
326
308
  const imageLock = await resolveCustomerImages({ id, catalogue, lock, composed, schemas, inputs, sealOwn: request.sealOwn });
327
309
  // The chart-set is copied whole, so it carries the coordinates of the channel it was published on:
328
310
  // the edition being written states its channel, and a chart-set of the other one is refused here.
329
311
  const chartSetLock = assertRosteredChartSetLock(catalogue.chartSet, `the catalogue's chart-set of "${catalogue.offeringId}"`);
330
- return { id, distributionLock: lock, imageLock, authorizationLock, chartSetLock };
312
+ // `storePublisherAnchorKeys` (the definition's selection) is what the seal checks the lock's Store publishers against.
313
+ return { id, distributionLock: lock, imageLock, authorizationLock, chartSetLock, storePublisherAnchorKeys };
331
314
  }
332
315
 
333
316
  /** Write the four locks to `<out>/<id>/` (the layout of an editions root). */