@xemahq/xema 0.27.0 → 0.28.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
@@ -55,7 +55,7 @@ Read strictly: every key is required, an unknown key or a duplicate key is refus
55
55
  "chartPublishers": [{ "index": "<chart-set-index-name>", "repo": "<owner>/<repo>" }],
56
56
  "sourceRepositoryUrlPrefix": "https://git.example.com/acme/",
57
57
  "sourceAuthUsername": "x-access-token",
58
- "evidenceTrustPolicy": { "required": true, "policy": "image-evidence-trust-policy.json", "pin": "image-evidence-trust-policy.pin.json" }
58
+ "evidenceTrustPolicy": { "policy": "image-evidence-trust-policy.json", "pin": "image-evidence-trust-policy.pin.json" }
59
59
  }
60
60
  ```
61
61
 
@@ -64,7 +64,7 @@ Read strictly: every key is required, an unknown key or a duplicate key is refus
64
64
  - `chartPublishers`: the chart-set indexes the pin covers; `repo` is recorded in `chart-set.lock.json`.
65
65
  - `sourceRepositoryUrlPrefix`: `https://<host>/<organisation>/`, the prefix every first-party `repositoryUrl` must equal (with `<repo>.git`).
66
66
  - `sourceAuthUsername`: the non-empty git username first-party source tokens authenticate as (GitHub: `x-access-token`; other forges differ). Required, no default.
67
- - `evidenceTrustPolicy` (mandatory): `{required: boolean}`, or `{required, policy, pin}` with `policy` and `pin` two data-root-relative POSIX paths (no `..`, regular no-follow files). `required` has no default. `required: true` with no policy is refused by `xema edition check` (`EvidenceTrustRequiredError`); `required: false` with no policy passes and prints NOT ARMED; a declared policy is enforced whether or not it is required. See below.
67
+ - `evidenceTrustPolicy` (mandatory): `{policy, pin}`, two data-root-relative POSIX paths (no `..`, regular no-follow files), or `null`, the explicit statement that this data root declares no policy. Any other key is refused as unknown (the former `required` switch included): no configuration admits a release edition without an armed policy. A data root that declares `null` serves develop editions only. See below.
68
68
 
69
69
  ## Operating notes
70
70
 
@@ -82,20 +82,18 @@ Read strictly: every key is required, an unknown key or a duplicate key is refus
82
82
  The descriptor's `evidenceTrustPolicy` has exactly two states, and sealing fails closed (also documented in `edition-contract.json` under `evidenceTrust`, which xema-deploy vendors):
83
83
 
84
84
  - `armed`: the data root declares a policy, the policy hashes to its data-root pin, this CLI's verifier is the pinned one, and the policy is ARMED on the channel of the edition.
85
- - `not-armed`: for a release edition, exactly `required: false` and no policy armed on the release channel; for a develop edition, a policy that is not armed on the develop channel, whatever `required` says.
85
+ - `not-armed`: a develop edition only, whose data root declares no policy or one that is not armed on the develop channel. A release edition never records `not-armed`.
86
86
 
87
87
  Arming is judged for ONE channel, the channel of the edition being checked, sealed or written: a channel is ARMED when the policy authorizes a signer for each of the three predicates on that channel and pins the cosign and the git binary. The signers of the other channel count for nothing, so a policy with release signers only is ARMED for a release edition. The develop channel admits not-armed: a develop edition is written when its channel has no signer, and prints one line on stderr (`develop channel: trust policy not armed, evidence is not signature-verified`). A verification of signed develop evidence is satisfied by develop signers only.
88
88
 
89
- `required: true` while the policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there) REFUSES `xema edition check`, sealing a release edition and writing a release artifact, with `EvidenceTrustRequiredError`, code `EVIDENCE_TRUST_REQUIRED_UNARMED`; `not-armed` is never recorded for a release edition of a `required: true` data root. A data root with `required: true` and no declared policy, and a channel that is PARTIALLY ARMED, are refused on either channel. Both `artifact` and `develop resolve` (and `develop check-existing`) hold the policy to its pin and the verifier to the pinned one before recording a state: a policy edited after pinning or a drifted verifier is refused.
89
+ A release edition requires an armed evidence trust policy, with no switch: while the policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there), writing a release descriptor or artifact (`xema edition artifact`) is REFUSED with `EvidenceTrustRequiredError`, code `EVIDENCE_TRUST_REQUIRED_UNARMED`, message "a release edition requires an armed evidence trust policy". The descriptor builder itself refuses a release descriptor that is not `armed`, so no route writes one. A channel that is PARTIALLY ARMED is refused on either channel. Both `artifact` and `develop resolve` (and `develop check-existing`) hold the policy to its pin and the verifier to the pinned one before recording a state: a policy edited after pinning or a drifted verifier is refused.
90
90
 
