@kungfu-tech/buildchain 3.0.6-alpha.0 → 3.0.6-alpha.2

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 (72) hide show
  1. package/README.md +4 -4
  2. package/actions/promote-buildchain-ref/README.md +8 -0
  3. package/bin/buildchain.mjs +13 -1
  4. package/contracts/auditable-demo-scenario-v1.schema.json +52 -0
  5. package/dist/site/buildchain-contract.json +47 -27
  6. package/dist/site/buildchain-site.json +165 -44
  7. package/dist/site/capability-registry.json +3 -3
  8. package/dist/site/cli-registry.json +40 -4
  9. package/dist/site/controller-registry.json +20 -4
  10. package/dist/site/kfd-claims.json +140 -19
  11. package/dist/site/kfd-upstream-aggregate.json +9 -9
  12. package/dist/site/manual-registry.json +9 -9
  13. package/dist/site/node-api-registry.json +1161 -180
  14. package/dist/site/page-registry.json +152 -31
  15. package/dist/site/public-surface-audit.json +386 -19
  16. package/dist/site/publication-authority-registry.json +61 -1
  17. package/dist/site/publication-registry.json +4 -4
  18. package/dist/site/release-provenance.json +1 -0
  19. package/dist/site/site-manifest.json +13 -13
  20. package/dist/site/workflow-registry.json +151 -13
  21. package/docs/MAP.md +2 -0
  22. package/docs/auditable-demo.md +58 -11
  23. package/docs/aws-us-elastic-runner-burst-plane.md +114 -80
  24. package/docs/cli-reference.md +154 -0
  25. package/docs/dev-alpha-candidate-patrol.md +13 -5
  26. package/docs/dev-delivery-warrant.md +158 -0
  27. package/docs/node-api-reference.md +54 -15
  28. package/docs/publication-authority.md +11 -0
  29. package/docs/release-candidate.md +19 -2
  30. package/docs/release-governance.md +60 -1
  31. package/docs/reusable-build-surface.md +11 -1
  32. package/docs/shifu-gate-profiles.md +12 -1
  33. package/docs/versioning.md +2 -0
  34. package/package.json +4 -2
  35. package/packages/core/buildchain-publication-authority.js +3 -1
  36. package/packages/core/channel-candidate.js +2 -21
  37. package/packages/core/channel-promotion-baseline.js +199 -0
  38. package/packages/core/dev-delivery-candidate-identity.js +94 -0
  39. package/packages/core/dev-delivery-common.js +73 -0
  40. package/packages/core/dev-delivery-proof.js +252 -0
  41. package/packages/core/dev-delivery-warrant-cancellation.js +94 -0
  42. package/packages/core/dev-delivery-warrant-settlement.js +73 -0
  43. package/packages/core/dev-delivery-warrant.js +591 -0
  44. package/scripts/auditable-demo-bundle-verification.mjs +148 -0
  45. package/scripts/auditable-demo-platform.mjs +86 -50
  46. package/scripts/auditable-demo-presentation.mjs +83 -0
  47. package/scripts/auditable-demo-renditions.mjs +264 -0
  48. package/scripts/auditable-demo.mjs +24 -30
  49. package/scripts/aws-windows-jit-campaign-core.mjs +7 -8
  50. package/scripts/aws-windows-jit-controller.mjs +1 -0
  51. package/scripts/aws-windows-jit-core.mjs +1 -1
  52. package/scripts/build-contract-core.mjs +58 -3
  53. package/scripts/buildchain-cli-help.mjs +8 -0
  54. package/scripts/buildchain-patrol.mjs +9 -0
  55. package/scripts/check-inventory.mjs +1 -0
  56. package/scripts/dev-alpha-candidate-patrol.mjs +45 -48
  57. package/scripts/dev-delivery-proof.mjs +193 -0
  58. package/scripts/dev-delivery-warrant.mjs +426 -0
  59. package/scripts/dev-pr-auto-merge.mjs +497 -55
  60. package/scripts/dev-pr-delivery-warrant.mjs +209 -0
  61. package/scripts/dispatch-artifact-signing-authority.mjs +2 -4
  62. package/scripts/gate-profile-core.mjs +24 -0
  63. package/scripts/generate-site-bundle.mjs +2 -2
  64. package/scripts/git-fetch-process-tree.mjs +142 -0
  65. package/scripts/lifecycle-substage-evidence.mjs +274 -0
  66. package/scripts/locked-source-checkout.mjs +6 -3
  67. package/scripts/resolve-artifact-transfer-mode.mjs +9 -0
  68. package/scripts/resolve-build-contract.mjs +7 -0
  69. package/scripts/route-offline-runners.mjs +1 -0
  70. package/scripts/run-lifecycle-core.mjs +9 -9
  71. package/scripts/shifu-gate-profile.mjs +10 -16
  72. package/scripts/site-capability-metadata.mjs +14 -0
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import crypto from "node:crypto";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { readRendererManifest } from "./auditable-demo-renditions.mjs";
8
+
9
+ const DIGEST = /^sha256:[0-9a-f]{64}$/u;
10
+ const MAX_METADATA_MEMBER_BYTES = 8 * 1024 * 1024;
11
+ const MAX_LONG_FORM_MANIFEST_BYTES = 32 * 1024 * 1024;
12
+ const MAX_TERMINAL_CAPTURE_BYTES = 4 * 1024 * 1024;
13
+ const MAX_TERMINAL_CAPTURE_EVENTS = 10_000;
14
+ const DIGEST_BUFFER_BYTES = 64 * 1024;
15
+ const UTF8 = new TextDecoder("utf-8", { fatal: true });
16
+
17
+ function fail(message) {
18
+ throw new Error(`auditable demo platform: ${message}`);
19
+ }
20
+
21
+ function requireValue(condition, message) {
22
+ if (!condition) fail(message);
23
+ }
24
+
25
+ function rootBytes(value) {
26
+ return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
27
+ }
28
+
29
+ function boundedRegular(file, label, maximum) {
30
+ const metadata = fs.lstatSync(file);
31
+ requireValue(metadata.isFile() && !metadata.isSymbolicLink() && metadata.size <= maximum, `${label} must be a bounded regular file`);
32
+ return metadata;
33
+ }
34
+
35
+ function readRegular(file, label, maximum = MAX_METADATA_MEMBER_BYTES) {
36
+ boundedRegular(file, label, maximum);
37
+ return fs.readFileSync(file);
38
+ }
39
+
40
+ function digestRegular(file, label, maximum) {
41
+ const expected = boundedRegular(file, label, maximum);
42
+ const descriptor = fs.openSync(file, "r");
43
+ const hash = crypto.createHash("sha256");
44
+ const buffer = Buffer.allocUnsafe(DIGEST_BUFFER_BYTES);
45
+ let bytes = 0;
46
+ try {
47
+ while (true) {
48
+ const count = fs.readSync(descriptor, buffer, 0, buffer.length, null);
49
+ if (count === 0) break;
50
+ bytes += count;
51
+ requireValue(bytes <= maximum, `${label} must be a bounded regular file`);
52
+ hash.update(buffer.subarray(0, count));
53
+ }
54
+ } finally {
55
+ fs.closeSync(descriptor);
56
+ }
57
+ requireValue(bytes === expected.size, `${label} changed while it was verified`);
58
+ return { bytes, root: `sha256:${hash.digest("hex")}` };
59
+ }
60
+
61
+ function decodeUtf8(bytes, label) {
62
+ try {
63
+ return UTF8.decode(bytes);
64
+ } catch {
65
+ fail(`${label} must be valid UTF-8`);
66
+ }
67
+ }
68
+
69
+ function inside(root, relative, label) {
70
+ requireValue(typeof relative === "string" && relative && !path.isAbsolute(relative), `${label} must be relative`);
71
+ const resolvedRoot = path.resolve(root);
72
+ const resolved = path.resolve(resolvedRoot, relative);
73
+ requireValue(resolved !== resolvedRoot && resolved.startsWith(`${resolvedRoot}${path.sep}`), `${label} escapes its root`);
74
+ return resolved;
75
+ }
76
+
77
+ function listBundleFiles(root, prefix = "") {
78
+ const entries = fs.readdirSync(path.join(root, prefix), { withFileTypes: true })
79
+ .sort((left, right) => left.name.localeCompare(right.name, "en"));
80
+ const files = [];
81
+ for (const entry of entries) {
82
+ requireValue(!entry.isSymbolicLink(), `bundle member must not be a symbolic link: ${path.join(prefix, entry.name)}`);
83
+ const relative = path.join(prefix, entry.name);
84
+ if (entry.isDirectory()) files.push(...listBundleFiles(root, relative));
85
+ else {
86
+ requireValue(entry.isFile(), `bundle member must be a regular file: ${relative}`);
87
+ files.push(relative.split(path.sep).join("/"));
88
+ }
89
+ }
90
+ return files;
91
+ }
92
+
93
+ const RENDERER_MANIFEST_HELPERS = {
94
+ decodeUtf8,
95
+ digestPattern: DIGEST,
96
+ invariant: requireValue,
97
+ maxBytes: MAX_TERMINAL_CAPTURE_BYTES,
98
+ maxEvents: MAX_TERMINAL_CAPTURE_EVENTS,
99
+ readRegular,
100
+ };
101
+
102
+ export function verifyBundleChecksums(root, label, options = {}) {
103
+ const resolved = path.resolve(root);
104
+ const checksums = readRegular(path.join(resolved, "checksums.sha256"), `${label} checksums`);
105
+ const declared = new Set();
106
+ const members = [];
107
+ let bundleBytes = 0;
108
+ for (const row of checksums.toString("utf8").split("\n").filter(Boolean)) {
109
+ const match = /^([0-9a-f]{64}) ([^\0\r\n]+)$/u.exec(row);
110
+ requireValue(match, `${label} checksum row is invalid`);
111
+ const target = inside(resolved, match[2], `${label} checksum member`);
112
+ requireValue(!declared.has(match[2]), `${label} checksum member is repeated`);
113
+ declared.add(match[2]);
114
+ const metadataMember = match[2].endsWith(".json") || match[2].endsWith(".sha256");
115
+ const maximum = options.allowLongFormRendererManifest && match[2] === "manifest.json"
116
+ ? MAX_LONG_FORM_MANIFEST_BYTES
117
+ : metadataMember
118
+ ? MAX_METADATA_MEMBER_BYTES
119
+ : (options.maximumMemberBytes || MAX_METADATA_MEMBER_BYTES);
120
+ bundleBytes += boundedRegular(target, `${label} member`, maximum).size;
121
+ requireValue(bundleBytes <= options.maximumBundleBytes, `${label} exceeds its aggregate byte budget`);
122
+ members.push({ expectedRoot: `sha256:${match[1]}`, maximum, name: match[2], target });
123
+ }
124
+ for (const member of members) {
125
+ const verified = options.allowLongFormRendererManifest && member.name === "manifest.json"
126
+ ? (() => {
127
+ const value = readRendererManifest(member.target, RENDERER_MANIFEST_HELPERS).bytes;
128
+ return { bytes: value.length, root: rootBytes(value) };
129
+ })()
130
+ : digestRegular(member.target, `${label} member`, member.maximum);
131
+ requireValue(verified.root === member.expectedRoot, `${label} checksum mismatch: ${member.name}`);
132
+ if (member.name === "manifest.json" && options.rendererManifestRoot) {
133
+ requireValue(verified.root === options.rendererManifestRoot, `${label} renderer manifest root mismatch`);
134
+ }
135
+ }
136
+ const actual = listBundleFiles(resolved).filter((name) => name !== "checksums.sha256");
137
+ requireValue(JSON.stringify([...declared].sort()) === JSON.stringify(actual), `${label} checksum member set is not exact`);
138
+ return rootBytes(checksums);
139
+ }
140
+
141
+ export function copyVerifiedRegular(source, destination, label, maximum) {
142
+ const sourceDigest = digestRegular(source, label, maximum);
143
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
144
+ fs.copyFileSync(source, destination);
145
+ const destinationDigest = digestRegular(destination, `${label} copy`, maximum);
146
+ requireValue(destinationDigest.bytes === sourceDigest.bytes && destinationDigest.root === sourceDigest.root, `${label} copy differs from its verified source`);
147
+ return { path: path.basename(destination), bytes: destinationDigest.bytes, root: destinationDigest.root };
148
+ }
@@ -5,6 +5,8 @@ import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
+ import { materializeDemoPresentation, validateDemoPresentation } from "./auditable-demo-presentation.mjs";
9
+ import { copyVerifiedRegular, verifyBundleChecksums } from "./auditable-demo-bundle-verification.mjs";
8
10
 
