@kungfu-tech/buildchain 2.12.9-alpha.1 → 2.13.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/actions/promote-buildchain-ref/README.md +13 -0
  2. package/actions/run-lifecycle/README.md +5 -0
  3. package/bin/buildchain.mjs +62 -0
  4. package/dist/site/buildchain-contract.json +50 -25
  5. package/dist/site/buildchain-site.json +82 -41
  6. package/dist/site/capability-registry.json +3 -3
  7. package/dist/site/cli-registry.json +24 -0
  8. package/dist/site/controller-registry.json +24 -4
  9. package/dist/site/kfd-claims.json +78 -16
  10. package/dist/site/kfd-upstream-aggregate.json +1 -1
  11. package/dist/site/manual-registry.json +10 -10
  12. package/dist/site/node-api-registry.json +20 -7
  13. package/dist/site/page-registry.json +68 -27
  14. package/dist/site/public-surface-audit.json +65 -15
  15. package/dist/site/publication-registry.json +4 -4
  16. package/dist/site/release-provenance.json +1 -0
  17. package/dist/site/site-manifest.json +14 -14
  18. package/dist/site/workflow-registry.json +25 -9
  19. package/docs/MAP.md +1 -0
  20. package/docs/artifact-verification-envelope.md +113 -0
  21. package/docs/cli.md +22 -0
  22. package/docs/install.md +1 -3
  23. package/docs/publication-authority.md +26 -0
  24. package/docs/publish-transaction.md +1 -1
  25. package/docs/release-candidate.md +24 -0
  26. package/docs/release-governance.md +12 -6
  27. package/docs/release-propagation.md +3 -0
  28. package/docs/reusable-build-surface.md +13 -0
  29. package/docs/versioning.md +1 -0
  30. package/package.json +2 -1
  31. package/packages/core/README.md +13 -0
  32. package/packages/core/artifact-passport.js +6 -1
  33. package/packages/core/artifact-verification-envelope.js +386 -0
  34. package/packages/core/buildchain-config.js +6 -2
  35. package/packages/core/controller-evidence.js +2 -3
  36. package/packages/core/index.js +16 -0
  37. package/packages/core/publication-authority.js +281 -12
  38. package/packages/core/release-line-bootstrap.js +24 -0
  39. package/scripts/assemble-publication-artifact-admission.mjs +10 -0
  40. package/scripts/assemble-self-publication-admission.mjs +10 -1
  41. package/scripts/check-inventory.mjs +5 -0
  42. package/scripts/generate-site-bundle.mjs +4 -0
  43. package/scripts/release-candidate-resolver.mjs +52 -29
  44. package/scripts/run-lifecycle-core.mjs +50 -6
@@ -136,6 +136,32 @@ publication capability.
136
136
  Evidence publication is a separate authority class and never grants product
137
137
  publication.
138
138
 
139
+ ## Consumer qualification handoff
140
+
141
+ Consumers may explicitly opt in to a final, consumer-owned qualification
142
+ predicate. Buildchain then transports the complete Gate aggregate alongside the
143
+ sealed capability instead of reducing it to a summary. The capability binds the
144
+ authority registry, consumer Gate registry and policy, exact source and runtime,
145
+ controller, runner, control-plane, artifact, provider target, channel, version,
146
+ freshness window, nonce, and exact predicate command digest.
147
+
148
+ The predicate runs in a separate job with read-only source access, no inherited
149
+ secrets, no OIDC permission, and no provider write permission. It receives only
150
+ paths to `capability.json` and `gate-aggregate.json` plus the exact predicate id
151
+ and digest. The consumer writes a
152
+ `kungfu-buildchain-consumer-publication-decision`; Buildchain seals that result
153
+ as a deterministic
154
+ `kungfu-buildchain-publication-qualification-receipt`. Buildchain does not parse
155
+ product-specific Gate meanings.
156
+
157
+ The provider action revalidates the capability, full aggregate, receipt,
158
+ predicate identity, freshness, nonce, source, version, channel, target, and all
159
+ sealed digests immediately before the publish transaction. Missing, denied,
160
+ stale, replayed, substituted, or drifted receipts fail before provider mutation.
161
+ Direct calls to the provider action cannot bypass the receipt when
162
+ `require-publication-qualification` is enabled. Consumers that do not opt in
163
+ retain the existing publication contract.
164
+
139
165
  ## API and CLI
140
166
 
141
167
  Use `@kungfu-tech/buildchain/publication-authority` or run:
@@ -210,7 +210,7 @@ Buildchain distinguishes two npm release modes:
210
210
 
211
211
  | Mode | Use | Auth | npm operation |
212
212
  | --- | --- | --- | --- |