91
- `xema edition check` runs one evidence-trust gate. With `required: true` and no policy it refuses (`EvidenceTrustRequiredError`). With `required: false` and no policy it prints
92
- `image-evidence trust policy: NOT ARMED` and passes, but refuses a data root that holds an
91
+ `xema edition check` runs one evidence-trust gate, and it FAILS when the editions tree holds a release edition while the policy is not ARMED on the release channel (none declared, UNARMED or PARTIAL): the refusal names the release editions and the repair, and states that no release edition of the data root can be published as an artifact (written by `xema edition artifact`) until the policy is armed on the release channel. A release edition is an edition directory (one holding `xema-distribution.json`) that holds a release lock (`distribution.lock.json` or `authorization.lock.json`); a develop resolve writes nothing into an editions tree. A tree holding no release edition passes for develop-only use with the same statement as a notice. `xema edition seal` is not gated by the policy: nothing sealed leaves without `xema edition artifact`. With no policy declared the gate also refuses a data root that holds an
93
92
  undeclared policy (a top-level `*.json` the kernel parses as an offline trust policy, or a
94
93
  `trust/` directory). With one declared it parses the policy with the kernel schema, checks the
95
94
  Sigstore trusted root the policy itself names (`trustedRootPath`, resolved against the data root,
96
95
  and `trustedRootDigest`), checks the pin file, and classifies the authority configuration of each channel: no