9
11
  const DIGEST = /^sha256:[0-9a-f]{64}$/u;
10
12
  const SAFE_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
@@ -25,7 +27,13 @@ const RENDITIONS = [
25
27
  ];
26
28
  const STANDARD_MAX_SECONDS = 60;
27
29
  const LONG_FORM_MAX_SECONDS = 180;
30
+ const PRESENTATION_FRAMED = "presentation-framed";
31
+ const TERMINAL_FILL = "terminal-fill";
28
32
  const MAX_EXECUTABLE_FILES = 32;
33
+ const MAX_METADATA_MEMBER_BYTES = 8 * 1024 * 1024;
34
+ const MAX_MEDIA_MEMBER_BYTES = 64 * 1024 * 1024;
35
+ const MAX_GATE_BUNDLE_BYTES = 64 * 1024 * 1024;
36
+ const MAX_MEDIA_BUNDLE_BYTES = 128 * 1024 * 1024;
29
37
 
30
38
  function durationPolicy(value = "standard") {
31
39
  requireValue(value === "standard" || value === "long-form", "scenario duration class is invalid");
@@ -80,40 +88,6 @@ function readJson(file, label) {
80
88
  }
81
89
  }
82
90
 
83
- function listBundleFiles(root, prefix = "") {
84
- const entries = fs.readdirSync(path.join(root, prefix), { withFileTypes: true })
85
- .sort((left, right) => left.name.localeCompare(right.name, "en"));
86
- const files = [];
87
- for (const entry of entries) {
88
- requireValue(!entry.isSymbolicLink(), `bundle member must not be a symbolic link: ${path.join(prefix, entry.name)}`);
89
- const relative = path.join(prefix, entry.name);
90
- if (entry.isDirectory()) files.push(...listBundleFiles(root, relative));
91
- else {
92
- requireValue(entry.isFile(), `bundle member must be a regular file: ${relative}`);
93
- files.push(relative.split(path.sep).join("/"));
94
- }
95
- }
96
- return files;
97
- }
98
-
99
- function verifyChecksums(root, label) {
100
- const resolved = path.resolve(root);
101
- const bytes = regular(path.join(resolved, "checksums.sha256"), `${label} checksums`);
102
- const rows = bytes.toString("utf8").split("\n").filter(Boolean);
103
- const declared = new Set();
104
- for (const row of rows) {
105
- const match = /^([0-9a-f]{64}) ([^\0\r\n]+)$/u.exec(row);
106
- requireValue(match, `${label} checksum row is invalid`);
107
- const target = inside(resolved, match[2], `${label} checksum member`);
108
- requireValue(!declared.has(match[2]), `${label} checksum member is repeated`);
109
- declared.add(match[2]);
110
- requireValue(rootBytes(regular(target, `${label} member`)) === `sha256:${match[1]}`, `${label} checksum mismatch: ${match[2]}`);
111
- }
112
- const actual = listBundleFiles(resolved).filter((name) => name !== "checksums.sha256");
113
- requireValue(JSON.stringify([...declared].sort()) === JSON.stringify(actual), `${label} checksum member set is not exact`);
114
- return rootBytes(bytes);
115
- }
116
-
117
91
  function inside(root, relative, label) {
118
92
  requireValue(typeof relative === "string" && relative && !path.isAbsolute(relative), `${label} must be relative`);
119
93
  const resolvedRoot = path.resolve(root);
@@ -204,6 +178,16 @@ function validateExecution(execution) {
204
178
  return policy;
205
179
  }
206
180
 
181
+ function validatePlayback(playback, maximumSeconds) {
182
+ exactKeys(playback, ["schema", "mode", "activeDurationMs", "finalHoldMs"], [], "scenario.playback");
183
+ requireValue(playback.schema === "buildchain.declarative-demo-playback/v1", "scenario playback schema is unsupported");
184
+ requireValue(playback.mode === "deterministic-readable", "scenario playback mode is invalid");
185
+ requireValue(Number.isInteger(playback.activeDurationMs) && playback.activeDurationMs >= 1000, "scenario playback active duration is invalid");
186
+ requireValue(Number.isInteger(playback.finalHoldMs) && playback.finalHoldMs >= 250 && playback.finalHoldMs <= 5000, "scenario playback final hold is invalid");
187
+ requireValue(playback.activeDurationMs + playback.finalHoldMs <= maximumSeconds * 1000, "scenario playback exceeds its declared duration class");
188
+ return playback;
189
+ }
190
+
207
191
  function validateStep(step, stepLabel, stepIds, maximumSeconds) {
208
192
  exactKeys(step, ["id", "argv", "timeoutSeconds", "expectedExitCodes", "stdoutIncludes", "fileAssertions"], [], stepLabel);
209
193
  requireValue(SAFE_ID.test(step.id) && !stepIds.has(step.id), `${stepLabel}.id is invalid or repeated`);
@@ -234,12 +218,18 @@ function validateDemo(demo, index, demoIds, maximumSeconds) {
234
218
  }
235
219
 
236
220
  export function validateScenario(value) {
237
- exactKeys(value, ["schema", "product", "artifact", "execution", "renditions", "demos", "publication", "authority"], ["transportSmoke"], "scenario");
221
+ exactKeys(value, ["schema", "product", "artifact", "execution", "renditions", "demos", "publication", "authority"], ["compositionMode", "playback", "transportSmoke", "presentation"], "scenario");
238
222
  requireValue(value.schema === "buildchain.declarative-binary-demo/v1", "unsupported scenario schema");
223
+ const compositionMode = value.compositionMode ?? PRESENTATION_FRAMED;
224
+ requireValue(
225
+ compositionMode === PRESENTATION_FRAMED || compositionMode === TERMINAL_FILL,
226
+ "scenario composition mode is invalid",
227
+ );
239
228
  validateProduct(value.product);
240
229
  validateArtifact(value.artifact);
241
230
  const executionPolicy = validateExecution(value.execution);
242
- requireValue(JSON.stringify(value.renditions) === JSON.stringify(RENDITIONS), "scenario must declare both native rendition profiles exactly");
231
+ if (value.playback) validatePlayback(value.playback, executionPolicy.maximumSeconds);
232
+ requireValue(stableJson(value.renditions) === stableJson(RENDITIONS), "scenario must declare both native rendition profiles exactly");
243
233
  requireValue(Array.isArray(value.demos) && value.demos.length >= 1 && value.demos.length <= 8, "scenario requires 1 through 8 demos");
244
234
  const demoIds = new Set();
245
235
  value.demos.forEach((demo, index) => validateDemo(demo, index, demoIds, executionPolicy.maximumSeconds));
@@ -248,12 +238,13 @@ export function validateScenario(value) {
248
238
  inside("/repository", value.publication.evidencePath, "scenario.publication.evidencePath");
249
239
  inside("/repository", value.publication.readmePath, "scenario.publication.readmePath");
250
240
  requireValue(SAFE_MARKER.test(value.publication.marker), "scenario publication marker is invalid");
241
+ if (value.presentation) validateDemoPresentation({ presentation: value.presentation, demos: value.demos, publication: value.publication, exactKeys, inside, requireValue, safeMarker: SAFE_MARKER });
251
242
  exactKeys(value.authority, ["grants", "nonAuthorities"], [], "scenario.authority");
252
243
  requireValue(JSON.stringify(value.authority) === JSON.stringify({ grants: [], nonAuthorities: NON_AUTHORITIES }), "scenario authority boundary is invalid");
253
244
  return value;
254
245
  }
255
246
 
256
- function validateCapture(capture, rendition, summaryRoot, durationClass) {
247
+ function validateCapture(capture, rendition, summaryRoot, durationClass, declaredPlayback) {
257
248
  const policy = durationPolicy(durationClass);
258
249
  requireValue(capture.schema === "buildchain.declarative-terminal-capture/v1", "capture schema mismatch");
259
250
  requireValue(JSON.stringify(capture.dimensions) === JSON.stringify({ columns: rendition.columns, rows: rendition.rows }), "capture dimensions mismatch");
@@ -268,13 +259,36 @@ function validateCapture(capture, rendition, summaryRoot, durationClass) {
268
259
  requireValue(index > 0 || event.atMs === 0, "capture event timeline must start at zero");
269
260
  previousAtMs = event.atMs;
270
261
  }
262
+ if (!declaredPlayback) {
263
+ requireValue(capture.playback === undefined, "legacy capture unexpectedly declares playback evidence");
264
+ return capture;
265
+ }
266
+ exactKeys(capture.playback, [
267
+ "schema", "mode", "timingSource", "activeDurationMs", "finalHoldMs", "presentedDurationMs",
268
+ "observedLastEventMs", "eventPayloadRoot", "eventOrder",
269
+ ], [], "capture.playback");
270
+ requireValue(capture.playback.schema === "buildchain.declarative-terminal-playback/v1", "capture playback schema mismatch");
271
+ requireValue(capture.playback.mode === declaredPlayback.mode, "capture playback mode mismatch");
272
+ requireValue(capture.playback.timingSource === "declared-event-ordinal", "capture playback timing source mismatch");
273
+ requireValue(capture.playback.activeDurationMs === declaredPlayback.activeDurationMs && capture.playback.finalHoldMs === declaredPlayback.finalHoldMs, "capture playback duration mismatch");
274
+ requireValue(capture.playback.presentedDurationMs === capture.durationMs && capture.durationMs === declaredPlayback.activeDurationMs + declaredPlayback.finalHoldMs, "capture presented duration mismatch");
275
+ requireValue(Number.isInteger(capture.playback.observedLastEventMs) && capture.playback.observedLastEventMs >= 0, "capture observed duration is invalid");
276
+ requireValue(capture.playback.eventOrder === "preserved", "capture playback event order mismatch");
277
+ requireValue(capture.playback.eventPayloadRoot === rootJson(capture.events.map((event) => event.data)), "capture playback payload root mismatch");
278
+ const lastIndex = capture.events.length - 1;
279
+ for (const [index, event] of capture.events.entries()) {
280
+ const expectedAtMs = lastIndex === 0 ? 0 : Math.round((index * declaredPlayback.activeDurationMs) / lastIndex);
281
+ requireValue(event.atMs === expectedAtMs, "capture playback timeline is not deterministic");
282
+ }
271
283
  return capture;
272
284
  }
273
285
 
274
- function projection(capture, transcript, demo, rendition, durationClass, sharedCaptureDurationMs) {
286
+ function projection(capture, transcript, demo, rendition, durationClass, sharedCaptureDurationMs, compositionMode) {
275
287
  const lines = transcript.endsWith("\n") ? transcript.slice(0, -1).split("\n") : transcript.split("\n");
276
288
  const policy = durationPolicy(durationClass);
277
- const durationMs = Math.min(policy.maximumSeconds * 1000, sharedCaptureDurationMs + 1000);
289
+ const durationMs = capture.playback
290
+ ? sharedCaptureDurationMs
291
+ : Math.min(policy.maximumSeconds * 1000, sharedCaptureDurationMs + 1000);
278
292
  const projected = {
279
293
  schema: "kungfu.terminal-capture/v1",
280
294
  command: capture.command,
@@ -293,6 +307,7 @@ function projection(capture, transcript, demo, rendition, durationClass, sharedC
293
307
  height: rendition.height,
294
308
  fps: policy.durationClass === "long-form" ? 10 : 15,
295
309
  ...(policy.durationClass === "long-form" ? { durationClass: "long-form" } : {}),
310
+ compositionMode,
296
311
  durationMs,
297
312
  title: demo.title,
298
313
  commandLabel: capture.command,
@@ -322,8 +337,11 @@ export function adaptCapture({ artifactRoot, output }) {
322
337
  const { root: _root, ...manifestBody } = manifest;
323
338
  requireValue(DIGEST.test(declaredRoot) && rootJson(manifestBody) === declaredRoot, "capture manifest root mismatch");
324
339
  requireValue(manifest.authority?.grants?.length === 0 && JSON.stringify(manifest.authority?.nonAuthorities) === JSON.stringify(NON_AUTHORITIES), "capture manifest grants authority");
340
+ const scenario = validateScenario(readJson(path.join(root, "scenario.json"), "captured scenario"));
341
+ requireValue(rootJson(scenario) === manifest.scenarioRoot, "captured scenario root mismatch");
325
342
  requireValue(Array.isArray(manifest.renditions) && manifest.renditions.length === 2, "capture rendition set is invalid");
326
343
  const executionPolicy = durationPolicy(manifest.execution?.durationClass);
344
+ requireValue(stableJson(manifest.execution?.playback) === stableJson(scenario.playback), "capture manifest playback declaration mismatch");
327
345
  prepareOutput(output);
328
346
  const set = [];
329
347
  const loaded = RENDITIONS.map((expected, index) => {
@@ -335,7 +353,7 @@ export function adaptCapture({ artifactRoot, output }) {
335
353
  const summary = readJson(inside(root, descriptor.runSummary, "run summary"), "run summary");
336
354
  requireValue(rootJson(summary) === descriptor.runSummaryRoot, "run summary root mismatch");
337
355
  const captureBytes = regular(inside(root, descriptor.terminalCapture, "terminal capture"), "terminal capture", 4 * 1024 * 1024);
338
- const capture = validateCapture(JSON.parse(captureBytes.toString("utf8")), expected, descriptor.runSummaryRoot, executionPolicy.durationClass);
356
+ const capture = validateCapture(JSON.parse(captureBytes.toString("utf8")), expected, descriptor.runSummaryRoot, executionPolicy.durationClass, scenario.playback);
339
357
  requireValue(rootJson(capture) === descriptor.terminalCaptureRoot, "terminal capture root mismatch");
340
358
  return { index, expected, descriptor, transcript, capture };
341
359
  });
@@ -349,6 +367,7 @@ export function adaptCapture({ artifactRoot, output }) {
349
367
  expected,
350
368
  executionPolicy.durationClass,
351
369
  sharedCaptureDurationMs,
370
+ scenario.compositionMode ?? PRESENTATION_FRAMED,
352
371
  );
353
372
  const suffix = index === 0 ? "" : "-720p";
354
373
  fs.writeFileSync(path.join(output, `complete-transcript${suffix}.txt`), transcript);
@@ -367,10 +386,7 @@ export function adaptCapture({ artifactRoot, output }) {
367
386
  }
368
387
 
369
388
  function copyRegular(source, destination, label) {
370
- const bytes = regular(source, label, 64 * 1024 * 1024);
371
- fs.mkdirSync(path.dirname(destination), { recursive: true });
372
- fs.writeFileSync(destination, bytes);
373
- return { path: path.basename(destination), bytes: bytes.length, root: rootBytes(bytes) };
389
+ return copyVerifiedRegular(source, destination, label, MAX_MEDIA_MEMBER_BYTES);
374
390
  }
375
391
 
376
392
  function replaceReadmeBlock(readme, marker, block) {
@@ -412,8 +428,14 @@ export function materializeDemo({ repositoryRoot, scenarioPath, demoId, captureR
412
428
  requireValue(captureManifest.demo?.id === demoId && captureManifest.scenarioRoot === rootJson(scenario), "capture does not bind the exact scenario demo");
413
429
  const gateReceipt = readJson(path.join(path.resolve(gateBundle), "gate-receipt.json"), "gate receipt");
414
430
  const mediaReceipt = readJson(path.join(path.resolve(mediaBundle), "media-receipt.json"), "media receipt");
415
- const gateRoot = verifyChecksums(gateBundle, "Gate bundle");
416
- const mediaRoot = verifyChecksums(mediaBundle, "media bundle");
431
+ const gateRoot = verifyBundleChecksums(gateBundle, "Gate bundle", { maximumBundleBytes: MAX_GATE_BUNDLE_BYTES });
432
+ requireValue(DIGEST.test(mediaReceipt.rendererManifestRoot), "media receipt renderer manifest root is invalid");
433
+ const mediaRoot = verifyBundleChecksums(mediaBundle, "media bundle", {
434
+ allowLongFormRendererManifest: scenario.execution.durationClass === "long-form",
435
+ maximumBundleBytes: MAX_MEDIA_BUNDLE_BYTES,
436
+ maximumMemberBytes: MAX_MEDIA_MEMBER_BYTES,
437
+ rendererManifestRoot: mediaReceipt.rendererManifestRoot,
438
+ });
417
439
  requireValue(gateReceipt.status === "passed" && mediaReceipt.status === "passed", "Gate and media receipts must pass");
418
440
  requireValue(mediaReceipt.qualifiedGateRoot === gateRoot, "media receipt is not bound to the exact qualified Gate");
419
441
  const sourceCoordinate = readJson(path.join(path.resolve(captureRoot), "source-coordinate.json"), "source coordinate");
@@ -462,11 +484,12 @@ export function materializeDemo({ repositoryRoot, scenarioPath, demoId, captureR
462
484
  ? scenario.publication.marker
463
485
  : `${scenario.publication.marker}:${demo.id}`;
464
486
  const commandLines = demo.steps.map((step) => `$ ${scenario.product.binaryName} ${step.argv.join(" ")}`.trim()).join("\n");
465
- const block = [
487
+ const imageLine = `[![${demo.title}](${relative}/demo.gif)](${relative}/public-evidence.json)`;
488
+ const legacyBlock = [
466
489
  `<!-- ${marker}:start -->`,
467
490
  `## ${demo.title}`,
468
491
  "",
469
- `[![${demo.title}](${relative}/demo.gif)](${relative}/public-evidence.json)`,
492
+ imageLine,
470
493
  "",
471
494
  "Animation scenario:",
472
495
  "",
@@ -488,10 +511,20 @@ export function materializeDemo({ repositoryRoot, scenarioPath, demoId, captureR
488
511
  "</details>",
489
512
  `<!-- ${marker}:end -->`,
490
513
  ].join("\n");
514
+ let block = legacyBlock;
515
+ let technicalSpecPath = "";
516
+ if (scenario.presentation) {
517
+ const materialized = materializeDemoPresentation({
518
+ repository, scenario, demo, evidenceDirectory, imageLine, commandLines,
519
+ inside, regular, replaceBlock: replaceReadmeBlock, requireValue,
520
+ });
521
+ block = [`<!-- ${marker}:start -->`, ...materialized.blockLines, `<!-- ${marker}:end -->`].join("\n");
522
+ technicalSpecPath = materialized.technicalSpecPath;
523
+ }
491
524
  const readmePath = inside(repository, scenario.publication.readmePath, "README path");
492
525
  const readme = regular(readmePath, "README", 4 * 1024 * 1024).toString("utf8");
493
526
  fs.writeFileSync(readmePath, replaceReadmeBlock(readme, marker, block));
494
- return { ok: true, demoId, evidenceRoot, evidenceDirectory: relative, passportRoot: passport.passportRoot };
527
+ return { ok: true, demoId, evidenceRoot, evidenceDirectory: relative, passportRoot: passport.passportRoot, technicalSpecPath };
495
528
  }
496
529
 
497
530
  function parseArgs(argv) {
@@ -521,7 +554,10 @@ function main(argv = process.argv.slice(2)) {
521
554
  }
522
555
  if (command === "publication") {
523
556
  const scenario = validateScenario(readJson(path.resolve(args.scenario), "scenario"));
524
- process.stdout.write(stableJson(scenario.publication));
557
+ process.stdout.write(stableJson({
558
+ ...scenario.publication,
559
+ ...(scenario.presentation ? { technicalSpecPath: scenario.presentation.materialization.technicalSpecPath } : {}),
560
+ }));
525
561
  return;
526
562
  }
527
563
  if (command === "prepare-artifact") {
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function boundedText(value, maximum, label, requireValue) {
5
+ requireValue(typeof value === "string" && value.length > 0 && value.length <= maximum, `${label} is invalid`);
6
+ }
7
+
8
+ export function validateDemoPresentation({ presentation, demos, publication, exactKeys, inside, requireValue, safeMarker }) {
9
+ exactKeys(presentation, ["schema", "proofs", "materialization"], [], "scenario.presentation");
10
+ requireValue(presentation.schema === "buildchain.declarative-demo-presentation/v1", "scenario presentation schema is unsupported");
11
+ requireValue(Array.isArray(presentation.proofs) && presentation.proofs.length === demos.length, "scenario presentation must bind every demo exactly once");
12
+ const labels = new Set();
13
+ for (const [index, proof] of presentation.proofs.entries()) {
14
+ const label = `scenario.presentation.proofs[${index}]`;
15
+ exactKeys(proof, ["demoId", "label", "question", "summary"], ["transitionAfter"], label);
16
+ requireValue(proof.demoId === demos[index].id, `${label}.demoId must preserve demo order`);
17
+ boundedText(proof.label, 80, `${label}.label`, requireValue);
18
+ requireValue(!labels.has(proof.label), `${label}.label is repeated`);
19
+ labels.add(proof.label);
20
+ boundedText(proof.question, 120, `${label}.question`, requireValue);
21
+ requireValue(proof.question === demos[index].title, `${label}.question must equal the demo title used by capture and media`);
22
+ boundedText(proof.summary, 500, `${label}.summary`, requireValue);
23
+ if (proof.transitionAfter !== undefined) boundedText(proof.transitionAfter, 500, `${label}.transitionAfter`, requireValue);
24
+ }
25
+ const materialization = presentation.materialization;
26
+ exactKeys(materialization, ["readmeMode", "technicalSpecPath", "technicalSpecTitle", "technicalMarker"], [], "scenario.presentation.materialization");
27
+ requireValue(["full", "media-only"].includes(materialization.readmeMode), "scenario presentation readmeMode is invalid");
28
+ const technicalSpecPath = inside("/repository", materialization.technicalSpecPath, "scenario presentation technicalSpecPath");
29
+ const readmePath = inside("/repository", publication.readmePath, "scenario publication readmePath");
30
+ requireValue(technicalSpecPath !== readmePath, "scenario presentation technical spec must be separate from the README");
31
+ boundedText(materialization.technicalSpecTitle, 120, "scenario.presentation.materialization.technicalSpecTitle", requireValue);
32
+ requireValue(safeMarker.test(materialization.technicalMarker), "scenario presentation technicalMarker is invalid");
33
+ }
34
+
35
+ function ensureOrderedMarkers(document, presentation, requireValue) {
36
+ let result = document;
37
+ for (const proof of presentation.proofs) {
38
+ const marker = `${presentation.materialization.technicalMarker}:${proof.demoId}`;
39
+ const start = `<!-- ${marker}:start -->`;
40
+ const end = `<!-- ${marker}:end -->`;
41
+ const first = result.indexOf(start);
42
+ const last = result.indexOf(end);
43
+ requireValue((first === -1) === (last === -1), "technical specification materialization markers are incomplete");
44
+ if (first !== -1) {
45
+ requireValue(result.indexOf(start, first + start.length) === -1 && result.indexOf(end, last + end.length) === -1 && last > first, "technical specification materialization markers are ambiguous");
46
+ } else {
47
+ result = `${result.trimEnd()}\n\n${start}\n${end}\n`;
48
+ }
49
+ }
50
+ return result;
51
+ }
52
+
53
+ export function materializeDemoPresentation({ repository, scenario, demo, evidenceDirectory, imageLine, commandLines, inside, regular, replaceBlock, requireValue }) {
54
+ const presentation = scenario.presentation;
55
+ const proof = presentation.proofs.find((entry) => entry.demoId === demo.id);
56
+ const technical = presentation.materialization;
57
+ const technicalSpecPath = inside(repository, technical.technicalSpecPath, "technical specification path");
58
+ const technicalRelative = path.relative(path.dirname(technicalSpecPath), evidenceDirectory).split(path.sep).join("/");
59
+ const technicalMarker = `${technical.technicalMarker}:${demo.id}`;
60
+ const technicalBlock = [
61
+ `<!-- ${technicalMarker}:start -->`, `## ${proof.label}: ${proof.question}`, "", proof.summary, "",
62
+ `[![${proof.question}](${technicalRelative}/demo.gif)](${technicalRelative}/public-evidence.json)`, "", "Commands:", "", "```text", commandLines, "```", "",
63
+ `Native renditions: [1080p MP4](${technicalRelative}/demo.mp4) · [1080p WebM](${technicalRelative}/demo.webm) · [720p MP4](${technicalRelative}/demo-720p.mp4) · [720p WebM](${technicalRelative}/demo-720p.webm)`, "",
64
+ `Claim boundary: ${demo.claimBoundary}`, "", `[Release Passport](${technicalRelative}/release-passport.json) · [auditable evidence](${technicalRelative}/public-evidence.json)`,
65
+ ...(proof.transitionAfter ? ["", proof.transitionAfter] : []), `<!-- ${technicalMarker}:end -->`,
66
+ ].join("\n");
67
+ fs.mkdirSync(path.dirname(technicalSpecPath), { recursive: true });
68
+ const existing = fs.existsSync(technicalSpecPath)
69
+ ? regular(technicalSpecPath, "technical specification", 4 * 1024 * 1024).toString("utf8")
70
+ : `# ${technical.technicalSpecTitle}\n`;
71
+ const document = ensureOrderedMarkers(existing, presentation, requireValue);
72
+ fs.writeFileSync(technicalSpecPath, replaceBlock(document, technicalMarker, technicalBlock));
73
+
74
+ if (technical.readmeMode === "media-only") {
75
+ return { blockLines: [imageLine], technicalSpecPath: technical.technicalSpecPath };
76
+ }
77
+ const readmePath = inside(repository, scenario.publication.readmePath, "README path");
78
+ const technicalLink = path.relative(path.dirname(readmePath), technicalSpecPath).split(path.sep).join("/");
79
+ return {
80
+ blockLines: [`## ${proof.question}`, "", proof.summary, "", imageLine, "", `[Technical specification and evidence](${technicalLink})`, ...(proof.transitionAfter ? ["", proof.transitionAfter] : [])],
81
+ technicalSpecPath: technical.technicalSpecPath,
82
+ };
83
+ }