213
- | `publish-final-version` | normal alpha or stable publication | `trusted-publishing` | `npm publish --tag <alpha|vX.Y-alpha|latest>` |
213
+ | `publish-final-version` | normal alpha or stable publication | `trusted-publishing` | `npm publish --tag <alpha|latest>` |
214
214
  | `promote-existing-version` | same-version alpha-to-latest recovery | `npm-token` | `npm dist-tag add <pkg>@<version> latest` |
215
215
 
216
216
  The normal libnode path is `publish-final-version`: publish an alpha package set
@@ -82,6 +82,11 @@ other package as `role: platform`. Consumer workflows therefore stay
82
82
  declarative and do not need their own artifact download or publish-evidence
83
83
  generation scripts.
84
84
 
85
+ Because a channel merge can trigger promotion before its PR-stage matrix has
86
+ finished uploading evidence, the resolver waits up to ten minutes for the exact
87
+ merged PR's successful workflow run and paired artifacts. Polling remains bound
88
+ to the PR/head identity; timeout or a sibling run still fails closed.
89
+
85
90
  By default, the wrapper forwards GitHub Release publication to the underlying
86
91
  `promote-buildchain-ref` semver model. Once the release transaction is complete,
87
92
  the action creates or updates the public GitHub Release, applies
@@ -135,3 +140,22 @@ consumer with a Shifu Gate registry supplies `publication-gate-aggregate-json`
135
140
  instead. Buildchain still requires caller-owned RC evidence, an exact authority
136
141
  runtime and source SHA, a repository-local publisher workflow, matching npm
137
142
  target/package identity, and a qualifying control-plane audit.
143
+
144
+ A consumer that owns additional product qualification semantics can opt in to
145
+ the sealed handoff without teaching Buildchain those semantics:
146
+
147
+ ```yaml
148
+ publication-consumer-predicate-id: kungfu.release-admission/v1
149
+ publication-consumer-qualification-command: node scripts/qualify-release.mjs
150
+ ```
151
+
152
+ The command reads `BUILDCHAIN_PUBLICATION_CAPABILITY_PATH` and
153
+ `BUILDCHAIN_PUBLICATION_GATE_AGGREGATE_PATH`, evaluates the complete aggregate,
154
+ and writes a decision JSON document to
155
+ `BUILDCHAIN_PUBLICATION_QUALIFICATION_RESULT_PATH`. It also receives
156
+ `BUILDCHAIN_PUBLICATION_PREDICATE_ID` and
157
+ `BUILDCHAIN_PUBLICATION_PREDICATE_DIGEST`. The separate qualification job has
158
+ no write or OIDC permission and does not inherit publication secrets. A
159
+ successful deterministic receipt is rechecked by the provider action
160
+ immediately before mutation. Omitting both inputs preserves the existing
161
+ consumer contract; supplying only one fails closed.
@@ -94,6 +94,13 @@ The implementation is intentionally stricter than a local release script:
94
94
  release source tree against that tested alpha tree;
95
95
  - generated version-state commits are verified before refs move.
96
96
 
97
+ Exact publication planning installs the checked-out promotion source's declared
98
+ dependencies before version-state verification. This keeps the pre-authority
99
+ version plan on the same package-manager boundary as the later promotion job,
100
+ including repositories whose verification commands import production packages.
101
+ The planning pass may materialize and verify declared derived files locally,
102
+ but dry-run never creates Git blobs, trees, commits, refs, or tags.
103
+
97
104
  Promotion intents are serialized globally per caller repository with
98
105
  `cancel-in-progress: false`. A queued intent re-reads its protected target ref
99
106
  before checkout, dependency installation, release-candidate resolution, or any
@@ -162,11 +169,6 @@ Buildchain then:
162
169
  7. Moves `vX.Y-alpha` to the same generated alpha commit.
163
170
  8. Moves `vX-alpha` only when `X.Y` is the highest minor in major `X` with a published alpha; older minor alpha work records a skip and cannot move the major channel backwards.
164
171
 
165
- The npm channel follows the same ownership rule. The highest alpha minor publishes
166
- with dist-tag `alpha`; maintenance alphas on an older minor publish with the
167
- line-specific dist-tag `vX.Y-alpha` so they cannot roll the global `alpha`
168
- channel backward. Exact prerelease versions remain installable directly.
169
-
170
172
  This keeps the test channel self-describing. If a consumer checks out
171
173
  `v2.0-alpha` or `v2-alpha`, the manifests and exact alpha tag agree. The major
172
174
  alpha ref removes routine consumer edits when Buildchain opens a newer minor,
@@ -588,7 +590,11 @@ Buildchain's own promotion workflow reads `BUILDCHAIN_PROMOTION_BYPASS_APPS`,
588
590
  `BUILDCHAIN_PROMOTION_BYPASS_TEAMS` repository variables so the declared bypass