97
- signer on the channel is UNARMED (on the release channel it passes only when `required: false`, and delivery stays
98
- fail-closed; on the develop channel it passes with the not-armed line), mixed is PARTIAL (refused on either channel, with
96
+ signer on the channel is UNARMED (on the release channel it fails when the tree holds a release edition and otherwise passes with the notice; on the develop channel it passes with the not-armed line), mixed is PARTIAL (refused on either channel, with
99
97
  what is unmet), complete is ARMED.
100
98
 
101
99
  The pin file (strict JSON, exact keys) is reviewed data:
@@ -214,7 +212,7 @@ Common to all verbs: root flags `--data-root`, `--editions-root`, `--sources-roo
214
212
  | `xema edition evidence oci backfill-plan` | `--distribution <id>… --producer-repository-root <abs dir>…`, roots `--data-root --editions-root --kernel`, env the registry credential variables the data root declares | prints `{"schemaVersion":1,"kind":"oci-backfill-plan","items":[{"status":"missing-git\|present\|missing","subjectKind","subjectId","imageRepository","subjectDigest","artifactDigest","attach"?}]}`; a `missing` item carries its attach plan; exit 2 unless every item is `present` |
215
213
  | `xema edition index runtime-image-source --repin-contract-authority` | roots `--data-root --sources-root --kernel --source-hash` | replaces ONLY `contractAuthority` of the committed `runtime-image-source-index.json` with the authority trusted from the `--kernel` checkout (clean, at the expected origin, else refused) and validates and rewrites it; every repository pin and subject is identical before and after. It writes the move on stdout and the owed follow-ups on stderr: `xema edition repin-evidence-trust`, re-resolving every edition (each `images.lock.json` carries the authority), and that signed producer statements made under the old authority no longer verify |
216
214
  | `xema edition source-closure` | `--repo <repo> --path <dir> --commit <40 hex>`, roots `--sources-root --source-hash --kernel` (the hash implementation is held to the kernel) | `{"schemaVersion":1,"repo","directory","commit","treeAlgorithm","treeHash","buildable","lockfileBlobId","closure":[{"directory","treeHash"}]}`: the facts a content coordinate is framed from |
217
- | `xema edition verify-evidence <id>` | see Evidence trust; `--descriptor <absolute path of the edition descriptor being verified>` (required: its `distributionId` must be the requested id and its `provenance.channel` is the channel whose signers may vouch for the evidence; it is held outside the graded checkouts like `--state`); plus `[--json]` (stdout is then exactly one `{"schema":"xema.edition-evidence-verification/v1","distributionId","channel","releaseSequence","statements":[{"predicateType","subject","statementDigest"}]}` document, where `statementDigest` is the digest of the verified statement, not of the image; the human text goes to stderr, and a failed verification prints no JSON); verifying a delivery entitlement is not available in this version | the in-repository gate and the release-checkout grading are this verifier; there is no second one. Its result is its exit status, the advanced replay state and, with `--json`, the verification document on stdout: it writes no "already verified" file and has no file-output flag. Verifying a delivery entitlement is not available in this version |
215
+ | `xema edition verify-evidence <id>` | see Evidence trust; `--descriptor <absolute path of the edition descriptor being verified>` (required: its `distributionId` must be the requested id and its `provenance.channel` is the channel whose signers may vouch for the evidence; it is held outside the graded checkouts like `--state`); plus `[--json]` (stdout is then exactly one `{"schema":"xema.edition-evidence-verification/v1","distributionId","channel","releaseSequence","statements":[{"predicateType","subject","statementDigest"}]}` document, where `statementDigest` is the digest of the verified statement, not of the image; the human text goes to stderr, and a failed verification prints no JSON); plus, given together or not at all, `--entitlement <evidence-root-relative path of an entitlement.json> --entitlement-workflow-sha <40-hex git sha> --customer <id> --mode <mode>` and optionally `--target-registry <registry>`: the delivery entitlement `accept` wrote is verified with the release (`--entitlement-workflow-sha` is the exact commit of the workflow run that signed the entitlement, the one its certificate is held to; it is stated by the caller and never read from the evidence; the statement and bundle are read from the directory holding `entitlement.json`), and the verification document then ends with the entitlement statement | the in-repository gate and the release-checkout grading are this verifier; there is no second one. Its result is its exit status, the advanced replay state and, with `--json`, the verification document on stdout: it writes no "already verified" file and has no file-output flag. A delivery entitlement verifies only as part of this run: a refused entitlement refuses the whole verification and the replay state does not advance. `entitlement.json` is `{schemaVersion: 2, distributionId, customerId, mode, targetRegistry, idempotencyKey, distributionStatementDigest, evidence}` with exactly those keys (`evidence` is the Sigstore evidence descriptor); every field but `evidence` is an unauthenticated convenience that must agree with the flags and the verified completeness statement, and the signed statement stays the authority |
218
216
 
219
217
  The signing plan (stdout of every `stage`):
220
218
 
@@ -66,6 +66,13 @@ export function assertEngineImage(image) {
66
66
  */
67
67
  export function editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
68
68
  assertCanonicalDistributionId(distributionId);
69
+ // Every writer of a descriptor passes here: no route writes a release
70
+ // edition that is not armed, whatever state its caller computed.
71
+ if (channel === EditionChannel.Release && evidenceTrustPolicy !== EvidenceTrustState.Armed) {
72
+ throw new Error(
73
+ `a release edition requires an armed evidence trust policy; "${distributionId}" was to be written ${JSON.stringify(evidenceTrustPolicy)}`,
74
+ );
75
+ }
69
76
  return parseEditionDescriptor({
70
77
  schema: EDITION_DESCRIPTOR_SCHEMA,
71
78
  distributionId,
@@ -124,6 +131,13 @@ export function parseEditionDescriptor(value) {
124
131
  if (!Object.values(EvidenceTrustState).includes(value.evidenceTrustPolicy)) {
125
132
  throw new Error(`${EDITION_DESCRIPTOR_FILE}: evidenceTrustPolicy must be one of ${Object.values(EvidenceTrustState).join(' | ')}`);
126
133
  }
134
+ // The reader holds the writer's rule: a release edition that is not armed is
135
+ // not an edition this plane writes, so it is not one it reads.
136
+ if (value.provenance.channel === EditionChannel.Release && value.evidenceTrustPolicy !== EvidenceTrustState.Armed) {
137
+ throw new Error(
138
+ `${EDITION_DESCRIPTOR_FILE}: a release edition requires an armed evidence trust policy; "${value.distributionId}" records ${JSON.stringify(value.evidenceTrustPolicy)}`,
139
+ );
140
+ }
127
141
  exactKeys(value.engine, ['image'], `${EDITION_DESCRIPTOR_FILE} engine`);
128
142
  assertEngineImage(value.engine.image);
129
143
  return value;
@@ -99,7 +99,7 @@ function requirePublishers(config) {
99
99
  );
100
100
  }
101
101
 
102
- const EVIDENCE_TRUST_POLICY_KEYS = Object.freeze(['pin', 'policy', 'required']);
102
+ const EVIDENCE_TRUST_POLICY_KEYS = Object.freeze(['pin', 'policy']);
103
103
 
104
104
  function isDataRootRelativePath(value) {
105
105
  return (
@@ -110,33 +110,31 @@ function isDataRootRelativePath(value) {
110
110
  }
111
111
 
112
112
  /**
113
- * The MANDATORY `evidenceTrustPolicy` declaration. `required` is a boolean with
114
- * no default: whether this data root's editions MUST be evidence-gated. The
115
- * policy is either declared (`policy` and `pin`, two data-root-relative paths)
116
- * or absent (both null); a declared policy is enforced whether or not it is
117
- * required. `required: true` with no policy is a state the shape allows and
118
- * `xema edition check` refuses (EvidenceTrustRequiredError), so the refusal
119
- * names the missing policy rather than a malformed file.
113
+ * The MANDATORY `evidenceTrustPolicy` declaration: `{policy, pin}` (two
114
+ * data-root-relative paths) or `null`, the explicit statement that this data
115
+ * root declares no policy. There is no switch that admits an ungated release:
116
+ * a release edition is written only against a policy ARMED on the release
117
+ * channel, so a data root that declares none serves develop editions only.
120
118
  */
121
119
  function requireEvidenceTrustPolicy(config) {
122
120
  if (!Object.hasOwn(config, 'evidenceTrustPolicy')) {
123
121
  throw new EditionWorkspaceConfigError(
124
- `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is required: {"required": true, "policy": ..., "pin": ...} ` +
125
- 'or {"required": false} (no default: say whether this data root\'s editions must be evidence-gated)',
122
+ `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is required: {"policy": ..., "pin": ...}, ` +
123
+ 'or null to state that this data root declares no policy (no release edition can then be sealed from it)',
126
124
  );
127
125
  }
128
126
  const value = config.evidenceTrustPolicy;
129
- const shapeError = () =>
127
+ if (value === null) {return Object.freeze({ policy: null, pin: null });}
128
+ const shapeError = (detail) =>
130
129
  new EditionWorkspaceConfigError(
131
- `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is exactly {required: boolean} or {required, policy, pin} with both paths data-root-relative`,
130
+ `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy" is exactly null or {policy, pin} with both paths data-root-relative${detail}`,
132
131
  );
