@xemahq/xema 0.41.1 → 0.42.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
@@ -100,7 +100,9 @@ The edition library builds, validates and verifies evidence statements; it never
100
100
 
101
101
  ## Kernel protocol and pins
102
102
 
103
- 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.
103
+ 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 protocols v9 to v14, each pinned to its digest and vendored (v10 to v14 grow the kernel source closure only; a data root whose pinned kernel speaks v8 stays on CLI 0.41.x, v5 on 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.
104
+
105
+ A develop edition does not take the kernel's develop head blindly: `xema edition develop choose-kernel --kernel <package dir> [--kernel-pin <40-hex>] --out <file>` reads the declared protocol of each commit on the checkout's first-parent history (from source, unbuilt) and chooses the newest one this CLI supports. It writes `{chosenSha, headSha, held, reason}` (`reason`: `head-supported` | `head-ahead` | `explicit-pin`; `held` is `head-ahead`), and when held prints `kernel held at v<N> (<sha12>), head at v<M> (<sha12>): raise the CLI` (stdout only, never persisted). The head is the checkout's `refs/remotes/origin/develop` (a full-history checkout). An explicit pin wins but is still checked, and must be on that head's first-parent history (`not-on-develop` otherwise); when no commit is supported it refuses (`EditionProtocolMismatchError`, `no-supported-commit`; the walk ends at the first commit with no declaration file) and never falls back to the head; a declaration that exists but does not parse, at the head or any walked commit, is refused by name (`unparseable`), never skipped. `freeze-graph` applies the same check to `--kernel-sha`.
104
106
 
105
107
  - `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`.
106
108
  - A source repository JOINS a data root that has committed pins with one command: `xema edition index runtime-image-source --trusted-write --repo <repository> [--at <40-hex>] [--kernel-root <checkout>]`. The repository is resolved once and the runtime image source, biome and platform-service indexes are all written from the committed pins plus that one pin; every committed pin is kept field for field (a differing one is refused by name), a second declared repository with no pin is refused, and the summary names each pin added, moved and dropped. Then `xema edition index biome --check`, `xema edition index runtime-image-source --check` and `pnpm check`. The biome builder takes no `--repo`: it would resolve the repository a second time.
@@ -22,8 +22,10 @@ import { relative, sep } from 'node:path';
22
22
  import { assertCustomerLockWithinCatalogueCeiling } from './catalogue.mjs';
23
23
  import { readGitJsonAtCommit } from './lib.mjs';
24
24
  import {
25
+ assertInstalledOperationCeilingAdmitted,
25
26
  assertOperationGrantsAdmitted,
26
27
  assertOrganizationSweepsAdmitted,
28
+ assertStoreWorkloadCeilingAdmitted,
27
29
  } from './operation-grant-admission.mjs';
28
30
 
29
31
  /** A biome whose recomputed content hash is not the lock's, or whose inputs cannot be recomputed. */
@@ -289,6 +291,8 @@ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredC
289
291
  const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById });
290
292
  assertOperationGrantsAdmitted(schemas, unsealed);
291
293
  assertOrganizationSweepsAdmitted(schemas, unsealed);
294
+ assertInstalledOperationCeilingAdmitted(schemas, unsealed);
295
+ assertStoreWorkloadCeilingAdmitted(schemas, unsealed);
292
296
  return schemas.sealDistributionLock(unsealed, hashes);
293
297
  }
294
298
 