589
591
  identity can match the actual `BUILDCHAIN_PROMOTION_TOKEN` actor, but consumers
590
592
  do not need to duplicate that actor manually when the token identity is
591
- discoverable.
593
+ discoverable. Buildchain's release-line bootstrap applies the same declared
594
+ allowances when it creates new `dev`, `alpha`, and `release` branch protection,
595
+ and fails closed before changing protection when no promotion authority is
596
+ declared. This keeps a new minor line publishable without weakening its normal
597
+ human review gate.
592
598
 
593
599
  ## What This Guarantees
594
600
 
@@ -194,6 +194,9 @@ downstream repository, writes the exact lock, and opens or updates a PR. It does
194
194
  not publish the downstream release directly. The downstream repository keeps its
195
195
  normal Buildchain governance: the PR updates source-of-truth facts, then
196
196
  downstream alpha or release publication runs through its own protected channel.
197
+ The workflow stages the declared lock path before checking the index, so the
198
+ first propagation creates a PR even when the lock did not previously exist; a
199
+ byte-identical rerun is an explicit successful no-op.
197
200
  For unreleased runtime validation, keep the caller's reusable workflow reference
198
201
  on `@v2` and pass a temporary train ref through `buildchain-ref`.
199
202
 
@@ -903,6 +903,18 @@ with:
903
903
  verify-command: ctest --test-dir build --output-on-failure
904
904
  ```
905
905
 
906
+ Every native and container matrix job is bounded by
907
+ `lifecycle-timeout-minutes`, which defaults to 120 minutes. The same input is
908
+ the fallback deadline for each install, build, and verify action, so a hung
909
+ command fails with the lifecycle name and matrix platform before it can occupy
910
+ a self-hosted runner indefinitely. A stage-level `timeout_minutes` in
911
+ `buildchain.toml` remains the more specific override for that stage.
912
+
913
+ ```yaml
914
+ with:
915
+ lifecycle-timeout-minutes: 90
916
+ ```
917
+
906
918
  The reusable build workflow samples the build lifecycle by default and carries
907
919
  the generated summary into the final verify diagnostics. Callers can override
908
920
  the sidecar path or disable sampling:
@@ -932,6 +944,7 @@ For custom workflows, use the action directly:
932
944
  with:
933
945
  stage: build
934
946
  required: "true"
947
+ timeout-minutes: "90"
935
948
  artifact-name: libnode-linux-x64-${{ github.sha }}
936
949
  artifact-paths: |
937
950
  dist
@@ -61,6 +61,7 @@ repository changes are patch".
61
61
 
62
62
  | Date | Action | Line | Faces | Class | Rationale | PR |
63
63
  | --- | --- | --- | --- | --- | --- | --- |
64
+ | 2026-07-15 | open-minor | `v2.13` | artifact-verification-envelope, package-subpaths | additive | The public envelope seals exact artifact, provenance, identity, lifecycle, revocation, and existing KFD assessment roots into one consumer-ready KFX admission input, with a dedicated Node API export and fail-closed verifier. | |
64
65
  | 2026-07-11 | open-minor | `v2.12` | channel-build-router, channel-selection-protocol | additive | The public `build.yml` reusable workflow lets consumers declare one build job while Buildchain selects generic major alpha for development/prerelease intent and stable major for production release intent, with explicit overrides, separate locks, and fail-closed ambiguity handling. | |
65
66
  | 2026-07-08 | open-minor | `v2.9` | build-facts-contract | additive | Build Facts add a public CLI command family, Node API export, config protocol, module/product fact contracts, release-passport evidence section, and Kungfu legacy buildinfo projection from the same source facts. | |
66
67
  | 2026-07-06 | open-minor | `v2.8` | kfd-1-contract-world-release-gate, kfd-2-release-trust-passport-audit, kfd-3-collaboration-interface-trust-proof, publish-source-lock-enforcement, required-check-protection | additive | KFD release gates add KFD-1 self contract verification, KFD-2 public release trust claim audit, KFD-3 collaboration-interface trust proofs, publish-side source-lock enforcement for promote-only wrappers, and protected channel required checks repaired to bind GitHub Actions check runs instead of legacy commit status contexts. | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "2.12.9-alpha.1",
3
+ "version": "2.13.0-alpha.1",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -16,6 +16,7 @@
16
16
  "./build-facts": "./packages/core/build-facts.js",
17
17
  "./core": "./packages/core/index.js",
18
18
  "./artifact-passport": "./packages/core/artifact-passport.js",
19
+ "./artifact-verification-envelope": "./packages/core/artifact-verification-envelope.js",
19
20
  "./buildchain-contract": "./packages/core/buildchain-contract.js",
20
21
  "./controller-evidence": "./packages/core/controller-evidence.js",
21
22
  "./diagnostics": "./packages/core/diagnostics.js",
@@ -16,6 +16,8 @@ Current shared surfaces:
16
16
  `@kungfu-tech/buildchain/build-facts`;
17
17
  - release passport creation and verification through
18
18
  `@kungfu-tech/buildchain/release-passport`.
19
+ - sealed exact-root and KFD assessment inputs for KFX admission through
20
+ `@kungfu-tech/buildchain/artifact-verification-envelope`.
19
21
  - managed KFD / Release Passport badge bundle facts and README marker blocks
20
22
  through `@kungfu-tech/buildchain/badges`.
21
23
  - publication artifact manifests, source bundles, and publication artifact
@@ -66,6 +68,17 @@ const fact = collectModuleBuildFacts({ moduleId: "native-core" });
66
68
  writeBuildFacts({ fact, output: ".buildchain/facts/native-core.json" });
67
69
  ```