133
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {throw shapeError();}
132
+ if (typeof value !== 'object' || Array.isArray(value)) {throw shapeError('');}
134
133
  const keys = Object.keys(value).sort();
135
- if (!keys.every((key) => EVIDENCE_TRUST_POLICY_KEYS.includes(key)) || typeof value.required !== 'boolean') {throw shapeError();}
136
- const declared = keys.length > 1;
137
- if (declared && keys.join() !== EVIDENCE_TRUST_POLICY_KEYS.join()) {throw shapeError();}
138
- if (!declared) {return Object.freeze({ required: value.required, policy: null, pin: null });}
139
- for (const key of ['policy', 'pin']) {
134
+ const unknown = keys.filter((key) => !EVIDENCE_TRUST_POLICY_KEYS.includes(key));
135
+ if (unknown.length > 0) {throw shapeError(`; unknown key ${unknown.map((key) => `"${key}"`).join(', ')}`);}
136
+ if (keys.join() !== EVIDENCE_TRUST_POLICY_KEYS.join()) {throw shapeError('');}
137
+ for (const key of EVIDENCE_TRUST_POLICY_KEYS) {
140
138
  if (!isDataRootRelativePath(value[key])) {
141
139
  throw new EditionWorkspaceConfigError(
142
140
  `${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy.${key}" must be a data-root-relative POSIX path without "." or ".." segments; got ${JSON.stringify(value[key])}`,
@@ -146,7 +144,7 @@ function requireEvidenceTrustPolicy(config) {
146
144
  if (value.policy === value.pin) {
147
145
  throw new EditionWorkspaceConfigError(`${EDITION_WORKSPACE_CONFIG_FILE}: "evidenceTrustPolicy.policy" and ".pin" must be different files`);
148
146
  }
149
- return Object.freeze({ required: value.required, policy: value.policy, pin: value.pin });
147
+ return Object.freeze({ policy: value.policy, pin: value.pin });
150
148
  }
151
149
 
152
150
  /** Validate a parsed config document; returns the frozen, typed config. */
@@ -4,8 +4,15 @@
4
4
  "repositoryPrefix": "xema-edition-",
5
5
  "descriptorFile": "xema-edition.json",
6
6
  "descriptorSchema": "xema.edition/v1",
7
- "locks": ["distribution.lock.json", "images.lock.json", "authorization.lock.json", "chart-set.lock.json"],
8
- "offeringLayers": { "catalogue": "catalogue.json" },
7
+ "locks": [
8
+ "distribution.lock.json",
9
+ "images.lock.json",
10
+ "authorization.lock.json",
11
+ "chart-set.lock.json"
12
+ ],
13
+ "offeringLayers": {
14
+ "catalogue": "catalogue.json"
15
+ },
9
16
  "provenancePins": {
10
17
  "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against; every other pin is a 40-hex commit"
11
18
  },
@@ -13,11 +20,11 @@
13
20
  "evidenceTrust": {
14
21
  "states": {
15
22
  "armed": "the data root declares an evidence trust policy that is pinned and ARMED on the channel of the edition",
16
- "not-armed": "a release edition: exactly, the data root's evidenceTrustPolicy.required is false and it has no policy armed on the release channel; a develop edition: its policy is not armed on the develop channel, whatever required says"
23
+ "not-armed": "a develop edition only: its data root declares no policy, or one that is not armed on the develop channel. A release edition never records not-armed"
17
24
  },
18
25
  "refusal": {
19
26
  "code": "EVIDENCE_TRUST_REQUIRED_UNARMED",
20
- "when": "evidenceTrustPolicy.required is true while the policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there): `xema edition check` fails, no release edition is sealed and no release artifact is written, so a release descriptor never records not-armed for a required data root. Arming is judged per channel: a develop edition admits not-armed"
27
+ "when": "a release edition is written while the data root's evidence trust policy is not armed on the release channel (none declared, or declared but UNARMED or PARTIALLY ARMED there): no release descriptor or artifact is written, and no configuration key admits one. Arming is judged per channel: a develop edition admits not-armed"
21
28
  }
22
29
  }
23
30
  }
@@ -171,6 +171,17 @@ export function isEntryModule(moduleUrl) {
171
171
  * root flags from it, so the entry's own parser sees only its own arguments.
172
172
  */
173
173
  export function configureEditionWorkspaceFromProcess(needs) {
174
- const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs);
174
+ let rest;
175
+ try {
176
+ rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), needs);
177
+ } catch (error) {
178
+ // An entry calls this before it has a `main` to catch anything: a missing
179
+ // or malformed root is the operator's mistake, answered with the roots the
180
+ // command takes, not with a stack trace.
181
+ process.stderr.write(
182
+ `${error.message}\nusage: this command takes ${needs.map((name) => `${WORKSPACE_FLAGS[name]} <dir>`).join(' ')}\n`,
183
+ );
184
+ process.exit(1);
185
+ }
175
186
  process.argv.splice(2, process.argv.length - 2, ...rest);
176
187
  }
@@ -386,6 +386,24 @@ export function gradeTrustedCheckouts({ grades, trustedGit, expected }) {
386
386
  group.requirements.push({ root: canonicalRoot, label, requiredPaths });
387
387
  byRepository.set(location.repositoryRoot, group);
388
388
  }
389
+ // A re-grade covers exactly the roots first graded: a root the first grade
390
+ // never saw has no HEAD to be held to, and is refused rather than re-graded
391
+ // unchecked.
392
+ if (expected !== undefined) {
393
+ const regraded = new Set(
394
+ [...byRepository.values()].flatMap((group) => group.requirements.map(({ root }) => root)),
395
+ );
396
+ for (const root of regraded) {
397
+ if (typeof expected.get(root)?.commit !== 'string') {
398
+ throw new Error(`${root} was not graded first, so its HEAD cannot be held unchanged`);
399
+ }
400
+ }
401
+ for (const root of expected.keys()) {
402
+ if (!regraded.has(root)) {
403
+ throw new Error(`${root} was graded first and is missing from the re-grade`);
404
+ }
405
+ }
406
+ }
389
407
  const result = new Map();
390
408
  for (const [repositoryRoot, group] of byRepository) {
391
409
  assertTrustedCheckoutLocation(group.location, group.labels.join(' + '));
@@ -393,9 +411,12 @@ export function gradeTrustedCheckouts({ grades, trustedGit, expected }) {
393
411
  repositoryRoot,
394
412
  requirements: group.requirements,
395
413
  trustedGit,
396
- expectedCommit: expected?.get(group.requirements[0].root)?.commit,
414
+ expectedCommit: expected?.get(group.requirements[0].root).commit,
397
415
  });
398
416
  for (const { root } of group.requirements) {
417
+ if (expected !== undefined && expected.get(root).commit !== graded.commit) {
418
+ throw new Error(`${repositoryRoot} HEAD changed during verification`);
419
+ }
399
420
  result.set(root, graded);
400
421
  }
401
422
  }