@@ -214,6 +214,7 @@ const COMMANDS = Object.freeze({
214
214
  required: ['--pins', '--cosign'],
215
215
  },
216
216
  'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
217
+ 'choose-kernel': { positional: false, values: ['--kernel-pin', '--out'], switches: [], required: ['--out'] },
217
218
  'check-existing': {
218
219
  positional: true,
219
220
  values: ['--pins', '--artifact', '--engine-image'],
@@ -30,7 +30,8 @@
30
30
  // [--overrides <json>] --out <file>
31
31
  // resolve refs/heads/develop once per graph repository (and the editions
32
32
  // root) from this checkout's runtime-image-source-index.json; the three checked-out
33
- // repositories are pinned at what the workflow checked out; write the pins.
33
+ // repositories are pinned at what the workflow checked out; write the pins. --kernel-sha is refused
34
+ // unless its declared edition protocol is one this CLI supports (the check choose-kernel applies to a pin).
34
35
  // check-existing <id> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
35
36
  // refuse to reuse a pulled artifact whose descriptor disagrees with this snapshot.
36
37
  // check-existing --definition <file> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
@@ -44,6 +45,13 @@
44
45
  // annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
45
46
  // and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
46
47
  // not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
48
+ // choose-kernel [--kernel-pin <40-hex>] --out <file>
49
+ // choose the develop kernel commit in the --kernel checkout (full history; its refs/remotes/origin/develop is the develop head):
50
+ // the newest first-parent commit whose declared edition protocol this CLI supports
51
+ // (kernel-protocol-choice.mjs). Writes {chosenSha, headSha, held, reason} (reason: head-supported |
52
+ // head-ahead | explicit-pin); when held, prints `kernel held at v<N> (<sha12>), head at v<M> (<sha12>):
53
+ // raise the CLI` to stdout (never persisted). A pin wins and is still checked; nothing supported is refused.
54
+ // Takes only --kernel (the checkout need not be built).
47
55
  // check-overrides --overrides <json>
48
56
  // refuse a pins override that would select the tooling this run executes
49
57
  // (the edition tooling's own repository); the workflow runs it before any other checkout.
@@ -68,8 +76,9 @@ import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
68
76
  import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
69
77
  import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
70
78
  import { EditionChannel } from './edition-constants.mjs';
71
- import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
79
+ import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
72
80
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
81
+ import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
73
82
  import { loadKernelDistributionSchemas } from './lib.mjs';
74
83
  import { snapshotPinSource } from './pin-source.mjs';
75
84
  import { composeEdition } from './resolve-distribution.mjs';
@@ -85,11 +94,15 @@ const VENDOR_ROOTS = Object.freeze([WorkspaceRoot.Data, WorkspaceRoot.Editions,
85
94
  const COVERAGE_COMMAND = 'coverage';
86
95
  const COVERAGE_OPERATIONAL_EXIT = CoverageExit.Operational;
87
96
  const DATA_ROOT_ONLY_COMMANDS = Object.freeze(['check-overrides']);
97
+ // `choose-kernel` reads the kernel checkout's git history only, before the kernel is built.
98
+ const KERNEL_ROOT_ONLY_COMMANDS = Object.freeze(['choose-kernel']);
88
99
 
89
100
  if (isEntryModule(import.meta.url)) {
90
101
  const [command] = process.argv.slice(2);
91
102
  if (DATA_ROOT_ONLY_COMMANDS.includes(command)) {
92
103
  configureEditionWorkspaceFromProcess([WorkspaceRoot.Data], { refuseOthers: true });
104
+ } else if (KERNEL_ROOT_ONLY_COMMANDS.includes(command)) {
105
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Kernel], { refuseOthers: true });
93
106
  } else {
94
107
  const customerForm = process.argv.includes(DEFINITION_FLAG);
95
108
  configureEditionWorkspaceFromProcess(customerForm ? (CUSTOMER_ROOTS[command] ?? VENDOR_ROOTS) : VENDOR_ROOTS, {
@@ -165,7 +178,14 @@ async function run(argv, env) {
165
178
  assertNoToolingOverride(parseOverrides(options['--overrides']));
166
179
  return;
167
180
  }
181
+ case 'choose-kernel': {
182
+ const { choice, notice } = chooseKernelCommit({ repoDir: kernelRepoDir(), pin: options['--kernel-pin'] });
183
+ writeOnce(options['--out'], choice);
184
+ if (notice !== undefined) {console.log(notice);}
185
+ return;
186
+ }
168
187
  case 'freeze-graph': {
188
+ assertKernelCommitSupported({ repoDir: kernelRepoDir(), commit: options['--kernel-sha'], label: `--kernel-sha ${options['--kernel-sha']}` });
169
189
  const pins = freezeGraph({
170
190
  runtimeSourceIndex: readStrictJsonFile(runtimeImageSourceIndexPath()),
171
191
  dataSha: options['--data-sha'],
@@ -22,9 +22,10 @@ export class UndeclaredSupplementalRuntimeComponentError extends Error {
22
22
  }
23
23
  }
24
24
 
25
- // The `supplementalRuntimeComponentId` field is read as plain data: this CLI is on
26
- // @xemahq/contracts 0.83, which does not type it. XEMA-171 (CLI edition protocol
27
- // v9) adopts the typed field from @xemahq/contracts >= 0.91.
25
+ // The `supplementalRuntimeComponentId` field is read as plain data. Since edition
26
+ // protocol v10 the kernel types it (`DispatchedRuntimeComponentSchema`, a union of
27
+ // two strict objects); this projection keeps reading it as data and refuses a target
28
+ // that is neither form, or both.
28
29
  const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
29
30
 
30
31
  /** True for the supplemental form of a projected edge. */
@@ -28,12 +28,15 @@ import { realpathSync } from 'node:fs';
28
28
 
29
29
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
30
30
 
31
- /** The edition protocols this library interprets: version -> content digest. */
31
+ /** The edition protocols this library interprets: version -> content digest. v10..v14 grow the kernel
32
+ * source closure only (entry subpaths and hashing unchanged), so one library speaks every row. */
32
33
  export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
33
- Object.freeze({
34
- version: 8,
35
- contentDigest: '5f878e9eaa2cd0ab39b645e4a67259b1e39383e4931e9849c4cc746f7ae43047',
36
- }),
34
+ Object.freeze({ version: 9, contentDigest: '7cd6d760e7e7caaec3634a08c739debbdeeeaac4f1bad6791246a12f4f248e37' }),
35
+ Object.freeze({ version: 10, contentDigest: '660129065650a8300f7c0ad54f957b4d43507e1a44e093c9812d2b1bbf143104' }),
36
+ Object.freeze({ version: 11, contentDigest: '600451d2253c470c7d21bafe87260dfc3bfd43b6ad3eee2ef4d8b1f7ad08eb6e' }),
37
+ Object.freeze({ version: 12, contentDigest: 'cff51ea06b6ba7d95d02dcd6305b6a55061b428da48ae74dcbff8a5c8107ce41' }),
38
+ Object.freeze({ version: 13, contentDigest: '5f69fe73d1dea1650ef1757204a53ccd4030ade78bfa91959c2d01c937fc0e16' }),
39
+ Object.freeze({ version: 14, contentDigest: '403b6a491b5922df1a423ba8a2d6ae3d3900bb1caff4e8c0caa48de90a47eb9b' }),
37
40
  ]);
38
41
 
39
42
  /** Where a kernel package publishes its identity and its contract, package-relative. */
@@ -64,6 +67,12 @@ export const EditionProtocolMismatchReason = Object.freeze({
64
67
  ContractMismatch: 'contract-mismatch',
65
68
  /** The kernel's protocol is not the one compiled into the running verifier. */
66
69
  BundleMismatch: 'bundle-mismatch',
70
+ /** No commit on the kernel checkout's first-parent history declares a protocol this library supports. */
71
+ NoSupportedCommit: 'no-supported-commit',
72
+ /** A kernel commit's protocol declaration source exists but does not parse; refused, never skipped. */
73
+ Unparseable: 'unparseable',
74
+ /** An explicit kernel commit is not on the first-parent history of the checkout's develop head. */
75
+ NotOnDevelop: 'not-on-develop',
67
76
  });
68
77
 
69
78
  export class EditionProtocolMismatchError extends Error {
@@ -76,7 +85,8 @@ export class EditionProtocolMismatchError extends Error {
76
85
 
77
86
  const describe = ({ version, contentDigest }) => `edition protocol v${version} (${contentDigest})`;
78
87
 
79
- const supportedList = () =>
88
+ /** `v<N>, v<M>`: the versions this library supports, as every refusal names them. */
89
+ export const supportedList = () =>
80
90
  SUPPORTED_EDITION_PROTOCOLS.map((protocol) => `v${protocol.version}`).join(', ');
81
91
 
82
92
  /** Refuse an identity that states `provisional: true`; nothing provisional is ever read as a protocol. */
@@ -147,10 +157,11 @@ function contractIdentity(contractBytes) {
147
157
  }
148
158
 
149
159
  /**
150
- * Refuse an identity this library does not support, and a contract file that
151
- * does not state that identity. Returns the identity.
160
+ * Refuse an identity this library does not support: a provisional one, an
161
+ * unsupported version, or a supported version at another digest. Returns it.
152
162
  */
153
- export function assertSupportedEditionProtocol({ identity, contractBytes, source }) {
163
+ export function assertSupportedEditionIdentity(identity, source) {
164
+ assertFinalIdentity(identity, source);
154
165
  const supported = SUPPORTED_EDITION_PROTOCOLS.find(
155
166
  (protocol) => protocol.version === identity.version,
156
167
  );
@@ -169,6 +180,22 @@ export function assertSupportedEditionProtocol({ identity, contractBytes, source
169
180
  'contract without a new protocol version.',
170
181
  );
171
182
  }
183
+ return identity;
184
+ }
185
+
186
+ /** True when `identity` is final and is a row of the supported table (version and digest). */
187
+ export const isSupportedEditionIdentity = (identity) =>
188
+ identity?.provisional === false &&
189
+ SUPPORTED_EDITION_PROTOCOLS.some(
190
+ (protocol) => protocol.version === identity.version && protocol.contentDigest === identity.contentDigest,
191
+ );
192
+
193
+ /**
194
+ * Refuse an identity this library does not support, and a contract file that
195
+ * does not state that identity. Returns the identity.
196
+ */
197
+ export function assertSupportedEditionProtocol({ identity, contractBytes, source }) {
198
+ assertSupportedEditionIdentity(identity, source);
172
199
  const stated = contractIdentity(contractBytes);
173
200
  if (stated?.version !== identity.version || stated?.contentDigest !== identity.contentDigest) {
174
201
  throw new EditionProtocolMismatchError(
@@ -38,6 +38,8 @@ const ROOT_MEANING = Object.freeze({
38
38
  });
39
39
 
40
40
  const KERNEL_PACKAGE_SUBPATH = ['packages', 'kernel', 'kernel-contracts'];
41
+ /** The kernel package's path inside the kernel-contracts repository, `/`-separated (a git tree path). */
42
+ export const KERNEL_PACKAGE_REPO_PATH = KERNEL_PACKAGE_SUBPATH.join('/');
41
43
 
42
44
  let configured = Object.freeze({});
43
45
 
@@ -0,0 +1,239 @@
1
+ // kernel-protocol-choice — which kernel commit a develop edition runs.
2
+ //
3
+ // The CLI runs the kernel's code and refuses a protocol it does not support
4
+ // (edition-protocol.mjs), so a develop edition that always took the kernel's
5
+ // develop HEAD would break on every kernel protocol bump until a CLI release
6
+ // caught up. Commits that declare the same protocol identity (version AND
7
+ // digest) are interchangeable by the digest contract, so the develop kernel is
8
+ // the NEWEST commit on the checkout's first-parent history whose declared
9
+ // identity is in this CLI's supported set: a deterministic choice, never a
10
+ // fallback.
11
+ //
12
+ // - head supported -> the head (reason head-supported)
13
+ // - head ahead of the CLI -> the newest supported (reason head-ahead, held)
14
+ // - an explicit 40-hex pin -> the pin, still checked (reason explicit-pin); it must be on the first-parent
15
+ // history of the develop head (refs/remotes/origin/develop), else not-on-develop
16
+ // - nothing supported -> refused (EditionProtocolMismatchError, no-supported-commit); never the head.
17
+ // The walk ends at the first commit with no declaration file (it predates the protocol).
18
+ // - a declaration that exists but does not parse, at the head or any walked commit
19
+ // -> refused (EditionProtocolMismatchError, unparseable) naming the commit; never skipped
20
+ //
21
+ // The declared identity is read from each commit's SOURCE
22
+ // (`<kernel package>/src/edition-protocol/lib/edition-protocol.ts`, the literal
23
+ // `EDITION_PROTOCOL = Object.freeze({ version, provisional, contentDigest })`),
24
+ // because the history is not built; the built kernel is checked again from its
25
+ // dist files by every loader. Reads git objects only; writes nothing.
26
+
27
+ import { execFileSync } from 'node:child_process';
28
+
29
+ import {
30
+ EditionProtocolMismatchError,
31
+ EditionProtocolMismatchReason,
32
+ assertSupportedEditionIdentity,
33
+ isSupportedEditionIdentity,
34
+ supportedList,
35
+ } from './edition-protocol.mjs';
36
+ import { KERNEL_PACKAGE_REPO_PATH } from './edition-workspace.mjs';
37
+
38
+ /** The kernel source file that declares the edition protocol identity, repository-relative. */
39
+ export const KERNEL_EDITION_PROTOCOL_SOURCE = `${KERNEL_PACKAGE_REPO_PATH}/src/edition-protocol/lib/edition-protocol.ts`;
40
+
41
+ /**
42
+ * The kernel checkout's develop head: the remote-tracking ref a full-history checkout (`fetch-depth: 0`)
43
+ * always has. Every choice and every explicit commit is judged against its first-parent history.
44
+ */
45
+ export const KERNEL_DEVELOP_REF = 'refs/remotes/origin/develop';
46
+
47
+ /** Why a kernel commit was chosen. A closed set; `held` is exactly `head-ahead`. */
48
+ export const KernelChoiceReason = Object.freeze({
49
+ HeadSupported: 'head-supported',
50
+ HeadAhead: 'head-ahead',
51
+ ExplicitPin: 'explicit-pin',
52
+ });
53
+
54
+ const SHA_PATTERN = /^[0-9a-f]{40}$/;
55
+ const MAX_GIT_OUTPUT = 256 * 1024 * 1024;
56
+ const DECLARATION = /export const EDITION_PROTOCOL\b[^=]*=\s*Object\.freeze\(\{([^}]*)\}\)/g;
57
+ const FIELDS = Object.freeze([
58
+ ['version', /\bversion:\s*([1-9][0-9]*)\s*(?:,|$)/],
59
+ ['provisional', /\bprovisional:\s*(true|false)\s*(?:,|$)/],
60
+ ['contentDigest', /\bcontentDigest:\s*'([a-f0-9]{64})'\s*(?:,|$)/],
61
+ ]);
62
+
63
+ /** A declaration source that exists but whose `EDITION_PROTOCOL` literal is absent or not exactly the three fields. */
64
+ const malformed = (label) =>
65
+ new EditionProtocolMismatchError(
66
+ EditionProtocolMismatchReason.Unparseable,
67
+ `${label} does not parse: it must declare exactly one EDITION_PROTOCOL = Object.freeze({ version: <positive integer>, ` +
68
+ "provisional: <boolean>, contentDigest: '<64 lowercase hex>' })",
69
+ );
70
+
71
+ /**
72
+ * The identity `{contentDigest, provisional, version}` the source text declares. Strict: exactly one
73
+ * declaration, each field exactly once, nothing else in the literal. Throws Unparseable otherwise.
74
+ */
75
+ export function parseDeclaredEditionProtocol(text, label) {
76
+ const declarations = [...text.matchAll(DECLARATION)];
77
+ if (declarations.length !== 1) {throw malformed(label);}
78
+ let rest = declarations[0][1];
79
+ const fields = {};
80
+ for (const [name, pattern] of FIELDS) {
81
+ const all = rest.match(new RegExp(pattern.source, 'g')) ?? [];
82
+ if (all.length !== 1) {throw malformed(label);}
83
+ fields[name] = all[0].match(pattern)[1];
84
+ rest = rest.replace(all[0], '');
85
+ }
86
+ if (rest.replace(/\/\/[^\n]*/g, '').trim() !== '') {throw malformed(label);}
87
+ return Object.freeze({
88
+ contentDigest: fields.contentDigest,
89
+ provisional: fields.provisional === 'true',
90
+ version: Number(fields.version),
91
+ });
92
+ }
93
+
94
+ const describeShort = (identity) =>
95
+ identity === undefined ? 'no declared protocol' : `v${identity.version}${identity.provisional ? '-provisional' : ''}`;
96
+
97
+ /** Git against one checkout; `runGit(args, input?)` returns stdout as a string. */
98
+ function gitIn(repoDir, runGit) {
99
+ return (args, input) =>
100
+ runGit('git', ['-C', repoDir, ...args], {
101
+ encoding: 'utf8',
102
+ maxBuffer: MAX_GIT_OUTPUT,
103
+ stdio: ['pipe', 'pipe', 'pipe'],
104
+ ...(input === undefined ? {} : { input }),
105
+ });
106
+ }
107
+
108
+ function assertCommit(git, sha, label) {
109
+ if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) {
110
+ throw new Error(`${label} is not a 40-hex commit SHA: ${JSON.stringify(sha)}`);
111
+ }
112
+ let resolved;
113
+ try {
114
+ resolved = git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]).trim();
115
+ } catch {
116
+ resolved = '';
117
+ }
118
+ if (resolved !== sha) {throw new Error(`${label} is not a commit in the kernel checkout`);}
119
+ }
120
+
121
+ /** The commit {@link KERNEL_DEVELOP_REF} names; refused when the checkout has no such ref. */
122
+ function readDevelopHead(git) {
123
+ let head;
124
+ try {
125
+ head = git(['rev-parse', '--verify', '--quiet', `${KERNEL_DEVELOP_REF}^{commit}`]).trim();
126
+ } catch {
127
+ head = '';
128
+ }
129
+ if (!SHA_PATTERN.test(head)) {
130
+ throw new Error(`the kernel checkout has no ${KERNEL_DEVELOP_REF}; check it out with full history (fetch-depth: 0)`);
131
+ }
132
+ return head;
133
+ }
134
+
135
+ /** The first-parent history of `head`, newest first. */
136
+ const firstParentHistory = (git, head) =>
137
+ git(['rev-list', '--first-parent', head]).split('\n').filter((line) => line.length > 0);
138
+
139
+ /**
140
+ * The declaring blob of every commit, by one `git cat-file --batch-check`: a Map commit -> blob sha, or
141
+ * undefined when the commit has no such file.
142
+ */
143
+ function declaringBlobs(git, commits) {
144
+ const input = commits.map((commit) => `${commit}:${KERNEL_EDITION_PROTOCOL_SOURCE}\n`).join('');
145
+ const lines = git(['cat-file', '--batch-check'], input).split('\n').filter((line) => line.length > 0);
146
+ if (lines.length !== commits.length) {
147
+ throw new Error(`git cat-file answered ${lines.length} lines for ${commits.length} kernel commits`);
148
+ }
149
+ return new Map(
150
+ commits.map((commit, index) => {
151
+ const [sha, type] = lines[index].split(' ');
152
+ return [commit, type === 'blob' && SHA_PATTERN.test(sha) ? sha : undefined];
153
+ }),
154
+ );
155
+ }
156
+
157
+ /** A reader of the declared identity per blob (memoized); `undefined` for an absent file. Throws Unparseable. */
158
+ function identityReader(git) {
159
+ const byBlob = new Map();
160
+ return (commit, blob) => {
161
+ if (blob === undefined) {return undefined;}
162
+ if (!byBlob.has(blob)) {
163
+ const label = `the kernel commit ${commit}: ${KERNEL_EDITION_PROTOCOL_SOURCE}`;
164
+ byBlob.set(blob, parseDeclaredEditionProtocol(git(['cat-file', 'blob', blob]), label));
165
+ }
166
+ return byBlob.get(blob);
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Refuse a kernel commit whose declared identity this CLI does not support (absent, provisional, another
172
+ * version, or a supported version at another digest), naming `label`. The one check behind an explicit pin
173
+ * and `freeze-graph --kernel-sha`. Returns the identity.
174
+ */
175
+ export function assertKernelCommitSupported({ repoDir, commit, label = `the kernel commit ${commit}`, runGit = execFileSync }) {
176
+ const git = gitIn(repoDir, runGit);
177
+ assertCommit(git, commit, label);
178
+ const developHead = readDevelopHead(git);
179
+ if (!firstParentHistory(git, developHead).includes(commit)) {
180
+ throw new EditionProtocolMismatchError(
181
+ EditionProtocolMismatchReason.NotOnDevelop,
182
+ `${label} is not on the first-parent history of the kernel develop head ${developHead} (${KERNEL_DEVELOP_REF}); ` +
183
+ 'an explicit kernel commit must be one that kernel develop actually passed through.',
184
+ );
185
+ }
186
+ const identity = identityReader(git)(commit, declaringBlobs(git, [commit]).get(commit));
187
+ if (identity === undefined) {
188
+ throw new EditionProtocolMismatchError(
189
+ EditionProtocolMismatchReason.Undeclared,
190
+ `${label} declares no edition protocol (${KERNEL_EDITION_PROTOCOL_SOURCE} is absent); this CLI supports ${supportedList()}.`,
191
+ );
192
+ }
193
+ return assertSupportedEditionIdentity(identity, `${label} (${KERNEL_EDITION_PROTOCOL_SOURCE})`);
194
+ }
195
+
196
+ /**
197
+ * Choose the develop kernel commit in the checkout at `repoDir` (fetched with full history).
198
+ *
199
+ * @param {object} request
200
+ * @param {string} request.repoDir the kernel-contracts checkout
201
+ * @param {string} [request.pin] an explicit 40-hex commit; it wins, and is still checked
202
+ * @returns {{ choice: {chosenSha: string, headSha: string, held: boolean, reason: string}, notice?: string }}
203
+ * `choice` is what is recorded; `notice` (only when held) is the run-summary line, never persisted.
204
+ */
205
+ export function chooseKernelCommit({ repoDir, pin, runGit = execFileSync }) {
206
+ const git = gitIn(repoDir, runGit);
207
+ const headSha = readDevelopHead(git);
208
+ if (pin !== undefined) {
209
+ assertKernelCommitSupported({ repoDir, commit: pin, label: `the kernel pin ${pin}`, runGit });
210
+ return { choice: { chosenSha: pin, headSha, held: false, reason: KernelChoiceReason.ExplicitPin } };
211
+ }
212
+ const commits = firstParentHistory(git, headSha);
213
+ const blobs = declaringBlobs(git, commits);
214
+ const identityAt = identityReader(git);
215
+ // Fail closed: an unparseable declaration (head or walked) is refused by name, never skipped; a commit
216
+ // with no declaration file predates the protocol, so the walk ends there.
217
+ const headIdentity = identityAt(headSha, blobs.get(headSha));
218
+ if (isSupportedEditionIdentity(headIdentity)) {
219
+ return { choice: { chosenSha: headSha, headSha, held: false, reason: KernelChoiceReason.HeadSupported } };
220
+ }
221
+ for (const commit of headIdentity === undefined ? [] : commits.slice(1)) {
222
+ const identity = identityAt(commit, blobs.get(commit));
223
+ if (identity === undefined) {break;}
224
+ if (isSupportedEditionIdentity(identity)) {
225
+ return {
226
+ choice: { chosenSha: commit, headSha, held: true, reason: KernelChoiceReason.HeadAhead },
227
+ notice:
228
+ `kernel held at ${describeShort(identity)} (${commit.slice(0, 12)}), ` +
229
+ `head at ${describeShort(headIdentity)} (${headSha.slice(0, 12)}): raise the CLI`,
230
+ };
231
+ }
232
+ }
233
+ throw new EditionProtocolMismatchError(
234
+ EditionProtocolMismatchReason.NoSupportedCommit,
235
+ `no commit on the first-parent history of the kernel head ${headSha} declares an edition protocol this CLI ` +
236
+ `supports (this CLI supports ${supportedList()}; the head declares ${describeShort(headIdentity)}` +
237
+ `${headIdentity === undefined ? '' : ` at ${headIdentity.contentDigest}`}). Raise the CLI; the head is never taken instead.`,
238
+ );
239
+ }
package/edition/lib.mjs CHANGED
@@ -40,6 +40,7 @@ import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
40
40
  import {
41
41
  PRINCIPAL_PRIVILEGE_FIELDS,
42
42
  assertPrivilegesHavePrincipals,
43
+ componentInstalledOperationRequests,
43
44
  componentOperationRequirements,
44
45
  componentOptionalOperationRequirements,
45
46
  componentOrganizationSweepRequirements,
@@ -686,6 +687,64 @@ const kernelDistributionDist = () => kernelDistPath('distribution', 'index.js');
686
687
  // through. A sibling of the distribution dist, loaded beside it.
687
688
  const kernelProcessTopologyDist = () => kernelDistPath('process-topology', 'index.js');
688
689
 
690
+ /** A composed-process subject, on a kernel that cannot write image-lock schemaVersion 4. */
691
+ export class ComposedProcessNeedsLockV4Error extends Error {
692
+ constructor(message) {
693
+ super(message);
694
+ this.name = 'ComposedProcessNeedsLockV4Error';
695
+ }
696
+ }
697
+
698
+ /** The image-lock revision of a kernel that predates `IMAGE_LOCK_WRITE_SCHEMA_VERSION` (edition protocol < v14). */
699
+ const PRE_V14_IMAGE_LOCK_SCHEMA_VERSION = 3;
700
+
701
+ /**
702
+ * Refuse a composed-process subject when the loaded kernel predates image-lock
703
+ * schemaVersion 4 (it exports no `IMAGE_LOCK_WRITE_SCHEMA_VERSION`): such a
704
+ * kernel has no `members[]`, so it cannot say which services the composed
705
+ * image carries. There is no fallback.
706
+ */
707
+ export function assertKernelSealsComposedMembership(schemas, subjects, label) {
708
+ if (Number.isInteger(schemas.IMAGE_LOCK_WRITE_SCHEMA_VERSION)) {return;}
709
+ const composed = (subjects ?? []).find((subject) => subject.kind === 'composed-process');
710
+ if (composed === undefined) {return;}
711
+ throw new ComposedProcessNeedsLockV4Error(
712
+ `${label}: composed-process subject "${composed.id}" — a composed-process subject needs a kernel that ` +
713
+ 'writes image-lock schemaVersion 4 (edition protocol >= v14); the loaded kernel ' +
714
+ `(${kernelRepoDir()}) exports no IMAGE_LOCK_WRITE_SCHEMA_VERSION`,
715
+ );
716
+ }
717
+
718
+ /**
719
+ * The READ side of the same rule, for a kernel before image-lock
720
+ * schemaVersion 4 (no `ImageLockSchemaVersion`): it reads only schemaVersion
721
+ * 3, which names no composed members, so a v3 lock carrying a composed-process
722
+ * subject cannot say which services run from it and is refused (worded as the
723
+ * kernel's own `LegacyComposedImageLockError`).
724
+ */
725
+ export function assertLegacyLockHasNoComposedSubject(lock) {
726
+ const composed = (lock.subjects ?? []).find((subject) => subject.kind === 'composed-process');
727
+ if (composed === undefined) {return;}
728
+ throw new ComposedProcessNeedsLockV4Error(
729
+ `distribution "${lock.distributionId}": subjects "${composed.id}" is a schemaVersion ` +
730
+ `${String(lock.schemaVersion)} composed-process subject, which names no members: regenerate with a CLI ` +
731
+ 'that writes image-lock schemaVersion 4, against a kernel at edition protocol >= v14',
732
+ );
733
+ }
734
+
735
+ /**
736
+ * The image-lock schemaVersion a writer emits for `subjects`: the loaded
737
+ * kernel's `IMAGE_LOCK_WRITE_SCHEMA_VERSION`. A kernel before edition protocol
738
+ * v14 exports none and writes only schemaVersion 3, which is allowed only when
739
+ * there is NO composed-process subject
740
+ * ({@link ComposedProcessNeedsLockV4Error} otherwise).
741
+ */
742
+ export function imageLockWriteSchemaVersion(schemas, subjects, label = 'images.lock.json') {
743
+ assertKernelSealsComposedMembership(schemas, subjects, label);
744
+ const version = schemas.IMAGE_LOCK_WRITE_SCHEMA_VERSION;
745
+ return Number.isInteger(version) ? version : PRE_V14_IMAGE_LOCK_SCHEMA_VERSION;
746
+ }
747
+
689
748
  /**
690
749
  * Dynamically import the BUILT kernel distribution schemas. The kernel package
691
750
  * is NOT linked into the repo-root node_modules (it lives in its own sub-
@@ -737,6 +796,8 @@ export async function loadKernelDistributionSchemas() {
737
796
  parseOciDigest,
738
797
  parseGitTreeId,
739
798
  parseImageLock,
799
+ IMAGE_LOCK_WRITE_SCHEMA_VERSION,
800
+ ImageLockSchemaVersion,
740
801
  parseImageProducerStatement,
741
802
  parseDistributionImagesStatement,
742
803
  parseImageDeliveryEntitlementStatement,
@@ -789,6 +850,8 @@ export async function loadKernelDistributionSchemas() {
789
850
  admitOperationGrants,
790
851
  admitOrganizationSweeps,
791
852
  contractRequiredServiceTargets,
853
+ admitInstalledOperationCeiling,
854
+ admitStoreWorkloadCeiling,
792
855
  } = mod;
793
856
  let topology;
794
857
  try {
@@ -886,6 +949,11 @@ export async function loadKernelDistributionSchemas() {
886
949
  typeof admitOperationGrants !== 'function' ||
887
950
  typeof admitOrganizationSweeps !== 'function' ||
888
951
  typeof contractRequiredServiceTargets !== 'function' ||
952
+ // The installed-operation ceiling and the edition's store-workload ceiling,
953
+ // both admitted at the lock (`assertInstalledOperationCeilingAdmitted`,
954
+ // `assertStoreWorkloadCeilingAdmitted`).
955
+ typeof admitInstalledOperationCeiling !== 'function' ||
956
+ typeof admitStoreWorkloadCeiling !== 'function' ||
889
957
  typeof distributionImagesPredicateLockRefusal !== 'function' ||
890
958
  typeof distributionLockDigest !== 'function' ||
891
959
  typeof imageDeliveryEntitlementRefusal !== 'function' ||
@@ -907,7 +975,7 @@ export async function loadKernelDistributionSchemas() {
907
975
  `resolveRuntimeImagePlatforms/RuntimeImagePlatformKey/` +
908
976
  `kernel contract-authority digest SSOT/compareLockedServiceResources/resolveNexusBindings/describeNexusBindingRefusal/canonicalNexusFacts/` +
909
977
  `positive closure (resolveBiomeClosure/platformInvariantRoots/DistributionResolutionMode/provenance)/` +
910
- `deriveWorkflowSubjects/deriveTrustCeiling (>= 0.71.0)/admitOperationGrants/admitOrganizationSweeps/` +
978
+ `deriveWorkflowSubjects/deriveTrustCeiling (>= 0.71.0)/admitOperationGrants/admitOrganizationSweeps/admitInstalledOperationCeiling/admitStoreWorkloadCeiling/` +
911
979
  `defaultTrustDomain/FIRST_PARTY_TRUST_DOMAIN/ProcessTrustPolicySchema/DistributionTrustPolicySchema/LockedStorePublisherSchema/` +
912
980
  `resolveFirstPartyCapabilityDomains/DistributionVisibility/` +
913
981
  `BiomeInstallPolicy/BiomeLaunchMode/ImageSourceHashAlgorithm/runtime-image enums — ` +
@@ -933,6 +1001,8 @@ export async function loadKernelDistributionSchemas() {
933
1001
  parseOciDigest,
934
1002
  parseGitTreeId,
935
1003
  parseImageLock,
1004
+ IMAGE_LOCK_WRITE_SCHEMA_VERSION,
1005
+ ImageLockSchemaVersion,
936
1006
  parseImageProducerStatement,
937
1007
  parseDistributionImagesStatement,
938
1008
  parseImageDeliveryEntitlementStatement,
@@ -992,6 +1062,8 @@ export async function loadKernelDistributionSchemas() {
992
1062
  admitOperationGrants,
993
1063
  admitOrganizationSweeps,
994
1064
  contractRequiredServiceTargets,
1065
+ admitInstalledOperationCeiling,
1066
+ admitStoreWorkloadCeiling,
995
1067
  distributionImagesPredicateLockRefusal,
996
1068
  imageDeliveryEntitlementRefusal,
997
1069
  producerWorkflowPinRefusal,
@@ -1623,7 +1695,7 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1623
1695
  'resolve no worker image: a worker declares artifact.kind "oci-image" or "shared-oci-image"',
1624
1696
  );
1625
1697
  }
1626
- // A privilege (`requiresOperations`, `optionalOperations`, `requiresOrganizationSweeps`) is admitted for a lock workload
1698
+ // A privilege (`requiresOperations`, `optionalOperations`, `requestsInstalledOperations`, `requiresOrganizationSweeps`) is admitted for a lock workload
1627
1699
  // principal only; on any other component it is refused, never dropped.
1628
1700
  assertPrivilegesHavePrincipals(components, imageComponents, rel);
1629
1701
  // A biome must declare its images in EXACTLY ONE shape. Declaring both a
@@ -1674,6 +1746,10 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1674
1746
  // (`optionalOperations`), keyed like `operationRequirements`. Declared
1675
1747
  // intent only: the seal grants, leaves ungranted or refuses each one.
1676
1748
  const optionalOperationRequirements = {};
1749
+ // `component → the operations it asks to exercise for the installing
1750
+ // organization` (`requestsInstalledOperations`), keyed like
1751
+ // `operationRequirements`. Declared intent only: the lock admits the ceiling.
1752
+ const installedOperationRequests = {};
1677
1753
  // `component → the organization sweeps it may hold`
1678
1754
  // (`requiresOrganizationSweeps`), keyed like `operationRequirements`.
1679
1755
  // Declared intent only: the seal admits or refuses each one.
@@ -1867,6 +1943,8 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
1867
1943
  if (operations !== null) {operationRequirements[name] = operations;}
1868
1944
  const optionalOperations = componentOptionalOperationRequirements(component, where);
1869
1945
  if (optionalOperations !== null) {optionalOperationRequirements[name] = optionalOperations;}
1946
+ const installed = componentInstalledOperationRequests(component, where);
1947
+ if (installed !== null) {installedOperationRequests[name] = installed;}
1870
1948
  const sweeps = componentOrganizationSweepRequirements(component, where);
1871
1949
  if (sweeps !== null) {organizationSweepRequirements[name] = sweeps;}
1872
1950
  const queues = componentWorkerTaskQueues(component, protocol, where);
@@ -2311,6 +2389,17 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
2311
2389
  ),
2312
2390
  }
2313
2391
  : {}),
2392
+ // The operations each workload component asks to exercise for the
2393
+ // installing organization; omitted when none declares any.
2394
+ ...(Object.keys(installedOperationRequests).length > 0
2395
+ ? {
2396
+ installedOperationRequests: Object.fromEntries(
2397
+ Object.entries(installedOperationRequests).sort(([a], [b]) =>
2398
+ compareStrings(a, b),
2399
+ ),
2400
+ ),
2401
+ }
2402
+ : {}),
2314
2403
  // The organization sweeps each workload component may hold; omitted when
2315
2404
  // none declares any.
2316
2405
  ...(Object.keys(organizationSweepRequirements).length > 0
@@ -2920,8 +3009,16 @@ export function validateImmutableImageLock(lock, schemas) {
2920
3009
  if (!schemas) {
2921
3010
  throw new Error('trusted kernel distribution schemas are required');
2922
3011
  }
3012
+ assertKernelSealsComposedMembership(schemas, lock.subjects, `distribution "${lock.distributionId}"`);
3013
+ // The revisions the loaded kernel reads (`ImageLockSchemaVersion`: 3 without
3014
+ // composed membership, 4 with it); a kernel before image-lock v4 reads only 3.
3015
+ if (schemas.ImageLockSchemaVersion === undefined) {assertLegacyLockHasNoComposedSubject(lock);}
3016
+ const readableVersions =
3017
+ schemas.ImageLockSchemaVersion === undefined
3018
+ ? [PRE_V14_IMAGE_LOCK_SCHEMA_VERSION]
3019
+ : Object.values(schemas.ImageLockSchemaVersion).filter((version) => Number.isInteger(version));
2923
3020
  if (
2924
- lock.schemaVersion !== 3 ||
3021
+ !readableVersions.includes(lock.schemaVersion) ||
2925
3022
  lock.sourceHashAlgorithm !== schemas.ImageSourceHashAlgorithm.ImageInputV2
2926
3023
  ) {
2927
3024
  throw new Error(