68
70
 
71
+ KFX producers can seal one passing artifact verification with exact roots,
72
+ identity, lifecycle, revocation, and an existing ADR-0052 assessment. Consumers
73
+ verify or project the same envelope instead of reconstructing bindings:
74
+
75
+ ```js
76
+ import {
77
+ projectArtifactVerificationEnvelopeToKfx,
78
+ verifyArtifactVerificationEnvelope,
79
+ } from "@kungfu-tech/buildchain/artifact-verification-envelope";
80
+ ```
81
+
69
82
  Publication repositories can produce site-consumable paper/report facts without
70
83
  becoming web-surface repositories. When `[publication.archive]` is configured,
71
84
  the same API also maintains the append-only publication registry used by site
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import http from "node:http";
4
4
  import https from "node:https";
5
5
  import path from "node:path";
6
+ import { sealArtifactVerificationReport } from "./artifact-verification-envelope.js";
6
7
  import { readJsonFromLocation, sha256File, verifyReleasePassport } from "./release-passport.js";
7
8
 
8
9
  export const ARTIFACT_VERIFICATION_CONTRACT = "kungfu-buildchain-artifact-verification";
@@ -708,6 +709,7 @@ export async function verifyArtifactPassport({
708
709
  subjectDigest = "",
709
710
  subjectKind = "",
710
711
  npmRegistryBaseUrl = "",
712
+ verificationEnvelope = undefined,
711
713
  } = {}) {
712
714
  const resolvedSubject = await resolveArtifactSubject(subject, { cwd, subjectDigest, subjectKind, npmRegistryBaseUrl });
713
715
  const issues = [];
@@ -789,7 +791,7 @@ export async function verifyArtifactPassport({
789
791
  }
790
792
  const ok = passportReport.ok && Boolean(match);
791
793
  const outcome = ok ? "pass" : "fail";
792
- return {
794
+ const report = {
793
795
  schemaVersion: 1,
794
796
  contract: ARTIFACT_VERIFICATION_CONTRACT,
795
797
  outcome,
@@ -817,6 +819,9 @@ export async function verifyArtifactPassport({
817
819
  : undefined,
818
820
  issues,
819
821
  };
822
+ return verificationEnvelope
823
+ ? sealArtifactVerificationReport({ report, ...verificationEnvelope })
824
+ : report;
820
825
  }
821
826
 
822
827
  export async function explainArtifactPassport(options = {}) {
@@ -0,0 +1,386 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export const ARTIFACT_VERIFICATION_ENVELOPE_CONTRACT =
4
+ "kungfu-buildchain-artifact-verification-envelope";
5
+ export const ARTIFACT_VERIFICATION_ENVELOPE_CHECK_CONTRACT =
6
+ "kungfu-buildchain-artifact-verification-envelope-check";
7
+ export const KFX_ADMISSION_INPUTS_CONTRACT =
8
+ "kungfu-buildchain-kfx-admission-inputs";
9
+
10
+ const ARTIFACT_VERIFICATION_CONTRACT =
11
+ "kungfu-buildchain-artifact-verification";
12
+ const KFX_TRUST_INPUTS_SCHEMA = "kungfu.kfx-trust-inputs/v1";
13
+ const KFD_ASSESSMENT_SCHEMA = "kungfu.trust.assessment/v1";
14
+ const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/;
15
+ const ROOT_FIELDS = Object.freeze([
16
+ "packageRoot",
17
+ "sourceRoot",
18
+ "dependencyRoot",
19
+ "buildPlanRoot",
20
+ "toolchainRoot",
21
+ "artifactRoot",
22
+ "qualificationRoot",
23
+ "verifierRoot",
24
+ ]);
25
+ const BINDING_FIELDS = Object.freeze([
26
+ "schema",
27
+ ...ROOT_FIELDS,
28
+ "issuer",
29
+ "publisher",
30
+ "contractVersion",
31
+ ]);
32
+
33
+ function stableJson(value) {
34
+ if (Array.isArray(value)) {
35
+ return `[${value.map(stableJson).join(",")}]`;
36
+ }
37
+ if (value && typeof value === "object") {
38
+ return `{${Object.keys(value)
39
+ .sort()
40
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
41
+ .join(",")}}`;
42
+ }
43
+ return JSON.stringify(value);
44
+ }
45
+
46
+ function jsonClone(value) {
47
+ return JSON.parse(JSON.stringify(value));
48
+ }
49
+
50
+ export function artifactVerificationEnvelopeDigest(value) {
51
+ return `sha256:${crypto.createHash("sha256").update(stableJson(value)).digest("hex")}`;
52
+ }
53
+
54
+ function issue(code, message, details = {}) {
55
+ return { level: "error", code, message, details };
56
+ }
57
+
58
+ function isObject(value) {
59
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
60
+ }
61
+
62
+ function isRoot(value) {
63
+ return SHA256_PATTERN.test(String(value || ""));
64
+ }
65
+
66
+ function nonEmptyString(value, label) {
67
+ const normalized = String(value || "").trim();
68
+ if (!normalized) {
69
+ throw new Error(`${label} must be a non-empty string`);
70
+ }
71
+ return normalized;
72
+ }
73
+
74
+ function nonNegativeInteger(value, label) {
75
+ if (!Number.isSafeInteger(value) || value < 0) {
76
+ throw new Error(`${label} must be a non-negative safe integer`);
77
+ }
78
+ return value;
79
+ }
80
+
81
+ function normalizeBindings(value = {}) {
82
+ if (!isObject(value)) {
83
+ throw new Error("bindings must be an object");
84
+ }
85
+ const supplied = Object.keys(value).sort();
86
+ const expected = [...BINDING_FIELDS].sort();
87
+ if (stableJson(supplied) !== stableJson(expected)) {
88
+ throw new Error(`bindings must contain exactly: ${BINDING_FIELDS.join(", ")}`);
89
+ }
90
+ if (value.schema !== KFX_TRUST_INPUTS_SCHEMA) {
91
+ throw new Error(`bindings.schema must be ${KFX_TRUST_INPUTS_SCHEMA}`);
92
+ }
93
+ const normalized = { schema: KFX_TRUST_INPUTS_SCHEMA };
94
+ for (const field of ROOT_FIELDS) {
95
+ const root = String(value[field] || "");
96
+ if (!isRoot(root)) {
97
+ throw new Error(`bindings.${field} must be a canonical lowercase sha256 root`);
98
+ }
99
+ normalized[field] = root;
100
+ }
101
+ normalized.issuer = nonEmptyString(value.issuer, "bindings.issuer");
102
+ normalized.publisher = nonEmptyString(value.publisher, "bindings.publisher");
103
+ normalized.contractVersion = nonEmptyString(value.contractVersion, "bindings.contractVersion");
104
+ return normalized;
105
+ }
106
+
107
+ function normalizeRevocation(value = {}, { issuedAt } = {}) {
108
+ if (!isObject(value)) {
109
+ throw new Error("revocation must be an object");
110
+ }
111
+ const revoked = value.revoked === true;
112
+ const status = nonEmptyString(value.status || (revoked ? "revoked" : "active"), "revocation.status");
113
+ if (!new Set(["active", "revoked"]).has(status) || revoked !== (status === "revoked")) {
114
+ throw new Error("revocation.status must agree with revocation.revoked");
115
+ }
116
+ const checkedAt = nonNegativeInteger(value.checkedAt ?? issuedAt, "revocation.checkedAt");
117
+ const root = String(value.root || "");
118
+ if (!isRoot(root)) {
119
+ throw new Error("revocation.root must be a canonical lowercase sha256 root");
120
+ }
121
+ return {
122
+ status,
123
+ revoked,
124
+ checkedAt,
125
+ source: nonEmptyString(value.source, "revocation.source"),
126
+ root,
127
+ };
128
+ }
129
+
130
+ function envelopeRoot(value) {
131
+ const basis = jsonClone(value);
132
+ if (isObject(basis.envelope)) {
133
+ delete basis.envelope.root;
134
+ }
135
+ return artifactVerificationEnvelopeDigest(basis);
136
+ }
137
+
138
+ function recomputeKfdReportRoot(report) {
139
+ const basis = jsonClone(report);
140
+ delete basis.report_hash;
141
+ return artifactVerificationEnvelopeDigest(basis);
142
+ }
143
+
144
+ export function verifyArtifactVerificationEnvelope({
145
+ envelope,
146
+ assessmentTime = Math.floor(Date.now() / 1000),
147
+ expectedEnvelopeRoot = "",
148
+ expectedIssuer = "",
149
+ expectedPublisher = "",
150
+ expectedContractVersion = "",
151
+ } = {}) {
152
+ const issues = [];
153
+ const at = Number.isSafeInteger(assessmentTime) && assessmentTime >= 0
154
+ ? assessmentTime
155
+ : -1;
156
+ if (!isObject(envelope)) {
157
+ issues.push(issue("envelope.object", "artifact verification envelope must be an object"));
158
+ return {
159
+ schemaVersion: 1,
160
+ contract: ARTIFACT_VERIFICATION_ENVELOPE_CHECK_CONTRACT,
161
+ ok: false,
162
+ outcome: "fail",
163
+ assessmentTime,
164
+ issues,
165
+ };
166
+ }
167
+
168
+ const metadata = isObject(envelope.envelope) ? envelope.envelope : {};
169
+ if (
170
+ metadata.contract !== ARTIFACT_VERIFICATION_ENVELOPE_CONTRACT ||
171
+ metadata.schemaVersion !== 1 ||
172
+ metadata.canonicalization !== "buildchain-stable-json/v1"
173
+ ) {
174
+ issues.push(issue("envelope.contract", "artifact verification envelope contract metadata is invalid"));
175
+ }
176
+ const computedEnvelopeRoot = envelopeRoot(envelope);
177
+ if (!isRoot(metadata.root) || metadata.root !== computedEnvelopeRoot) {
178
+ issues.push(issue("envelope.root", "artifact verification envelope root does not match canonical content", {
179
+ expected: computedEnvelopeRoot,
180
+ actual: metadata.root || "",
181
+ }));
182
+ }
183
+ if (expectedEnvelopeRoot && metadata.root !== expectedEnvelopeRoot) {
184
+ issues.push(issue("envelope.root.expected", "artifact verification envelope root does not match the pinned root"));
185
+ }
186
+
187
+ const verificationOk =
188
+ envelope.contract === ARTIFACT_VERIFICATION_CONTRACT &&
189
+ envelope.schemaVersion === 1 &&
190
+ envelope.outcome === "pass" &&
191
+ envelope.ok === true &&
192
+ envelope.trust === "pass" &&
193
+ envelope.passport?.verification?.ok === true &&
194
+ envelope.passport?.verification?.trust === "pass";
195
+ if (!verificationOk) {
196
+ issues.push(issue("verification.invalid", "base artifact verification must be a passing v1 report"));
197
+ }
198
+
199
+ let bindings;
200
+ try {
201
+ bindings = normalizeBindings(envelope.bindings);
202
+ } catch (error) {
203
+ issues.push(issue("bindings.invalid", error.message));
204
+ }
205
+ if (bindings) {
206
+ const subjectRoot = String(envelope.subject?.digest || "");
207
+ const matchedRoot = String(envelope.match?.artifact?.digest || "");
208
+ if (
209
+ !isRoot(subjectRoot) ||
210
+ subjectRoot !== matchedRoot ||
211
+ bindings.artifactRoot !== subjectRoot
212
+ ) {
213
+ issues.push(issue("bindings.artifactRoot", "artifact root must match the exact verified subject and passport artifact"));
214
+ }
215
+ if (expectedIssuer && bindings.issuer !== expectedIssuer) {
216
+ issues.push(issue("bindings.issuer", "issuer does not match the expected authority"));
217
+ }
218
+ if (expectedPublisher && bindings.publisher !== expectedPublisher) {
219
+ issues.push(issue("bindings.publisher", "publisher does not match the expected identity"));
220
+ }
221
+ if (expectedContractVersion && bindings.contractVersion !== expectedContractVersion) {
222
+ issues.push(issue("bindings.contractVersion", "verification contract version does not match the expected version"));
223
+ }
224
+ }
225
+
226
+ const issuedAt = envelope.issuedAt;
227
+ const expiresAt = envelope.expiresAt;
228
+ if (
229
+ at < 0 ||
230
+ !Number.isSafeInteger(issuedAt) ||
231
+ issuedAt < 0 ||
232
+ !Number.isSafeInteger(expiresAt) ||
233
+ expiresAt <= issuedAt ||
234
+ at < issuedAt ||
235
+ at >= expiresAt
236
+ ) {
237
+ issues.push(issue("lifecycle.invalid", "envelope must be active at assessmentTime with valid issuedAt/expiresAt bounds"));
238
+ }
239
+ const revocation = envelope.revocation;
240
+ if (
241
+ !isObject(revocation) ||
242
+ !isRoot(revocation.root) ||
243
+ !Number.isSafeInteger(revocation.checkedAt) ||
244
+ revocation.checkedAt < issuedAt ||
245
+ (at >= 0 && revocation.checkedAt > at) ||
246
+ !["active", "revoked"].includes(revocation.status) ||
247
+ revocation.revoked !== (revocation.status === "revoked") ||
248
+ envelope.revoked !== revocation.revoked
249
+ ) {
250
+ issues.push(issue("lifecycle.revocation", "revocation binding is invalid or inconsistent"));
251
+ }
252
+ if (envelope.revoked === true) {
253
+ issues.push(issue("lifecycle.revoked", "artifact verification envelope has been revoked"));
254
+ }
255
+
256
+ const assessment = envelope.kfdAssessment;
257
+ const report = isObject(assessment?.report) ? assessment.report : {};
258
+ if (
259
+ !isObject(assessment) ||
260
+ assessment.schema !== KFD_ASSESSMENT_SCHEMA ||
261
+ assessment.state !== "fresh" ||
262
+ report.state !== "fresh"
263
+ ) {
264
+ issues.push(issue("kfd.assessment.state", "KFD assessment must be a fresh ADR-0052 assessment"));
265
+ }
266
+ if (
267
+ !isRoot(assessment?.assessment_key) ||
268
+ report.assessment_key !== assessment?.assessment_key
269
+ ) {
270
+ issues.push(issue("kfd.assessment.key", "KFD assessment key must be canonical and match the report"));
271
+ }
272
+ const computedReportRoot = isObject(report) ? recomputeKfdReportRoot(report) : "";
273
+ if (!isRoot(report.report_hash) || report.report_hash !== computedReportRoot) {
274
+ issues.push(issue("kfd.report.root", "KFD report hash does not match canonical report content", {
275
+ expected: computedReportRoot,
276
+ actual: report.report_hash || "",
277
+ }));
278
+ }
279
+ if (
280
+ !String(report.purpose || "").trim() ||
281
+ !isRoot(report.query_proof_root) ||
282
+ !isRoot(report.contract_world?.root) ||
283
+ !isRoot(report.policy?.root) ||
284
+ !Array.isArray(report.fact_surfaces) ||
285
+ report.fact_surfaces.length === 0 ||
286
+ report.fact_surfaces.some((surface) => !isRoot(surface?.root))
287
+ ) {
288
+ issues.push(issue("kfd.report.bindings", "KFD report must bind purpose, query proof, contract world, policy, and fact-surface roots"));
289
+ }
290
+ if (bindings && bindings.qualificationRoot !== report.report_hash) {
291
+ issues.push(issue("bindings.qualificationRoot", "qualification root must equal the KFD report hash"));
292
+ }
293
+
294
+ const ok = issues.length === 0;
295
+ return {
296
+ schemaVersion: 1,
297
+ contract: ARTIFACT_VERIFICATION_ENVELOPE_CHECK_CONTRACT,
298
+ ok,
299
+ outcome: ok ? "pass" : "fail",
300
+ assessmentTime,
301
+ envelopeRoot: metadata.root || "",
302
+ bindings: bindings || undefined,
303
+ kfdAssessment: isObject(assessment)
304
+ ? {
305
+ state: assessment.state || "",
306
+ assessmentKey: assessment.assessment_key || "",
307
+ reportRoot: report.report_hash || "",
308
+ }
309
+ : undefined,
310
+ issues,
311
+ };
312
+ }
313
+
314
+ export function sealArtifactVerificationReport({
315
+ report,
316
+ bindings,
317
+ kfdAssessment,
318
+ issuedAt,
319
+ expiresAt,
320
+ revocation,
321
+ } = {}) {
322
+ if (!isObject(report)) {
323
+ throw new Error("report must be an artifact verification object");
324
+ }
325
+ const normalizedIssuedAt = nonNegativeInteger(issuedAt, "issuedAt");
326
+ const normalizedExpiresAt = nonNegativeInteger(expiresAt, "expiresAt");
327
+ if (normalizedExpiresAt <= normalizedIssuedAt) {
328
+ throw new Error("expiresAt must be greater than issuedAt");
329
+ }
330
+ const normalizedRevocation = normalizeRevocation(revocation, {
331
+ issuedAt: normalizedIssuedAt,
332
+ });
333
+ const result = {
334
+ ...jsonClone(report),
335
+ issuedAt: normalizedIssuedAt,
336
+ expiresAt: normalizedExpiresAt,
337
+ revoked: normalizedRevocation.revoked,
338
+ revocation: normalizedRevocation,
339
+ bindings: normalizeBindings(bindings),
340
+ kfdAssessment: jsonClone(kfdAssessment),
341
+ envelope: {
342
+ schemaVersion: 1,
343
+ contract: ARTIFACT_VERIFICATION_ENVELOPE_CONTRACT,
344
+ canonicalization: "buildchain-stable-json/v1",
345
+ root: "",
346
+ },
347
+ };
348
+ result.envelope.root = envelopeRoot(result);
349
+ const check = verifyArtifactVerificationEnvelope({
350
+ envelope: result,
351
+ assessmentTime: normalizedRevocation.checkedAt,
352
+ });
353
+ if (!check.ok) {
354
+ throw new Error(`artifact verification envelope is invalid: ${check.issues.map((entry) => entry.code).join(", ")}`);
355
+ }
356
+ return result;
357
+ }
358
+
359
+ export function projectArtifactVerificationEnvelopeToKfx({
360
+ envelope,
361
+ assessmentTime = Math.floor(Date.now() / 1000),
362
+ expectedEnvelopeRoot = "",
363
+ expectedIssuer = "",
364
+ expectedPublisher = "",
365
+ expectedContractVersion = "",
366
+ } = {}) {
367
+ const check = verifyArtifactVerificationEnvelope({
368
+ envelope,
369
+ assessmentTime,
370
+ expectedEnvelopeRoot,
371
+ expectedIssuer,
372
+ expectedPublisher,
373
+ expectedContractVersion,
374
+ });
375
+ if (!check.ok) {
376
+ throw new Error(`artifact verification envelope failed closed: ${check.issues.map((entry) => entry.code).join(", ")}`);
377
+ }
378
+ return {
379
+ schemaVersion: 1,
380
+ contract: KFX_ADMISSION_INPUTS_CONTRACT,
381
+ envelopeRoot: envelope.envelope.root,
382
+ attestation: jsonClone(envelope),
383
+ trustInputs: jsonClone(envelope.bindings),
384
+ kfdAssessment: jsonClone(envelope.kfdAssessment),
385
+ };
386
+ }
@@ -1181,7 +1181,7 @@ export function getPublishContract(loadedConfig) {
1181
1181
  return loadedConfig?.config?.publish;
1182
1182
  }
1183
1183
 
1184
- export function runLifecycleStage({ cwd = process.cwd(), loadedConfig, name, stage, env: extraEnv }) {
1184
+ export function runLifecycleStage({ cwd = process.cwd(), loadedConfig, name, stage, env: extraEnv, timeoutMinutes }) {
1185
1185
  const lifecycle = loadedConfig?.config?.lifecycle || {};
1186
1186
  const selected = stage || getLifecycleStage(loadedConfig, name);
1187
1187
  if (!selected) {
@@ -1193,7 +1193,11 @@ export function runLifecycleStage({ cwd = process.cwd(), loadedConfig, name, sta
1193
1193
  ...(selected.env || {}),
1194
1194
  ...(extraEnv || {}),
1195
1195
  };
1196
- const timeout = selected.timeoutMinutes ? selected.timeoutMinutes * 60_000 : undefined;
1196
+ const effectiveTimeoutMinutes = selected.timeoutMinutes ?? timeoutMinutes;
1197
+ if (effectiveTimeoutMinutes !== undefined && (!Number.isFinite(effectiveTimeoutMinutes) || effectiveTimeoutMinutes <= 0)) {
1198
+ throw new Error("lifecycle timeoutMinutes must be a positive number");
1199
+ }
1200
+ const timeout = effectiveTimeoutMinutes ? effectiveTimeoutMinutes * 60_000 : undefined;
1197
1201
  const execOptions = {
1198
1202
  cwd,
1199
1203
  env,
@@ -344,7 +344,6 @@ export function createControllerReceipt({ plan, stages = [], evidence = [], reas
344
344
  const issues = status === "passed"
345
345
  ? missingEvidence.map((kind) => `required controller evidence is missing: ${kind}`)
346
346
  : [];
347
- const qualifying = status === "passed" && plan.qualifying === true && issues.length === 0;
348
347
  const receipt = {
349
348
  schemaVersion: 1,
350
349
  contract: BUILDCHAIN_CONTROLLER_EVIDENCE_CONTRACT,
@@ -354,11 +353,11 @@ export function createControllerReceipt({ plan, stages = [], evidence = [], reas
354
353
  runtime: { ...plan.runtime },
355
354
  planDigest: plan.digest,
356
355
  status,
357
- qualifying,
356
+ qualifying: status === "passed" && plan.qualifying === true && issues.length === 0,
358
357
  stages: normalizedStages,
359
358
  evidence: normalizedEvidence,
360
359
  issues,
361
- ...(reason && !qualifying ? {
360
+ ...(reason ? {
362
361
  reason: {
363
362
  code: nonEmptyString(reason.code, "reason.code"),
364
363
  summary: nonEmptyString(reason.summary, "reason.summary"),