@@ -1,40 +1,36 @@
1
- // The evidence-trust part of `xema edition check`.
1
+ // The evidence-trust part of `xema edition check`, and the state an edition
2
+ // records in its descriptor.
2
3
  //
3
- // A data root states whether its editions MUST be evidence-gated
4
- // (`evidenceTrustPolicy.required` in `edition-workspace.json`, no default) and
5
- // may DECLARE a policy (`policy` + `pin`). This gate says which, in words, and
6
- // refuses every state in which the answer would be a silent one:
4
+ // A data root DECLARES an evidence trust policy (`evidenceTrustPolicy: {policy,
5
+ // pin}` in `edition-workspace.json`) or states that it declares none (`null`).
6
+ // No switch admits an ungated release:
7
7
  //
8
- // * required but no policy -> refused (EvidenceTrustRequiredError, code
9
- // EVIDENCE_TRUST_REQUIRED_UNARMED): a data root that
10
- // insists on gating cannot run ungated. Sealing an
11
- // edition (descriptorEvidenceTrust) refuses the same
12
- // way for a RELEASE edition of a required data
13
- // root whose declared policy is UNARMED or PARTIAL
14
- // on the release channel;
15
- // * not required, no policy and clean -> passes, printing that the editions are NOT
16
- // evidence-gated (an explicit absence, not a skip);
8
+ // * a RELEASE edition is written only against a policy ARMED on the release
9
+ // channel. No policy, UNARMED or PARTIALLY ARMED there -> refused
10
+ // (EvidenceTrustRequiredError, code EVIDENCE_TRUST_REQUIRED_UNARMED);
11
+ // * a DEVELOP edition admits not-armed and says so in one line; a policy
12
+ // PARTIALLY ARMED on the develop channel is refused;
13
+ // * `xema edition check` FAILS when the editions tree holds a release
14
+ // edition (an edition directory holding a release lock) and the policy is
15
+ // not ARMED on the release channel (none declared, UNARMED or PARTIAL):
16
+ // that edition cannot be published as an artifact. A tree holding no
17
+ // release edition passes for develop-only use, with a notice saying so;
17
18
  // * no policy declared but a policy file is present at the top level or under
18
- // `trust/` -> refused: a policy nothing reads is a policy
19
- // someone believes is enforced;
20
- // * declared -> the policy parses with the kernel schema, its
21
- // pinned Sigstore root matches, the data-root pin
22
- // records the policy's digest AND this CLI's built
23
- // verifier (a missing bundle is refused, never
24
- // skipped), and the authority configuration is
25
- // judged PER CHANNEL (trust-policy-arming.mjs): on
26
- // the release channel it is unarmed (passes,
27
- // delivery fail-closed) or armed; the develop
28
- // channel admits not-armed and says so in one
29
- // line; a channel that is partially armed is refused.
19
+ // `trust/` -> refused: a policy nothing reads is a policy someone believes
20
+ // is enforced;
21
+ // * declared -> the policy parses with the kernel schema, its pinned Sigstore
22
+ // root matches, the data-root pin records the policy's digest AND this
23
+ // CLI's built verifier (a missing bundle is refused, never skipped), and
24
+ // the authority configuration is judged PER CHANNEL
25
+ // (trust-policy-arming.mjs); a channel that is partially armed is refused.
30
26
 
31
- import { readFileSync, readdirSync } from 'node:fs';
27
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
32
28
  import { join } from 'node:path';
33
29
 
34
30
  import { locateBuiltVerifier, readBuiltVerifierDigests } from './built-verifier.mjs';
35
31
  import { EDITION_WORKSPACE_CONFIG_FILE, editionWorkspaceConfig } from './edition-config.mjs';
36
32
  import { EDITION_CONTRACT, EditionChannel, EvidenceTrustState } from './edition-constants.mjs';
37
- import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
33
+ import { dataRoot as configuredDataRoot, editionsRoot as configuredEditionsRoot } from './edition-workspace.mjs';
38
34
  import {
39
35
  assertPinnedPolicy,
40
36
  assertPinnedVerifier,
@@ -42,6 +38,7 @@ import {
42
38
  readEvidenceTrustPin,
43
39
  } from './evidence-trust-pin.mjs';
44
40
  import { loadKernelDistributionSchemas } from './lib.mjs';
41
+ import { heldReleaseLocks } from './release-channel-guard.mjs';
45
42
  import {
46
43
  TrustPolicyArmingState,
47
44
  assertArmingChannel,
@@ -51,25 +48,72 @@ import {
51
48
  const TAG = '[dist:check]';
52
49
 
53
50
  /**
54
- * The stable code of the refusal to write a `not-armed` edition for a data root
55
- * that requires evidence gating. Consumers (xema-deploy, workflows) match on the
56
- * code, never on the message; it is also listed in edition-contract.json.
51
+ * The stable code of the refusal to write a RELEASE edition without an armed
52
+ * evidence trust policy. Consumers (xema-deploy, workflows) match on the code,
53
+ * never on the message; it is also listed in edition-contract.json.
57
54
  */
58
55
  export const EVIDENCE_TRUST_REQUIRED_UNARMED = EDITION_CONTRACT.evidenceTrust.refusal.code;
59
56
 
60
57
  /**
61
- * The data root requires evidence gating (`required: true`) but its evidence
62
- * trust is not armed: it declares no policy, or declares one that is UNARMED or
63
- * PARTIALLY ARMED. Sealing an edition or writing an artifact is refused with this
64
- * error; `not-armed` is never recorded for `required: true`.
58
+ * What `xema edition check` says of a data root whose policy is not armed on
59
+ * the release channel. Sealing is not gated (nothing sealed leaves without
60
+ * `xema edition artifact`); publishing is.
61
+ */
62
+ export const NO_RELEASE_ARTIFACT_NOTICE =
63
+ 'no release edition of this data root can be published as an artifact (written by `xema edition artifact`) until the policy is armed on the release channel';
64
+
65
+ /**
66
+ * The release editions of the configured editions tree: the edition directories
67
+ * (those holding `xema-distribution.json`) that hold a release lock. An edition
68
+ * that is only defined, with no lock resolved yet, is not one.
69
+ */
70
+ export function releaseEditionIds() {
71
+ const root = configuredEditionsRoot();
72
+ if (!existsSync(root)) {return [];}
73
+ return readdirSync(root, { withFileTypes: true })
74
+ .filter(
75
+ (entry) =>
76
+ entry.isDirectory() &&
77
+ existsSync(join(root, entry.name, 'xema-distribution.json')) &&
78
+ heldReleaseLocks(join(root, entry.name)).length > 0,
79
+ )
80
+ .map((entry) => entry.name)
81
+ .sort();
82
+ }
83
+
84
+ /**
85
+ * The verdict of the check for a policy that is not armed on the release
86
+ * channel: red when the tree holds a release edition (it names them and the
87
+ * repair), green with the notice when it holds none.
88
+ */
89
+ function notArmedOnReleaseVerdict(what, repair) {
90
+ const releaseEditions = releaseEditionIds();
91
+ if (releaseEditions.length === 0) {
92
+ console.warn(
93
+ `${TAG} image-evidence trust policy: NOT ARMED on the release channel — ${what}. ${configuredEditionsRoot()} holds no release edition, ` +
94
+ `so this passes for develop-only use: ${NO_RELEASE_ARTIFACT_NOTICE}.`,
95
+ );
96
+ return true;
97
+ }
98
+ console.error(
99
+ `${TAG} image-evidence trust policy FAILED: ${what}, and ${configuredEditionsRoot()} holds ${String(releaseEditions.length)} release edition(s) ` +
100
+ `(${releaseEditions.join(', ')}): ${NO_RELEASE_ARTIFACT_NOTICE}. ${repair}`,
101
+ );
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * A release edition requires an armed evidence trust policy and this data root
107
+ * has none: it declares no policy, or declares one that is UNARMED or PARTIALLY
108
+ * ARMED on the release channel. Writing the release descriptor or artifact is
109
+ * refused with this error; a release edition never records `not-armed`.
65
110
  */
66
111
  export class EvidenceTrustRequiredError extends Error {
67
112
  /** @param {string} dataRoot @param {string} reason what is not armed, in words */
68
113
  constructor(dataRoot, reason) {
69
114
  super(
70
- `${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} sets evidenceTrustPolicy.required to true but ${reason}: ` +
71
- 'arm the declared policy (`xema edition check` says what is unmet), declare {"required": true, "policy": ..., "pin": ...}, ' +
72
- 'or set required to false to run this data root NOT ARMED.',
115
+ `a release edition requires an armed evidence trust policy, but ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ${reason}: ` +
116
+ 'declare a policy ({"policy": ..., "pin": ...}) and arm it on the release channel (`xema edition check` says what is unmet).',
73
117
  );
74
118
  this.name = 'EvidenceTrustRequiredError';
75
119
  this.code = EVIDENCE_TRUST_REQUIRED_UNARMED;
@@ -93,23 +137,21 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
93
137
  export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed, evidence is not signature-verified';
94
138
 
95
139
  /**
96
- * The evidence state an edition of `channel` records in its descriptor, two
97
- * states and fail closed. Arming is judged for that ONE channel
98
- * (`assessTrustPolicyArming`): the signers of the other channel count for nothing.
140
+ * The evidence state an edition of `channel` records in its descriptor. Arming
141
+ * is judged for that ONE channel (`assessTrustPolicyArming`): the signers of
142
+ * the other channel count for nothing.
99
143
  *
100
144
  * * `armed` - the data root declares a policy, the policy is pinned (the
101
145
  * policy and this CLI's verifier match the data-root pin) and
102
146
  * ARMED on `channel`;
103
- * * `not-armed` - a release edition: exactly `required: false` and no armed
104
- * policy. A develop edition: its channel is not armed, whatever
105
- * `required` says, and it says so in one stderr line.
147
+ * * `not-armed` - a DEVELOP edition only: its channel is not armed (no
148
+ * policy, or UNARMED there), and it says so in one stderr line.
106
149
  *
107
- * Every other state is refused: a release edition of a `required: true` data
108
- * root that is not ARMED on the release channel (no policy, or UNARMED or
109
- * PARTIAL there) throws EvidenceTrustRequiredError; a data root that requires
110
- * gating and declares no policy at all, a policy edited after pinning, a
111
- * drifted verifier, and a policy PARTIALLY ARMED on `channel` are refused on
112
- * either channel.
150
+ * Every other state is refused. A RELEASE edition that is not ARMED on the
151
+ * release channel (no policy, UNARMED or PARTIAL there) throws
152
+ * EvidenceTrustRequiredError: this function never returns `not-armed` for it.
153
+ * A policy edited after pinning, a drifted verifier, and a policy PARTIALLY
154
+ * ARMED on `channel` are refused on either channel.
113
155
  *
114
156
  * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
115
157
  */
@@ -118,25 +160,24 @@ export async function descriptorEvidenceTrust({ channel, locateVerifier = locate
118
160
  const root = configuredDataRoot();
119
161
  const schemas = await loadKernelDistributionSchemas();
120
162
  assertArmingChannel(schemas, channel);
121
- const notArmed = () => {
122
- if (channel === EditionChannel.Develop) {console.error(DEVELOP_NOT_ARMED_NOTICE);}
163
+ const release = channel === EditionChannel.Release;
164
+ const developNotArmed = () => {
165
+ console.error(DEVELOP_NOT_ARMED_NOTICE);
123
166
  return EvidenceTrustState.NotArmed;
124
167
  };
125
168
  if (declaration.policy === null) {
126
- if (declaration.required) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
127
- return notArmed();
169
+ if (release) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
170
+ return developNotArmed();
128
171
  }
129
172
  const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
130
173
  const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
174
+ if (state === TrustPolicyArmingState.ARMED) {return EvidenceTrustState.Armed;}
131
175
  if (state === TrustPolicyArmingState.PARTIAL) {
132
- if (declaration.required) {throw new EvidenceTrustRequiredError(root, `its declared policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
176
+ if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
133
177
  throw new Error(`the declared evidence trust policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')}); an edition cannot be written against it`);
134
178
  }
135
- if (state === TrustPolicyArmingState.ARMED) {return EvidenceTrustState.Armed;}
136
- if (declaration.required && channel !== EditionChannel.Develop) {
137
- throw new EvidenceTrustRequiredError(root, `its declared policy is UNARMED on the ${channel} channel`);
138
- }
139
- return notArmed();
179
+ if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is UNARMED on the ${channel} channel`);}
180
+ return developNotArmed();
140
181
  }
141
182
 
142
183
  export const UNDECLARED_TRUST_DIRECTORY = 'trust';
@@ -184,21 +225,19 @@ export async function checkEvidenceTrustPolicy({
184
225
  const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
185
226
  const schemas = await loadKernelDistributionSchemas();
186
227
  if (declaration.policy === null) {
187
- if (declaration.required) {throw new EvidenceTrustRequiredError(dataRoot, 'declares no policy');}
188
228
  const undeclared = undeclaredPolicyArtifacts(dataRoot, schemas);
189
229
  if (undeclared.length > 0) {
190
230
  console.error(
191
231
  `${TAG} image-evidence trust policy FAILED: ${dataRoot} holds ${undeclared.join(', ')} ` +
192
- `but ${EDITION_WORKSPACE_CONFIG_FILE} declares evidenceTrustPolicy {"required": false} with no policy, so nothing reads it. ` +
193
- 'Declare it ({"required": ..., "policy": ..., "pin": ...}) or remove it.',
232
+ `but ${EDITION_WORKSPACE_CONFIG_FILE} declares no evidenceTrustPolicy, so nothing reads it. ` +
233
+ 'Declare it ({"policy": ..., "pin": ...}) or remove it.',
194
234
  );
195
235
  return false;
196
236
  }
197
- console.log(
198
- `${TAG} image-evidence trust policy: NOT ARMED — ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ` +
199
- 'declares evidenceTrustPolicy {"required": false} with no policy, so this data root\'s editions are not evidence-gated.',
237
+ return notArmedOnReleaseVerdict(
238
+ `${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy (its develop editions are not evidence-gated)`,
239
+ 'Declare a policy ({"policy": ..., "pin": ...}), authorize the release signers in it, pin it with `xema edition repin-evidence-trust`, and re-run `xema edition check` until it reports ARMED on the release channel.',
200
240
  );
201
- return true;
202
241
  }
203
242
 
204
243
  const { policy, policyDigest } = loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier });
@@ -213,8 +252,9 @@ export async function checkEvidenceTrustPolicy({
213
252
  `${policy.trustedCosignBinaryDigests.length} cosign / ` +
214
253
  `${policy.trustedGitBinaryDigests.length} git binary pins.`,
215
254
  );
216
- // Each channel is judged on its own signers. The release channel is the one
217
- // `required` speaks for; the develop channel admits not-armed and says so.
255
+ // Each channel is judged on its own signers. The develop channel admits
256
+ // not-armed and says so; a release channel that is not armed fails the
257
+ // check as soon as the tree holds a release edition.
218
258
  const partial = (channel, unmet) => {
219
259
  console.error(
220
260
  `${TAG} image-evidence trust policy is PARTIALLY ARMED on the ${channel} channel — this mixed state is invalid ` +
@@ -232,20 +272,18 @@ export async function checkEvidenceTrustPolicy({
232
272
  const release = assessTrustPolicyArming(policy, schemas, EditionChannel.Release);
233
273
  if (release.state === TrustPolicyArmingState.PARTIAL) {
234
274
  partial(EditionChannel.Release, release.unmet);
235
- return false;
236
- }
237
- if (release.state === TrustPolicyArmingState.UNARMED && declaration.required) {
238
- const refusal = new EvidenceTrustRequiredError(dataRoot, 'its declared policy is UNARMED on the release channel');
239
- console.error(`${TAG} ${refusal.code}: ${refusal.message}`);
275
+ console.error(
276
+ `${TAG} ${NO_RELEASE_ARTIFACT_NOTICE}. Complete the release channel in ${declaration.policy} (every line above), ` +
277
+ 're-pin with `xema edition repin-evidence-trust`, and re-run `xema edition check`.',
278
+ );
240
279
  return false;
241
280
  }
242
281
  if (develop.state === TrustPolicyArmingState.UNARMED) {console.error(`${TAG} ${DEVELOP_NOT_ARMED_NOTICE}`);}
243
282
  if (release.state === TrustPolicyArmingState.UNARMED) {
244
- console.warn(
245
- `${TAG} image-evidence trust policy: declared but UNARMED on the release channel — delivery stays fail-closed ` +
246
- 'until `xema edition check` reports it armed.',
283
+ return notArmedOnReleaseVerdict(
284
+ `${declaration.policy} is declared but UNARMED on the release channel (it authorizes no release signer)`,
285
+ `Authorize the release signers and pin the cosign and git binaries in ${declaration.policy}, re-pin with \`xema edition repin-evidence-trust\`, and re-run \`xema edition check\` until it reports ARMED on the release channel.`,
247
286
  );
248
- return true;
249
287
  }
250
288
  console.log(`${TAG} image-evidence trust policy: ARMED on the release channel${develop.armed ? ' and on the develop channel' : ''}.`);
251
289
  return true;