@rightkit/release 0.6.12 → 0.6.14

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 (109) hide show
  1. package/README.md +49 -1
  2. package/adapter-env.mjs +165 -0
  3. package/android-ndk.mjs +5 -1
  4. package/android-release.mjs +73 -8
  5. package/app-check.mjs +22 -8
  6. package/app-closure.mjs +214 -0
  7. package/assemble-app-command.mjs +24 -7
  8. package/atomic-write.mjs +57 -0
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +1190 -335
  11. package/broker-client.mjs +106 -0
  12. package/broker-exception.mjs +211 -0
  13. package/broker-handshake.mjs +160 -0
  14. package/broker-pins.mjs +27 -8
  15. package/broker-wire.mjs +262 -0
  16. package/build-launch.mjs +364 -12
  17. package/build-ownership.mjs +117 -49
  18. package/build-release.mjs +3 -2
  19. package/cache-command.mjs +7 -2
  20. package/candidate-build-receipt.mjs +96 -0
  21. package/candidate-receipt-command.mjs +29 -0
  22. package/cargo-contract.mjs +4 -3
  23. package/cargo-ledger.mjs +1 -1
  24. package/cargo-license-notices.mjs +2 -2
  25. package/cargo-shapes.mjs +332 -0
  26. package/cargo-target-shim.mjs +44 -5
  27. package/cargo-target.mjs +5 -0
  28. package/carried-adapter-run.mjs +31 -0
  29. package/carried-cargo-run.mjs +9 -0
  30. package/carried-cargo.mjs +237 -0
  31. package/carrier-census.mjs +124 -0
  32. package/class-a-launch-guard.mjs +266 -33
  33. package/cli/right-release.mjs +2 -0
  34. package/clippy-route.mjs +23 -11
  35. package/compile-inputs.mjs +9 -12
  36. package/control-plane.mjs +18 -0
  37. package/deps-cargo-lock.mjs +208 -0
  38. package/deps.mjs +5 -0
  39. package/deterministic-scans.mjs +273 -44
  40. package/development-signing.mjs +14 -1
  41. package/development.mjs +109 -24
  42. package/direct-origin.mjs +107 -0
  43. package/disk-command.mjs +1 -0
  44. package/disk-policy.mjs +18 -3
  45. package/freshness-census.mjs +206 -58
  46. package/gate-executor.mjs +30 -12
  47. package/gate-stages.mjs +93 -16
  48. package/github-release.mjs +2 -1
  49. package/github-upload-gate.mjs +2 -1
  50. package/install-tauri-authenticode.mjs +346 -0
  51. package/ios-dev.mjs +77 -23
  52. package/ios-package-recipe.mjs +100 -41
  53. package/linux-artifacts.mjs +17 -2
  54. package/linux-qualifier.mjs +83 -0
  55. package/local-publish.mjs +36 -14
  56. package/local-runtime.mjs +2 -2
  57. package/lock-identity.mjs +138 -0
  58. package/lsclean.sh +0 -0
  59. package/managed-cargo.mjs +151 -44
  60. package/managed-roots.mjs +130 -12
  61. package/media-sidecar-catalog.mjs +1 -1
  62. package/native-adapters.mjs +411 -0
  63. package/native-helpers.mjs +26 -18
  64. package/native-tool-planning.mjs +94 -0
  65. package/ownership-mark.mjs +182 -0
  66. package/pack-digest.mjs +122 -0
  67. package/package.json +12 -12
  68. package/platform-dir.mjs +14 -0
  69. package/pnpm-adapter-grammar.mjs +226 -0
  70. package/progress-control.mjs +7 -2
  71. package/provenance-real-receipt.pins.json +7 -0
  72. package/provenance-verify.mjs +262 -1
  73. package/publication-outcomes.mjs +21 -0
  74. package/publish-update.mjs +2 -1
  75. package/qa-journeys-prebuilt-run.mjs +18 -0
  76. package/qa-journeys-prebuilt.mjs +148 -0
  77. package/qa-snapshot-run.mjs +53 -0
  78. package/qa-snapshot.mjs +164 -0
  79. package/qualification-verify.mjs +144 -0
  80. package/qualify-reuse.mjs +38 -0
  81. package/recipe-build.mjs +16 -3
  82. package/release-qa-audit.mjs +2 -2
  83. package/release-qa-command.mjs +2 -1
  84. package/release-qa-receipt.mjs +3 -2
  85. package/release-state.mjs +3 -2
  86. package/release.mjs +14 -3
  87. package/rightkit-drift.mjs +27 -5
  88. package/rightkit-versions.json +15 -10
  89. package/rustright-names.mjs +32 -0
  90. package/scan-tool-enrolment.mjs +167 -0
  91. package/sign-windows.mjs +212 -112
  92. package/signing-files.mjs +35 -0
  93. package/signing-identity.mjs +7 -0
  94. package/snapshot-broker.mjs +528 -57
  95. package/snapshot-check.mjs +95 -11
  96. package/snapshot-metadata.mjs +123 -0
  97. package/snapshot-request.mjs +389 -96
  98. package/stage-binary.mjs +22 -4
  99. package/stage-commands.mjs +56 -13
  100. package/stage-sidecar.mjs +93 -21
  101. package/suite-signing-policy.mjs +657 -0
  102. package/suite-signing.mjs +223 -0
  103. package/swift-scratch.mjs +25 -0
  104. package/tauri-locked.mjs +25 -0
  105. package/test-command.mjs +164 -29
  106. package/unit-gate.mjs +201 -31
  107. package/upload-large.mjs +0 -0
  108. package/web-build.mjs +2 -2
  109. package/win-package-recipe.mjs +26 -2
@@ -2,9 +2,9 @@
2
2
  // rustright-cli shims.rs `snapshot_carrier`, the verifier rustright-protocol attestation.rs `verify_receipt`).
3
3
  //
4
4
  // For each `rustright cargo` request that builds the commit snapshot on a broker host the gate:
5
- // 1. picks a fresh nonce (RIGHTKIT_ATTEST_NONCE: an attested request; the carrier must carry the same nonce);
6
- // 2. writes the carrier `rightkit.snapshot-request/1` as an owner-only file under `<control root>/snapshot-requests/` (the
7
- // directory the client accepts, never a link) and points RIGHTKIT_SNAPSHOT_REQUEST at it as `{"path","sha256"}` (at most
5
+ // 1. picks a fresh nonce (RUSTRIGHT_ATTEST_NONCE: an attested request; the carrier must carry the same nonce);
6
+ // 2. writes the carrier `rustright.snapshot-request/1` as an owner-only file under `<control root>/snapshot-requests/` (the
7
+ // directory the client accepts, never a link) and points RUSTRIGHT_SNAPSHOT_REQUEST at it as `{"path","sha256"}` (at most
8
8
  // 4096 bytes; the file at most 4 MiB). The carrier binds the commit and tree, the snapshot and its expected tree, the
9
9
  // declared inputs, the build-ownership handle, the cargo working directory (primaryCwd) and sha256 of the canonical cargo
10
10
  // argv (without `cargo`), so it cannot be replayed onto another request;
@@ -13,16 +13,24 @@
13
13
  // snapshot, declaredInputs and ownership), and the signed snapshotEvidence: the receipt's copies equal the signed ones,
14
14
  // the commit and handle are the carrier's, and the snapshot held the expected tree at START and at exit.
15
15
  // The carrier is set only on that one cargo invocation and is removed after it; nothing the gate exports carries it.
16
+ // A pnpm, node or QA step of a snapshot stage is carried the same way as an ADAPTER request (`rustright run <adapter> -- <args>`,
17
+ // rustright#190: snapshot-adapter-v1): the same carrier, the adapter's name in the request core, and a signed `nestedChildren` list (in snapshotEvidence, mirrored on the receipt) of the
18
+ // compiles that job's own children made (the Tauri CLI's Cargo calls), which the gate verifies as it does the call (rightkit#1301).
16
19
 
17
20
  import crypto from "node:crypto";
18
21
  import fs from "node:fs";
19
22
  import path from "node:path";
20
- import { canonicalJson, ProvenanceError, verifyProvenance } from "./provenance-verify.mjs";
23
+ import { REQUEST_OVER_CAP_CODE, requestOverCap } from "./broker-wire.mjs";
24
+ import { ownerFileOf, writeCarrierOwner } from "./carrier-census.mjs";
25
+ import { BROKER_COMPILE_COMMANDS, CARGO_COMMAND_ALIASES, cargoCommandIndex, cargoSubcommandOf, expandCargoAliases, isCompileCommand, isPackageObservation } from "./cargo-shapes.mjs";
26
+ import { canonicalJson, CAUSE_EVIDENCE_SCHEMA, CAUSE_UNEXPLAINED, CAUSE_UNKNOWN, ProvenanceError, requireExplainedCauses, verifyBr19Evidence, verifyCauseEvidence, verifyProvenance, verifyReceiptCoverage, verifyResultEvidence } from "./provenance-verify.mjs";
21
27
 
22
- export const SNAPSHOT_REQUEST_SCHEMA = "rightkit.snapshot-request/1";
23
- export const SNAPSHOT_REQUEST_ENV = "RIGHTKIT_SNAPSHOT_REQUEST";
24
- export const ATTEST_NONCE_ENV = "RIGHTKIT_ATTEST_NONCE";
25
- export const REQUEST_ID_FILE_ENV = "RIGHTKIT_REQUEST_ID_FILE";
28
+ export const SNAPSHOT_REQUEST_SCHEMA = "rustright.snapshot-request/1";
29
+ // One spelling per variable, the current one (rustright-cli shims.rs reads RUSTRIGHT_* first, then the legacy RIGHTKIT_* spelling, which task
30
+ // L-1 removes): the gate never sets both.
31
+ export const SNAPSHOT_REQUEST_ENV = "RUSTRIGHT_SNAPSHOT_REQUEST";
32
+ export const ATTEST_NONCE_ENV = "RUSTRIGHT_ATTEST_NONCE";
33
+ export const REQUEST_ID_FILE_ENV = "RUSTRIGHT_REQUEST_ID_FILE";
26
34
  export const REQUEST_REF_SCHEMA = "rustright.request-ref.v1";
27
35
  export const DESCRIPTOR_MAX_BYTES = 4096;
28
36
  export const CARRIER_MAX_BYTES = 4 * 1024 * 1024;
@@ -37,32 +45,124 @@ export const MANAGED_RUST_ENVIRONMENT_KEYS = Object.freeze([
37
45
  export const SNAPSHOT_EVIDENCE_FIELDS = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "ownership", "outputDir"]);
38
46
  /** The stage evidence the broker signs in payload.stageEvidence and copies onto the receipt (attestation.rs STAGE_EVIDENCE_FIELDS). */
39
47
  export const STAGE_EVIDENCE_FIELDS = Object.freeze(["freshness", "attribution", "toolchain"]);
40
- const SIGNED_TOP_LEVEL_FIELDS = Object.freeze(["verdict", "refreshed", "exit", "signal", "timingsMs", "diagnosticsPath"]);
48
+ /** The result fields the broker signs in payload.resultEvidence and mirrors on the receipt (attestation.rs RESULT_EVIDENCE_FIELDS). */
49
+ export const RESULT_EVIDENCE_FIELDS = Object.freeze(["exit", "signal", "timingsMs", "diagnosticsPath"]);
41
50
  const TIMING_KEYS =Object.freeze(["admission", "queue", "bind", "cargo", "afterExitBind", "diagnostics"]);
51
+ /** The broker's code for a compile whose toolchain identity cannot be built (checked_toolchain_identity), and the gate's typed error code for it. */
52
+ export const TOOLCHAIN_UNAVAILABLE_CODE = "RK_TOOLCHAIN_IDENTITY_UNAVAILABLE";
53
+ export const TOOLCHAIN_UNAVAILABLE = "toolchain-unavailable";
54
+ /** `{"state":"UNKNOWN","reason":<non-empty string>}` exactly: the one spelling of an identity the broker could not establish (BR19). */
55
+ export const isToolchainUnknown = (v) => v !== null && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 2 && v.state === "UNKNOWN" && typeof v.reason === "string" && v.reason !== "";
42
56
  /** `<toolchain>|cargo:<sha256>|rustc:<sha256>` (rustright toolchain_identity). */
43
57
  const TOOLCHAIN_FORMAT = /^[^|\s][^|]*\|cargo:[0-9a-f]{64}\|rustc:[0-9a-f]{64}$/;
44
58
 
45
- /** The compile-class cargo commands (and their aliases): the ones whose receipt carries attribution and toolchain. */
46
- export const COMPILE_CLASS_COMMANDS = Object.freeze(["check", "clippy", "build", "doc", "rustc", "test", "b", "c", "t", "d"]);
47
- const GLOBAL_VALUE_OPTIONS = new Set(["--color", "--explain"]);
48
-
49
- /** The cargo subcommand of an argv (without `cargo`): after an optional `+toolchain` and the global options, before `--`; null when there is none. */
50
- export function cargoSubcommandOf(argv) {
51
- const words = [...argv];
52
- for (let i = words[0]?.startsWith("+") ? 1 : 0; i < words.length; i += 1) {
53
- const w = words[i];
54
- if (w === "--") return null;
55
- if (GLOBAL_VALUE_OPTIONS.has(w)) { i += 1; continue; }
59
+ // GB-01/GB-02: the command-shape table is cargo-shapes.mjs (one versioned table, exercised against RustRight's cargo-shapes vector); these names
60
+ // are its views for the carried call. `isCompileClass` decides which evidence a receipt must carry from the broker's own compile rule (the `compile`
61
+ // column of the vector), so bench, run, rustdoc and a compiling `package` owe it too and a read-only metadata or `package --list` legitimately carries none.
62
+ export { BROKER_COMPILE_COMMANDS, CARGO_COMMAND_ALIASES, cargoSubcommandOf, isPackageObservation };
63
+ /** Whether the broker classifies this argv (without the tool name) as a compile. `package` is one unless it is the observation listing. */
64
+ export const isCompileClass = (argv) => isCompileCommand(argv);
65
+ /** The commands that build or run, and so can rewrite Cargo.lock inside a snapshot unless they are locked (rightkit#1301): the same table as `isCompileClass`. */
66
+ export const isLockBound = (argv) => isCompileClass(argv);
67
+
68
+ /**
69
+ * The argv a carried call sends: a build or run command with neither `--locked` nor `--frozen` (before any `--`) gets `--locked` right
70
+ * after its subcommand. RustRight refuses an unlocked grant-minted build, and an unlocked build could rewrite Cargo.lock in the snapshot.
71
+ * Adding it, not refusing, is the less surprising rule: the call runs as the caller meant it, except that a stale lock fails instead of
72
+ * being rewritten. The ledger and the signed request digest both carry the argv as sent.
73
+ */
74
+ /** cargo-ndk's own options that take a value (outer grammar): `cargo ndk [-t <abi>] [-p|--platform <api>] [-o <dir>] ... <cargo subcommand> [cargo args]`. */
75
+ const NDK_VALUE_OPTIONS = new Set(["-t", "--target", "-o", "--output-dir", "-p", "--platform"]);
76
+ const NDK_INNER_LOCKED = new Set(["build", "b", "check", "c", "test", "t", "bench", "clippy", "doc", "rustc", "rustdoc", "run", "r"]);
77
+
78
+ /**
79
+ * GB-62: `cargo ndk <outer options> build <cargo args>` is two grammars. The immutable-dependency flag belongs to the INNER Cargo command, so it is
80
+ * inserted right after that inner subcommand (never after `ndk`, where cargo-ndk would read it as its own option, and never before it). Returns the argv
81
+ * unchanged when the inner command is not a locked-class command, already carries `--locked`/`--frozen` before `--`, or cannot be found.
82
+ */
83
+ function lockedNdkArgv(words) {
84
+ const end = words.indexOf("--");
85
+ const own = end < 0 ? words : words.slice(0, end);
86
+ let i = words.findIndex((w) => w === "ndk");
87
+ if (i < 0) return words;
88
+ for (i += 1; i < own.length; i += 1) {
89
+ const w = own[i];
90
+ if (NDK_VALUE_OPTIONS.has(w)) { i += 1; continue; }
56
91
  if (w.startsWith("-")) continue;
57
- return w;
92
+ break;
93
+ }
94
+ if (i >= own.length || !NDK_INNER_LOCKED.has(own[i])) return words;
95
+ if (own.slice(i + 1).some((w) => w === "--locked" || w === "--frozen")) return words;
96
+ return [...words.slice(0, i + 1), "--locked", ...words.slice(i + 1)];
97
+ }
98
+
99
+ export function lockedArgv(argv) {
100
+ // GB-01: the standard aliases are expanded BEFORE locking and hashing, so what the gate sends, signs and digests is what the broker classifies
101
+ // (a committed `.cargo/config.toml` [alias] can never redefine it). Only the subcommand token changes.
102
+ const words = expandCargoAliases(argv);
103
+ if (cargoSubcommandOf(words) === "ndk") return lockedNdkArgv(words);
104
+ if (!isLockBound(words)) return words;
105
+ const end = words.indexOf("--");
106
+ if ((end < 0 ? words : words.slice(0, end)).some((w) => w === "--locked" || w === "--frozen")) return words;
107
+ const i = cargoCommandIndex(words);
108
+ if (i === null) return words;
109
+ return [...words.slice(0, i + 1), "--locked", ...words.slice(i + 1)];
110
+ }
111
+
112
+ const CARRIED_REDIRECT = /^(--config|-Z|-C)(=|$)|^-[A-Za-z]*[CZ]/;
113
+
114
+ /**
115
+ * The one place that turns a caller's cargo arguments (without `cargo`) into the arguments a carried call sends (carried-cargo.mjs
116
+ * `runCarriedCargo`; stage-commands.mjs derives what a stage's signed receipts must show from the same function, so the two cannot drift):
117
+ * `--manifest-path <p>` or `--manifest-path=<p>` before any `--` is removed (the client refuses to rewrite an attested call; the call
118
+ * runs from the manifest's directory instead, the last one wins) and returned as `manifestPath`; `--target-dir` and the redirecting
119
+ * options are refused. Returns `{ words, manifestPath }` (`manifestPath` null when none) or `{ refusal: { code, message } }`.
120
+ * `words` are not yet locked: the signed request carries `lockedArgv(words)` (carryBrokerCargo).
121
+ */
122
+ export function splitCarriedArgs(args) {
123
+ const original = [...args];
124
+ const words = [];
125
+ let manifestPath = null;
126
+ let atSeparator = false;
127
+ for (let i = 0; i < original.length; i += 1) {
128
+ const w = original[i];
129
+ if (atSeparator) { words.push(w); continue; }
130
+ if (w === "--") { atSeparator = true; words.push(w); continue; }
131
+ if (w === "--manifest-path" || w.startsWith("--manifest-path=")) {
132
+ const value = w === "--manifest-path" ? original[i + 1] : w.slice("--manifest-path=".length);
133
+ if (w === "--manifest-path") i += 1;
134
+ if (typeof value !== "string" || !value) return { refusal: { code: "RK_CARGO_BROKER_MANIFEST_PATH", message: "--manifest-path names no manifest" } };
135
+ manifestPath = value;
136
+ continue;
137
+ }
138
+ if (w === "--target-dir" || w.startsWith("--target-dir=")) return { refusal: { code: "RK_CARGO_REDIRECT", message: `\`cargo ${original.join(" ")}\` passes --target-dir; the broker pins a carried call's output directory` } };
139
+ if (CARRIED_REDIRECT.test(w)) return { refusal: { code: "RK_CARGO_REDIRECT", message: `\`cargo ${original.join(" ")}\` passes ${w}, which moves its working directory, configuration or output` } };
140
+ words.push(w);
58
141
  }
59
- return null;
142
+ return { words, manifestPath };
143
+ }
144
+
145
+ /**
146
+ * What a carried call sends the broker for `args` run from `cwd` (a posix path relative to the workspace root, "." for the root): the
147
+ * argv as signed (`requestedArgv`: no --manifest-path, `--locked` added to a build or run) and the directory it runs in (`cwd` joined
148
+ * with the manifest's directory). Returns `{ argv, cwd }` or null when the call would be refused.
149
+ */
150
+ export function carriedRequestOf(args, cwd = ".") {
151
+ const split = splitCarriedArgs(args);
152
+ if (split.refusal) return null;
153
+ const dir = split.manifestPath === null ? cwd : path.posix.normalize(path.posix.join(cwd, path.posix.dirname(split.manifestPath.replaceAll("\\", "/"))));
154
+ return { argv: lockedArgv(split.words), cwd: dir === "" ? "." : dir };
60
155
  }
61
- export const isCompileClass = (argv) => COMPILE_CLASS_COMMANDS.includes(cargoSubcommandOf(argv));
62
156
 
63
- /** Where the gate's declared compile inputs are listed, as read from the snapshot (GATE-CONTRACT "Request"; the broker's COMPILE_INPUTS). */
64
- export const COMPILE_INPUTS = ".rightkit/compile-inputs.json";
65
- export const MAX_DECLARATION_BYTES = 1024 * 1024;
157
+ /**
158
+ * Where the declared compile inputs are listed, as read from the snapshot (the broker's COMPILE_INPUTS; GB-49): `.rustright/compile-inputs.json` first,
159
+ * `.rightkit/compile-inputs.json` ONLY when the canonical file is absent. A present but invalid canonical declaration is an error, never a fallback.
160
+ */
161
+ export const COMPILE_INPUTS_CANONICAL = ".rustright/compile-inputs.json";
162
+ export const COMPILE_INPUTS_LEGACY = ".rightkit/compile-inputs.json";
163
+ export const COMPILE_INPUTS = COMPILE_INPUTS_CANONICAL;
164
+ export const COMPILE_INPUT_DECLARATIONS = Object.freeze([COMPILE_INPUTS_CANONICAL, COMPILE_INPUTS_LEGACY]);
165
+ export const MAX_DECLARATION_BYTES = 4 * 1024 * 1024;
66
166
  const MAX_INPUT_FILES = 200_000;
67
167
  const MAX_INPUT_BYTES = 4 * 1024 * 1024 * 1024;
68
168
 
@@ -98,14 +198,26 @@ export function observedEnvOf(env, platform = process.platform) {
98
198
  return out;
99
199
  }
100
200
 
201
+ /**
202
+ * The next per-call lease handle of a build ownership: `<ownership-handle>#<uuid>`. A workspace index or a per-process counter is not unique
203
+ * (metadata, check and test reuse a workspace; wrapper children are separate processes), so each invocation draws its own random UUID. The broker accepts one
204
+ * completion per handle on a snapshot, and its status for the bare ownership handle covers every `#` sub-handle.
205
+ */
206
+ export const nextCallHandle = (handle) => `${handle}#${crypto.randomUUID()}`;
207
+ /** Whether `callHandle` is exactly one sub-handle of the ownership `handle` (`<handle>#<token>`, never the bare handle or another's). */
208
+ export const isCallHandleOf = (callHandle, handle) => typeof callHandle === "string" && typeof handle === "string" && handle !== "" && callHandle.length > handle.length + 1 && callHandle.startsWith(`${handle}#`) && /^[A-Za-z0-9._-]+$/.test(callHandle.slice(handle.length + 1));
209
+ /** The ownership handle a signed (sub-)handle belongs to: everything before the first `#`. */
210
+ export const ownershipOfHandle = (handle) => (typeof handle === "string" ? handle.split("#")[0] : handle);
211
+
101
212
  /**
102
213
  * Writes the carrier for one cargo request and returns `{ file, sha256, descriptor, carrier, env }`; `env` is what that one
103
214
  * invocation adds (carrier descriptor, nonce, request-id file). Refuses before writing anything the reader would refuse.
104
215
  */
105
- export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredInputs = [], handle, primaryCwd, argv, nonce = freshNonce(), requestIdFile, uuid = crypto.randomUUID }) {
216
+ export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredInputs = [], handle, callHandle, primaryCwd, argv, nonce = freshNonce(), requestIdFile, uuid = crypto.randomUUID }) {
106
217
  if (!objectId(commit?.sha) || !objectId(commit?.tree)) fail("invalid", "commit sha and tree must be full lowercase object ids");
107
218
  if (!snapshot?.path || !path.isAbsolute(snapshot.path) || snapshot.expectedTree !== commit.tree) fail("invalid", "the snapshot needs an absolute path and the commit's tree as expectedTree");
108
219
  if (typeof handle !== "string" || !handle.trim()) fail("invalid", "the build-ownership handle is missing");
220
+ if (callHandle !== undefined && !isCallHandleOf(callHandle, handle)) fail("invalid", `the call handle ${JSON.stringify(callHandle)} is not a sub-handle (<ownership-handle>#<n>) of the build ownership`);
109
221
  if (!primaryCwd || !path.isAbsolute(primaryCwd)) fail("invalid", "primaryCwd must be the cargo request's absolute working directory");
110
222
  if (!Array.isArray(argv) || !argv.every((a) => typeof a === "string")) fail("invalid", "argv must be the cargo arguments (without cargo)");
111
223
  if (typeof nonce !== "string" || !/^[0-9a-f]{64,128}$/.test(nonce)) fail("invalid", "the nonce must be 64 to 128 lowercase hex characters");
@@ -120,32 +232,48 @@ export function writeSnapshotRequest({ controlRoot, commit, snapshot, declaredIn
120
232
  commit: { sha: commit.sha, tree: commit.tree },
121
233
  snapshot: { path: snapshot.path, expectedTree: snapshot.expectedTree },
122
234
  declaredInputs: { inputs: [...declaredInputs] },
123
- ownership: { handle },
235
+ // One completion per (snapshot, lease handle) at the broker (rustright snapshot.rs:330-343): each carried request names its own
236
+ // sub-handle `<ownership-handle>#<n>`, which the broker's bare-handle lease status aggregates (snapshot.rs:469).
237
+ ownership: { handle: callHandle ?? nextCallHandle(handle) },
124
238
  primaryCwd,
125
239
  argvSha256: argvSha256(argv),
126
240
  nonce,
127
241
  };
128
242
  const bytes = Buffer.from(JSON.stringify(carrier), "utf8");
129
243
  if (bytes.length > CARRIER_MAX_BYTES) fail("too-large", `the snapshot request is ${bytes.length} bytes, more than ${CARRIER_MAX_BYTES}`);
244
+ // GB-53: the request line is 1 MiB, newline included, one unit with the response limit. The declared inputs ride in it with the envelope: a request that cannot fit is
245
+ // refused here, before the carrier is written, before it counts as sent, so no job exists.
246
+ const overCap = requestOverCap(Buffer.byteLength(JSON.stringify(carrier.declaredInputs)) + Buffer.byteLength(JSON.stringify({ commit: carrier.commit, snapshot: carrier.snapshot, ownership: carrier.ownership, primaryCwd, argv })));
247
+ if (overCap) fail(REQUEST_OVER_CAP_CODE, overCap);
130
248
  const file = path.join(dir, `${uuid()}.json`);
131
- fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 });
132
249
  const digest = sha256(bytes);
133
250
  const descriptor = JSON.stringify({ path: file, sha256: digest });
134
- if (Buffer.byteLength(descriptor) > DESCRIPTOR_MAX_BYTES) { fs.rmSync(file, { force: true }); fail("too-large", "the snapshot request descriptor exceeds 4096 bytes"); }
251
+ if (Buffer.byteLength(descriptor) > DESCRIPTOR_MAX_BYTES) fail("too-large", "the snapshot request descriptor exceeds 4096 bytes");
252
+ // GB-76 (BROKER-CONTRACT section 16): the creator is recorded FIRST, in `<carrier>.owner.json` (the gate's identity as RustRight reads it, the call's nonce and sub-handle, the creation time), then
253
+ // the carrier; both are removed in the call's `finally`. A broker sweep retires a leftover only on proof that the gate is gone and nothing is held under the handle, never on age alone. A
254
+ // record that cannot be written leaves no carrier.
255
+ try { writeCarrierOwner({ file, handle: carrier.ownership.handle, nonce }); } catch (e) { if (e.code !== "EEXIST") fs.rmSync(ownerFileOf(file), { force: true }); fail("invalid", `the carrier's owner record cannot be written (${e.code ?? e.message}), so no carrier is left behind`); }
256
+ try { fs.writeFileSync(file, bytes, { flag: "wx", mode: 0o600 }); } catch (e) { fs.rmSync(ownerFileOf(file), { force: true }); throw e; }
135
257
  const env = { [SNAPSHOT_REQUEST_ENV]: descriptor, [ATTEST_NONCE_ENV]: nonce, ...(requestIdFile ? { [REQUEST_ID_FILE_ENV]: requestIdFile } : {}) };
136
258
  return { file, sha256: digest, descriptor, carrier, env };
137
259
  }
138
260
 
139
- /** The request core the broker digests for a snapshot cargo request: the base core plus commit, snapshot, declaredInputs and ownership. */
140
- export function snapshotRequestCore({ argv, workspace, observedEnv = {}, carrier }) {
261
+ /**
262
+ * The request core the broker digests for a snapshot request: the base core plus commit, snapshot, declaredInputs and ownership. A cargo
263
+ * request has `shim: "cargo"` and no adapter; a carried adapter request (rustright#190 D2) has `shim: "adapter"`, the registered adapter's
264
+ * name, no observed environment (the client captures none for an adapter), and `env`: the request's own environment object, exactly the
265
+ * variables RUSTRIGHT_ADAPTER_ENV names (rustright-protocol attestation.rs request_core `environment`; adapter-env.mjs), always empty for Cargo.
266
+ * The adapter spelling is RustRight's; see snapshot-broker.mjs carryBrokerAdapter.
267
+ */
268
+ export function snapshotRequestCore({ argv, workspace, observedEnv = {}, carrier, adapter = null, adapterEnv = {} }) {
141
269
  return {
142
- adapter: null, env: {}, inputManifest: null, nonce: carrier.nonce, observedEnv, outputs: [], requestedArgv: [...argv], shim: "cargo", workspace,
270
+ adapter, env: adapter === null ? {} : { ...adapterEnv }, inputManifest: null, nonce: carrier.nonce, observedEnv: adapter === null ? observedEnv : {}, outputs: [], requestedArgv: [...argv], shim: adapter === null ? "cargo" : "adapter", workspace,
143
271
  commit: carrier.commit, snapshot: carrier.snapshot, declaredInputs: carrier.declaredInputs, ownership: carrier.ownership,
144
272
  };
145
273
  }
146
274
  export const requestDigestOf = (core) => sha256(canonicalJson(core));
147
275
 
148
- /** The request reference the client writes at submit (RIGHTKIT_REQUEST_ID_FILE), or a throw. */
276
+ /** The request reference the client writes at submit (RUSTRIGHT_REQUEST_ID_FILE), or a throw. */
149
277
  export function readRequestRef(file) {
150
278
  let ref;
151
279
  try { ref = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { fail("request", `no request reference at ${file} (${e.message})`); }
@@ -160,49 +288,69 @@ const same = (a, b) => canonicalJson(a ?? null) === canonicalJson(b ?? null);
160
288
  const lstatOrNull = (p) => { try { return fs.lstatSync(p); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
161
289
 
162
290
  /**
163
- * The declared inputs of a snapshot: `inputs` of `.rightkit/compile-inputs.json` read FROM THE SNAPSHOT, never the checkout. No
164
- * manifest means an empty set; a link on its path, a file that is not regular, an oversized or malformed manifest, or an input that
165
- * is not a plain relative path (absolute, `.`/`..`, inside `.git`, the manifest itself) is refused before any request is made.
291
+ * The declared inputs of a snapshot, read FROM THE SNAPSHOT, never the checkout, by the one rule every RightKit reader shares (the broker's, vector
292
+ * docs/contracts/declared-inputs.fixtures.json): `.rustright/compile-inputs.json` when it exists, else `.rightkit/compile-inputs.json`, else none. Either file
293
+ * is judged by the same checks: no link on its path, a regular file of at most 4 MiB, JSON with `inputs` a list of plain relative paths (not absolute, no `.`,
294
+ * `..` or empty component, not inside `.git`) and no input that is, or holds, either declaration file. A present but invalid canonical declaration is an
295
+ * error: it is never read as the legacy one.
166
296
  */
167
297
  export function readDeclaredInputs(snapshotRoot) {
168
- const parts = COMPILE_INPUTS.split("/");
169
- let at = snapshotRoot; let meta = null;
170
- for (const part of parts) {
171
- at = path.join(at, part);
172
- meta = lstatOrNull(at);
173
- if (meta === null) return [];
174
- if (meta.isSymbolicLink()) fail("declared-inputs", `${COMPILE_INPUTS} runs through a link at ${at}`);
298
+ let file = null; let at = null; let meta = null;
299
+ for (const candidate of COMPILE_INPUT_DECLARATIONS) {
300
+ let here = snapshotRoot; let found = true;
301
+ for (const part of candidate.split("/")) {
302
+ here = path.join(here, part);
303
+ const m = lstatOrNull(here);
304
+ if (m === null) { found = false; break; }
305
+ if (m.isSymbolicLink()) fail("declared-inputs", `${candidate} runs through a link at ${here}`);
306
+ meta = m;
307
+ }
308
+ if (found) { file = candidate; at = here; break; }
175
309
  }
176
- if (!meta.isFile()) fail("declared-inputs", `${COMPILE_INPUTS} is not a regular file`);
177
- if (meta.size > MAX_DECLARATION_BYTES) fail("declared-inputs", `${COMPILE_INPUTS} is larger than ${MAX_DECLARATION_BYTES} bytes`);
310
+ if (file === null) return [];
311
+ if (!meta.isFile()) fail("declared-inputs", `${file} is not a regular file`);
312
+ if (meta.size > MAX_DECLARATION_BYTES) fail("declared-inputs", `${file} is larger than ${MAX_DECLARATION_BYTES} bytes`);
178
313
  let value;
179
- try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${COMPILE_INPUTS} is not JSON (${e.message})`); }
180
- if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${COMPILE_INPUTS}: \`inputs\` must be a list of paths`);
314
+ try { value = JSON.parse(fs.readFileSync(at, "utf8")); } catch (e) { fail("declared-inputs", `${file} is not JSON (${e.message})`); }
315
+ if (!value || typeof value !== "object" || !Array.isArray(value.inputs)) fail("declared-inputs", `${file}: \`inputs\` must be a list of paths`);
181
316
  for (const input of value.inputs) {
182
- if (typeof input !== "string" || !input) fail("declared-inputs", `${COMPILE_INPUTS}: every input is a non-empty relative path`);
317
+ if (typeof input !== "string" || !input) fail("declared-inputs", `${file}: every input is a non-empty relative path`);
183
318
  const components = input.split(/[\\/]/);
184
- if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} is not a plain relative path inside the snapshot`);
185
- if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} is inside .git; repository metadata is never a compile input`);
186
- if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${COMPILE_INPUTS}: ${input} overlaps the declaration file itself`);
319
+ if (path.isAbsolute(input) || path.posix.isAbsolute(input) || path.win32.isAbsolute(input) || components.some((c) => c === "" || c === "." || c === "..")) fail("declared-inputs", `${file}: ${input} is not a plain relative path inside the snapshot`);
320
+ if (components.some((c) => c.toLowerCase() === ".git")) fail("declared-inputs", `${file}: ${input} is inside .git; repository metadata is never a compile input`);
321
+ for (const declaration of COMPILE_INPUT_DECLARATIONS) {
322
+ const parts = declaration.split("/");
323
+ if (components.length <= parts.length && components.every((c, i) => c === parts[i])) fail("declared-inputs", `${file}: ${input} overlaps the declaration file itself (${declaration})`);
324
+ }
187
325
  }
188
326
  return [...value.inputs];
189
327
  }
190
328
 
329
+ /** The inputs a request supplied must be exactly the declaration the snapshot holds (the broker's check): a throw naming the difference. */
330
+ export function assertSuppliedInputs(snapshotRoot, supplied) {
331
+ const declared = readDeclaredInputs(snapshotRoot);
332
+ if (!same(declared, supplied)) fail("declared-inputs", "the snapshot's declared inputs are not the ones this request carried (declaredInputs differs from the snapshot declaration)");
333
+ return declared;
334
+ }
335
+
191
336
  /**
192
337
  * The `declaredInputDigests` the broker records for these inputs, computed here from the snapshot: per input, sha256 over, for each
193
338
  * regular file in directory order (names sorted by their bytes), the file's relative name, a NUL, its bytes and a NUL. A link
194
- * anywhere, a special file, or a limit breach is a refusal. Child names are joined with the platform's separator, as the broker's
195
- * `Path::join` does.
339
+ * anywhere, a special file, or a limit breach is a refusal. The name is the input's path components joined by `/` on EVERY platform (GB-49: RustRight hashes
340
+ * `/` on Windows too, so a `\` in a declaration or a platform separator never reaches the hash), UTF-8, no Unicode normalisation.
341
+ *
342
+ * `allowAbsent` is set ONLY for a non-compiling snapshot-bound request (the carried read-only set): a declared input that does not exist in the
343
+ * snapshot (ENOENT/ENOTDIR on a component; a link is still refused) is then the entry `{ path, absent: true }` (no `sha256`, 0 files, 0 bytes), the
344
+ * wire form RustRight signs. A compile request never allows it: a missing declared input is a refusal there (the build outputs must exist).
196
345
  */
197
- export function declaredInputDigestsOf(snapshotRoot, inputs, { platform = process.platform } = {}) {
198
- const sep = platform === "win32" ? "\\" : "/";
346
+ export function declaredInputDigestsOf(snapshotRoot, inputs, { allowAbsent = false } = {}) {
199
347
  let files = 0; let bytes = 0;
200
348
  const walk = (full, name, hasher) => {
201
349
  const meta = fs.lstatSync(full);
202
350
  if (meta.isSymbolicLink()) fail("declared-inputs", `declared compile input holds a link at ${name}`);
203
351
  if (meta.isDirectory()) {
204
352
  const children = fs.readdirSync(full).sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")));
205
- for (const child of children) walk(path.join(full, child), name.endsWith("/") || name.endsWith("\\") ? `${name}${child}` : `${name}${sep}${child}`, hasher);
353
+ for (const child of children) walk(path.join(full, child), `${name}/${child}`, hasher);
206
354
  return;
207
355
  }
208
356
  if (!meta.isFile()) fail("declared-inputs", `declared compile input ${name} is not a regular file or directory`);
@@ -230,11 +378,14 @@ export function declaredInputDigestsOf(snapshotRoot, inputs, { platform = proces
230
378
  for (const part of input.split(/[\\/]/)) {
231
379
  at = path.join(at, part);
232
380
  let meta;
233
- try { meta = fs.lstatSync(at); } catch (e) { fail("declared-inputs", `${input}: ${e.message}`); }
381
+ try { meta = fs.lstatSync(at); } catch (e) {
382
+ if (allowAbsent && (e.code === "ENOENT" || e.code === "ENOTDIR")) return { path: input, absent: true };
383
+ fail("declared-inputs", `${input}: ${e.message}`);
384
+ }
234
385
  if (meta.isSymbolicLink()) fail("declared-inputs", `${input} runs through a link at ${at}`);
235
386
  }
236
387
  const hasher = crypto.createHash("sha256");
237
- walk(at, input, hasher);
388
+ walk(at, input.split(/[\\/]/).join("/"), hasher);
238
389
  return { path: input, sha256: hasher.digest("hex") };
239
390
  });
240
391
  }
@@ -248,7 +399,15 @@ const inside = (child, parent, platform) => {
248
399
  };
249
400
 
250
401
  /**
251
- * The generation target the broker pins an app's outputs to: `<root>/<12 hex>/g/<NNN>/target` under one of the managed `ws` roots
402
+ * The leaf names of a managed generation (`g/<gen>/<leaf>`). The leaf is per GENERATION, not per platform (rustright-state lib.rs:52-62 and
403
+ * :422-461): a generation keeps the leaf it was created with, and only new generations default to `t` on Windows and `target` elsewhere, so
404
+ * either may appear on either platform. The gate takes the leaf from the signed outputDir; it never infers it and never picks a directory.
405
+ */
406
+ export const GENERATION_LEAVES = Object.freeze(["target", "t"]);
407
+ const isGenerationLeaf = (segment) => GENERATION_LEAVES.includes(segment);
408
+
409
+ /**
410
+ * The generation target the broker pins an app's outputs to: `<root>/<12 hex>/g/<NNN>/<leaf>` (leaf `target` or `t`, GENERATION_LEAVES) under one of the managed `ws` roots
252
411
  * (`<external data root>/rightkit-build-control/ws`; BROKER-CONTRACT "Source identity", output pinning). The 12-hex key is
253
412
  * rustright-state's workspace_key, which the gate never computes (the snapshot's own key is `snapshots/<sha256(repo path)[:16]>`, a
254
413
  * different thing); it reads the receipt's outputDir and checks only this shape. The generation number (3+ digits) is the broker's.
@@ -258,16 +417,96 @@ export function isGenerationTarget(outputDir, outputRoots, platform = process.pl
258
417
  for (const root of outputRoots) {
259
418
  const rel = mod.relative(platform === "win32" ? mod.resolve(root).toLowerCase() : mod.resolve(root), platform === "win32" ? mod.resolve(outputDir).toLowerCase() : mod.resolve(outputDir));
260
419
  const segments = rel.split(/[\\/]/);
261
- if (segments.length === 4 && segments[0] !== ".." && !mod.isAbsolute(rel) && /^[0-9a-f]{12}$/.test(segments[0]) && segments[1] === "g" && /^[0-9]{3,}$/.test(segments[2]) && segments[3] === "target") return true;
420
+ if (segments.length === 4 && segments[0] !== ".." && !mod.isAbsolute(rel) && /^[0-9a-f]{12}$/.test(segments[0]) && segments[1] === "g" && /^[0-9]{3,}$/.test(segments[2]) && isGenerationLeaf(segments[3])) return true;
262
421
  }
263
422
  return false;
264
423
  }
265
424
 
266
425
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
267
426
 
427
+ /** Whether `dir` is a managed generation target or a directory inside one (a nested compile's own directory under the adapter job's target). */
428
+ export function isInsideGenerationTarget(dir, outputRoots, platform = process.platform) {
429
+ const mod = platform === "win32" ? path.win32 : path;
430
+ for (const root of outputRoots) {
431
+ const rel = mod.relative(platform === "win32" ? mod.resolve(root).toLowerCase() : mod.resolve(root), platform === "win32" ? mod.resolve(dir).toLowerCase() : mod.resolve(dir));
432
+ const segments = rel.split(/[\\/]/);
433
+ if (segments.length >= 4 && segments[0] !== ".." && !mod.isAbsolute(rel) && /^[0-9a-f]{12}$/.test(segments[0]) && segments[1] === "g" && /^[0-9]{3,}$/.test(segments[2]) && isGenerationLeaf(segments[3])) return true;
434
+ }
435
+ return false;
436
+ }
437
+
438
+
268
439
  /**
269
- * Verifies the receipt of one snapshot cargo request. `pins` come from this host's enrollment, never from the receipt.
270
- * Returns `{ payload, evidence }`; throws ProvenanceError (legacy, signature, pins, job, nonce, request) or SnapshotRequestError (evidence).
440
+ * The filesystem-free identity of a signed `snapshotEvidence` (rightkit#1301 B9), shared by the gate's own verification of a receipt
441
+ * (verifySnapshotReceipt) and the hosted admission (qualification-verify.mjs): the commit is `commit` ({ sha, tree }), the ownership
442
+ * handle is `handle` when one is known, the request id is the job's, the snapshot path is `snapshotPath` when one is known (compared with
443
+ * `samePath`, which the gate gives a real-path comparison), and the snapshot held `tree` at START and at exit. Throws a
444
+ * SnapshotRequestError (`evidence`); returns the evidence's `snapshot`.
445
+ */
446
+ export function verifySnapshotEvidenceIdentity(evidence, { commit, tree = commit?.tree, jobId, handle, snapshotPath, samePath = (a, b) => a === b }) {
447
+ if (!evidence || typeof evidence !== "object") fail("evidence", "the signed payload carries no snapshotEvidence: the broker did not build this as a snapshot request");
448
+ if (!same(evidence.commit, commit)) fail("evidence", "the signed commit is not the one this request carried");
449
+ if (handle !== undefined && evidence.ownership?.handle !== handle) fail("evidence", "the signed ownership handle is not this gate's");
450
+ if (typeof evidence.requestId !== "string" || evidence.requestId !== jobId) fail("evidence", "the signed request id is not this job");
451
+ const snap = evidence.snapshot ?? {};
452
+ if (snapshotPath !== undefined && !samePath(snap.path, snapshotPath)) fail("evidence", `the signed snapshot ${snap.path} is not ${snapshotPath}`);
453
+ if (snap.treeAtStart !== tree || snap.treeAtExit !== tree) {
454
+ fail("evidence", `the snapshot was tree ${snap.treeAtStart} at START and ${snap.treeAtExit} at exit, not ${tree} throughout: the build changed a tracked file (or added or removed one) in the commit's snapshot, so the result is discarded; that is the app owner's defect to fix (write generated output to an ignored path or an output directory, or generate before commit)`);
455
+ }
456
+ return snap;
457
+ }
458
+
459
+ export const NESTED_CHILD_FIELDS = Object.freeze(["requestId", "parentRequestId", "shim", "argv", "cwd", "exitCode", "status", "outputDir"]);
460
+ /** The most children a root job's list may carry (rustright#190: the broker's cap; a refused child is never listed). */
461
+ export const NESTED_CHILDREN_CAP = 8192;
462
+
463
+ /**
464
+ * The nested children of a snapshot-bound adapter job (rustright#190, final): the list the broker signs at `payload.snapshotEvidence.nestedChildren`
465
+ * and mirrors at the receipt's `nestedChildren`; BOTH are required and must be equal. Entries are in admission order, each exactly
466
+ * `{ requestId, parentRequestId, shim, argv, cwd, exitCode, status, outputDir }`: `argv` is redacted (cargo only), `exitCode` is an integer or
467
+ * null, `status` is "UNFINISHED" for a child that had not ended when the root's receipt was built, `outputDir` is null for a child that does not
468
+ * compile (`rustc -vV`, `metadata`). A refused child is not listed. Returns the normalised list or throws a SnapshotRequestError (`nested`).
469
+ * Refused: a missing or differing list, more than the cap, a child whose `parentRequestId` is neither the root job nor an earlier listed child, a repeated request id, an
470
+ * UNFINISHED child, a null or non-zero exit code, a cwd outside the snapshot, and a non-null `outputDir` that is not absolute, is inside the
471
+ * snapshot or the checkout, or is not a managed generation target or inside one.
472
+ */
473
+ export function verifyNestedChildren(evidence, receipt, { snapshotPath, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), jobId }) {
474
+ const list = evidence?.nestedChildren;
475
+ if (!Array.isArray(list)) fail("nested", "the signed snapshotEvidence carries no nestedChildren list: the broker did not sign what this adapter job's children ran");
476
+ if (!Array.isArray(receipt?.nestedChildren)) fail("nested", "the receipt carries no nestedChildren list (the mirror of the signed one)");
477
+ if (!same(receipt.nestedChildren, list)) fail("nested", "receipt nestedChildren differs from the signed list");
478
+ if (list.length > NESTED_CHILDREN_CAP) fail("nested", `the nestedChildren list holds ${list.length} entries, more than the cap ${NESTED_CHILDREN_CAP}`);
479
+ const canon = (p) => { try { return canonicalPath(p); } catch { return p; } };
480
+ const ids = new Set();
481
+ return list.map((child, index) => {
482
+ const where = `nestedChildren[${index}]`;
483
+ if (!isObject(child) || Object.keys(child).sort().join() !== [...NESTED_CHILD_FIELDS].sort().join()) fail("nested", `${where} is not exactly { ${NESTED_CHILD_FIELDS.join(", ")} }`);
484
+ if (typeof child.requestId !== "string" || !child.requestId || child.requestId === jobId || ids.has(child.requestId)) fail("nested", `${where} has no request id of its own`);
485
+ const name = `${where} (${child.requestId})`;
486
+ // The parent is the job the request named: this job, or a child listed before it (admission order; depth >= 2, rustright#190 ad9ecb37).
487
+ if (child.parentRequestId !== jobId && !ids.has(child.parentRequestId)) fail("nested", `${name} names parent ${JSON.stringify(child.parentRequestId)}, neither this job ${jobId} nor an earlier listed child`);
488
+ ids.add(child.requestId);
489
+ if (typeof child.shim !== "string" || !child.shim) fail("nested", `${name} names no shim`);
490
+ if (!Array.isArray(child.argv) || !child.argv.every((a) => typeof a === "string")) fail("nested", `${name} argv is not a list of strings`);
491
+ if (child.status === "UNFINISHED") fail("nested", `${name} (${child.argv.join(" ")}) had not finished when the job's receipt was built (UNFINISHED)`);
492
+ if (typeof child.status !== "string" || !child.status) fail("nested", `${name} has no status`);
493
+ if (!Number.isSafeInteger(child.exitCode) || child.exitCode !== 0) fail("nested", `${name} (${child.argv.join(" ")}) exited ${child.exitCode === null ? "with no exit code" : child.exitCode}, not 0: a child of this job failed or never finished`);
494
+ if (typeof child.cwd !== "string" || (!path.isAbsolute(child.cwd) && !path.win32.isAbsolute(child.cwd))) fail("nested", `${name} names no absolute working directory`);
495
+ if (!inside(child.cwd, snapshotPath, platform) && !inside(canon(child.cwd), canon(snapshotPath), platform)) fail("nested", `${name} ran in ${child.cwd}, outside the snapshot ${snapshotPath}`);
496
+ const out = child.outputDir;
497
+ if (out !== null) {
498
+ if (typeof out !== "string" || (!path.isAbsolute(out) && !path.win32.isAbsolute(out))) fail("nested", `${name} names an output directory that is neither null nor absolute`);
499
+ if (inside(out, snapshotPath, platform) || inside(out, canon(snapshotPath), platform)) fail("nested", `${name} output directory ${out} is inside the snapshot`);
500
+ if (inside(out, checkout, platform) || inside(out, canon(checkout), platform)) fail("nested", `${name} output directory ${out} is inside the checkout`);
501
+ if (!isInsideGenerationTarget(out, outputRoots, platform)) fail("nested", `${name} output directory ${out} is not inside a managed generation target under ${outputRoots.join(", ")}`);
502
+ }
503
+ return { requestId: child.requestId, parentRequestId: child.parentRequestId, shim: child.shim, argv: [...child.argv], cwd: child.cwd, exitCode: 0, status: child.status, outputDir: out };
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Verifies the receipt of one snapshot request (a cargo call, or with `adapter` an adapter job). `pins` come from this host's enrollment, never from the receipt.
509
+ * Returns `{ payload, evidence, attribution, toolchain, nestedChildren, requestDigest, causeEvidence }` (`causeEvidence` is null for a command that carries none); throws ProvenanceError (legacy, signature, pins, job, nonce, request, evidence, cause-unknown, cause-unexplained) or SnapshotRequestError (evidence).
271
510
  *
272
511
  * Beyond the signature, the job, the nonce and the request digest, the receipt must carry (item 7 row 7.22):
273
512
  * - signed SUCCEEDED with exit 0, and signed snapshotEvidence equal to the receipt's copies (commit, ownership handle, request id,
@@ -275,42 +514,53 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
275
514
  * - `declaredInputDigests` equal to the gate's own digest of the snapshot's declared inputs, read from the snapshot now;
276
515
  * - `outputDir` absolute, outside the snapshot and the checkout, and a managed generation target under one of `outputRoots`;
277
516
  * - signed stageEvidence (freshness, attribution, toolchain) equal to the receipt's;
278
- * - verdict, timingsMs, exit, diagnosticsPath, brokerVersion, executor and refreshed present with their types.
517
+ * - for a compile, freshness.causeEvidence (rustright.cause-evidence.v1) coherent with its census and with a verdict of `explained`: refused as `evidence` when missing,
518
+ * of another schema or incoherent, `cause-unknown` when the broker could not say why units were rebuilt, `cause-unexplained` when a rebuilt unit has no explained cause (RA-03);
519
+ * - timingsMs, exit, diagnosticsPath, brokerVersion and executor present with their types; the retired `verdict` and `refreshed` are refused (GB-30: Class A and its verdict reuse are the gate's).
279
520
  * `checkout` and `outputRoots` are required: without them nothing can be said about where the outputs went, so it refuses.
280
521
  */
281
- export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), digestInputs = declaredInputDigestsOf, readInputs = readDeclaredInputs }) {
282
- const requestDigest = requestDigestOf(snapshotRequestCore({ argv, workspace, observedEnv, carrier }));
522
+ export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, workspace, observedEnv = {}, adapter = null, adapterEnv = {}, checkout, outputRoots, platform = process.platform, canonicalPath = (p) => fs.realpathSync.native(p), digestInputs = declaredInputDigestsOf, readInputs = readDeclaredInputs }) {
523
+ const requestDigest = requestDigestOf(snapshotRequestCore({ argv, workspace, observedEnv, carrier, adapter, adapterEnv }));
283
524
  const payload = verifyProvenance(receipt, pins, { jobId, nonce: carrier.nonce, requestDigest });
284
525
  // The signed outcome, not the client's exit code: a passing row needs the broker to have signed SUCCEEDED with exit 0 and no
285
526
  // signal (service.rs provenance_payload `result`). An authentic FAILED, or a payload without a result, is not a success.
286
527
  const result = payload.result;
287
528
  if (!result || typeof result !== "object") fail("result", "the signed payload carries no result: the broker did not sign this request's outcome");
288
529
  if (result.status !== "SUCCEEDED" || result.exitCode !== 0 || (result.signal !== undefined && result.signal !== null)) {
289
- fail("result", `the broker signed ${result.status ?? "no status"} (exit ${result.exitCode ?? "none"}${result.signal ? `, signal ${result.signal}` : ""}), not SUCCEEDED with exit 0`);
530
+ // Any terminal status but SUCCEEDED fails the call, whatever the broker says about why (a tracked write is refused at exit with a
531
+ // `snapshot exit bind: ...` message; the field that carries it is pending RustRight's confirmation, so the usual spellings are read).
532
+ // The signed result of a broker verdict after a clean run (FAILED with exit 0: RR_UNEXPLAINED_COLD, ...) carries no reason; the receipt's own
533
+ // unsigned `error` names it. It is shown for diagnosis only and decides nothing: the verdict is the signed status.
534
+ // GB-66: a compile the broker refused to run because it could not name its toolchain fails with this code (and an UNKNOWN toolchain on its receipt).
535
+ if ([result.code, receipt?.code].includes(TOOLCHAIN_UNAVAILABLE_CODE)) fail(TOOLCHAIN_UNAVAILABLE, `the broker did not run this compile: it could not build the toolchain identity (${TOOLCHAIN_UNAVAILABLE_CODE}${isToolchainUnknown(receipt?.toolchain) ? `: ${receipt.toolchain.reason}` : ""}); register the toolchain's cargo and rustc and its name, then ask again`);
536
+ const why = [result.message, result.error, result.reason].find((v) => typeof v === "string" && v !== "");
537
+ const unsigned = why ? "" : [receipt?.error, receipt?.code].find((v) => typeof v === "string" && v !== "");
538
+ fail("result", `the broker signed ${result.status ?? "no status"} (exit ${result.exitCode ?? "none"}${result.signal ? `, signal ${result.signal}` : ""}${why ? `; ${why}` : ""}${unsigned ? `; the receipt's unsigned error: ${unsigned}` : ""}), not SUCCEEDED with exit 0`);
290
539
  }
291
540
  const evidence = payload.snapshotEvidence;
292
541
  if (!evidence || typeof evidence !== "object") fail("evidence", "the signed payload carries no snapshotEvidence: the broker did not build this as a snapshot request");
293
542
  for (const name of SNAPSHOT_EVIDENCE_FIELDS) {
294
543
  if (evidence[name] === undefined || !same(receipt[name], evidence[name])) fail("evidence", `receipt ${name} differs from the signed snapshot evidence`);
295
544
  }
296
- if (!same(evidence.commit, carrier.commit)) fail("evidence", "the signed commit is not the one this request carried");
297
- if (evidence.ownership?.handle !== carrier.ownership.handle) fail("evidence", "the signed ownership handle is not this gate's");
298
- if (evidence.requestId !== jobId) fail("evidence", "the signed request id is not this job");
299
- const snap = evidence.snapshot ?? {};
300
- let samePath = false;
301
- try { samePath = canonicalPath(snap.path) === canonicalPath(carrier.snapshot.path); } catch { samePath = false; }
302
- if (!samePath) fail("evidence", `the signed snapshot ${snap.path} is not ${carrier.snapshot.path}`);
303
- if (snap.treeAtStart !== carrier.snapshot.expectedTree || snap.treeAtExit !== carrier.snapshot.expectedTree) {
304
- fail("evidence", `the snapshot was tree ${snap.treeAtStart} at START and ${snap.treeAtExit} at exit, not ${carrier.snapshot.expectedTree} throughout: the build changed a tracked file (or added or removed one) in the commit's snapshot, so the result is discarded; that is the app owner's defect to fix (write generated output to an ignored path or an output directory, or generate before commit)`);
305
- }
545
+ verifySnapshotEvidenceIdentity(evidence, {
546
+ commit: carrier.commit, tree: carrier.snapshot.expectedTree, jobId, handle: carrier.ownership.handle, snapshotPath: carrier.snapshot.path,
547
+ samePath: (a, b) => { try { return canonicalPath(a) === canonicalPath(b); } catch { return false; } },
548
+ });
306
549
  // Declared inputs: what the broker digested in place must be what the gate digests in the snapshot, from the same manifest.
550
+ // An absent entry ({path, absent:true}) is evidence only for a non-compiling snapshot-bound request, and only when the gate's own read of the
551
+ // snapshot finds the input absent too: the gate digests with `allowAbsent` for that class alone, so a receipt that says absent where the gate
552
+ // sees the input present (or present where it is absent) differs below and is refused. A compile receipt never carries one.
553
+ const nonCompile = adapter === null && !isCompileClass(argv);
554
+ if (!nonCompile && Array.isArray(evidence.declaredInputDigests) && evidence.declaredInputDigests.some((d) => isObject(d) && (d.absent !== undefined || d.sha256 === undefined))) {
555
+ fail("evidence", "the signed declaredInputDigests carry an absent declared input on a compile request (cargo " + `${cargoSubcommandOf(argv)}); only a non-compiling request may record one`);
556
+ }
307
557
  let gateInputs; let gateDigests;
308
558
  try {
309
559
  gateInputs = readInputs(carrier.snapshot.path);
310
- gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform });
560
+ gateDigests = digestInputs(carrier.snapshot.path, gateInputs, { platform, allowAbsent: nonCompile });
311
561
  } catch (e) { fail("evidence", `the gate cannot digest the snapshot's declared inputs (${e.message})`); }
312
- if (!same(gateInputs, carrier.declaredInputs?.inputs)) fail("evidence", "the snapshot's declared inputs are not the ones this request carried");
313
- if (!same(evidence.declaredInputDigests, gateDigests)) fail("evidence", "the signed declaredInputDigests are not the gate's own digest of the snapshot's declared inputs");
562
+ if (!same(gateInputs, carrier.declaredInputs?.inputs)) fail("evidence", "the snapshot's declared inputs are not the ones this request carried (declaredInputs differs from the snapshot declaration)");
563
+ if (!same(evidence.declaredInputDigests, gateDigests)) fail("evidence", "the signed declaredInputDigests are not the gate's own digest of the snapshot's declared inputs (an input the receipt records absent must be absent in the snapshot, and the reverse)");
314
564
  // Output directory: where the outputs went must be the broker's managed generation target, never under the snapshot or the checkout.
315
565
  const out = evidence.outputDir;
316
566
  if (typeof out !== "string" || !path.isAbsolute(out) && !path.win32.isAbsolute(out)) fail("evidence", "the signed evidence names no absolute output directory");
@@ -318,46 +568,89 @@ export function verifySnapshotReceipt(receipt, { pins, jobId, carrier, argv, wor
318
568
  const canon = (p) => { try { return canonicalPath(p); } catch { return p; } };
319
569
  if (inside(out, carrier.snapshot.path, platform) || inside(out, canon(carrier.snapshot.path), platform)) fail("evidence", `the output directory ${out} is inside the snapshot`);
320
570
  if (inside(out, checkout, platform) || inside(out, canon(checkout), platform)) fail("evidence", `the output directory ${out} is inside the checkout`);
321
- if (!isGenerationTarget(out, outputRoots, platform)) fail("evidence", `the output directory ${out} is not a managed generation target (<ws root>/<repo-key>/g/<generation>/target) under ${outputRoots.join(", ")}`);
322
- // Stage evidence is signed with the rest and must equal the receipt's copy. `freshness` is required for every carried command;
323
- // `attribution` and `toolchain` only for a compile-class command (check, clippy, build, doc, rustc, test: the broker emits them
324
- // for compiles only), so metadata, fetch --locked, fmt --check or tree --locked pass without them. When either side carries one
571
+ if (!isGenerationTarget(out, outputRoots, platform)) fail("evidence", `the output directory ${out} is not a managed generation target (<ws root>/<repo-key>/g/<generation>/{target|t}) under ${outputRoots.join(", ")}`);
572
+ // Stage evidence is signed with the rest and must equal the receipt's copy. `freshness`, `attribution` and `toolchain` are
573
+ // required only for a compile-class command (check, clippy, build, doc, rustc, test): the broker emits them for compiles only, so a
574
+ // read-only metadata, fetch --locked, fmt --check or tree --locked passes without them (rightkit#1311: a carried metadata job has
575
+ // no freshness census; the Mac witness, request ed37214e). When either side carries one
325
576
  // anyway, both must carry the same value.
326
577
  const stage = payload.stageEvidence;
327
578
  if (!isObject(stage)) fail("evidence", "the signed payload carries no stageEvidence");
328
- const compile = isCompileClass(argv);
579
+ // An adapter job is not a cargo command: whether it signs attribution and toolchain is the adapter's; when either side carries one,
580
+ // both must carry the same value (below).
581
+ const compile = adapter === null && isCompileClass(argv);
329
582
  for (const name of STAGE_EVIDENCE_FIELDS) {
330
583
  const signed = stage[name] ?? null; const copy = receipt[name] ?? null;
331
- const required = name === "freshness" || compile;
332
- if (required && (signed === null || copy === null)) fail("evidence", `${name} is missing from the signed stage evidence or the receipt${name === "freshness" ? "" : ` (a compile-class command, cargo ${cargoSubcommandOf(argv)}, carries it)`}`);
584
+ const required = compile;
585
+ if (required && (signed === null || copy === null)) fail("evidence", `${name} is missing from the signed stage evidence or the receipt (a compile-class command, cargo ${cargoSubcommandOf(argv)}, carries it)`);
333
586
  if (!same(copy, signed)) fail("evidence", `receipt ${name} differs from the signed stage evidence`);
334
587
  }
335
588
  // Shapes RustRight emits (item 4 row 4.18): attribution is `{available, ...}`, and an unavailable one names its reason
336
589
  // (`cargo-json-message-format-not-requested` without a JSON message stream; a consumer that needs per-package attribution treats
337
590
  // `available:false` as FAILED, not partial); toolchain is `<toolchain>|cargo:<sha256>|rustc:<sha256>`.
591
+ // GB-02 (BROKER-CONTRACT section 14): a compile's freshness is an object with a boolean `available`, and `available: false` always names a reason: absence is never an
592
+ // unavailable census, and an unavailable one on a SUCCEEDED compile is the broker's own failure (RR_FRESHNESS_UNAVAILABLE), never evidence.
593
+ if (compile) {
594
+ const freshness = receipt.freshness;
595
+ if (!isObject(freshness) || typeof freshness.available !== "boolean") fail("evidence", "receipt freshness is not {available, ...}");
596
+ if (freshness.available === false && !(typeof freshness.reason === "string" && freshness.reason !== "")) fail("evidence", "receipt freshness is unavailable and names no reason");
597
+ }
338
598
  const attribution = receipt.attribution ?? null; const toolchain = receipt.toolchain ?? null;
339
599
  if (attribution !== null) {
340
600
  if (!isObject(attribution) || typeof attribution.available !== "boolean") fail("evidence", "receipt attribution is not {available, ...}");
341
601
  if (attribution.available === false && !(typeof attribution.reason === "string" && attribution.reason !== "")) fail("evidence", "receipt attribution is unavailable and names no reason");
342
602
  }
603
+ // GB-66: the broker signs `<name>|cargo:<sha256>|rustc:<sha256>` only when it could build that identity; when it could not (no configured name, an unregistered or
604
+ // changed cargo or rustc) the receipt's toolchain is exactly {"state":"UNKNOWN","reason":...}. That is a typed unavailable identity, never evidence and never a
605
+ // string with an empty component.
606
+ if (isToolchainUnknown(toolchain)) fail(TOOLCHAIN_UNAVAILABLE, `the broker could not name this compile's toolchain (${TOOLCHAIN_UNAVAILABLE_CODE}: UNKNOWN, ${toolchain.reason}); an unknown toolchain identity is not evidence, so the call is unverified and nothing is concluded from it`);
343
607
  if (toolchain !== null && !(typeof toolchain === "string" && TOOLCHAIN_FORMAT.test(toolchain))) fail("evidence", "receipt toolchain is not <toolchain>|cargo:<sha256>|rustc:<sha256>");
608
+ // RA-03: a census that counts rebuilt units says nothing about why they were rebuilt. A compile's signed freshness must carry `causeEvidence`
609
+ // (rustright.cause-evidence.v1: provenance-verify.mjs requireExplainedCauses, the one implementation) whose verdict is `explained`: a missing or unrecognized
610
+ // schema or an incoherent one is `evidence`, an `unknown` verdict (a quiet, caller-logged or artifact-running root whose log was not read, dropped output, an
611
+ // uncovered unit) is `cause-unknown`, and a unit rebuilt with no explained cause is `cause-unexplained`; none is a success, and none is relaxed for an older
612
+ // broker that signs no cause evidence. The judged object is the SIGNED copy (the receipt's equals it, checked above). A command that is not a compile
613
+ // legitimately carries no census; when one carries cause evidence anyway it must still be well formed, but nothing is judged for it (nothing was rebuilt).
614
+ const signedFreshness = stage.freshness ?? null;
615
+ let causeEvidence = null;
616
+ if (compile) causeEvidence = requireExplainedCauses(signedFreshness);
617
+ else if (isObject(signedFreshness) && signedFreshness.causeEvidence !== undefined) causeEvidence = verifyCauseEvidence(signedFreshness);
344
618
  // Fields the signature does not cover must still be present with their types (the consumer reads them). Every carried command:
345
- // verdict, refreshed, exit (an integer, or null with a signal) and signal, timingsMs{admission, queue, bind, cargo, afterExitBind,
346
- // diagnostics} (each a number or null) and diagnosticsPath (a path, or null). Success needs exit 0 and no signal, as signed.
619
+ // exit (an integer, or null with a signal) and signal, timingsMs{admission, queue, bind, cargo, afterExitBind, diagnostics} (each a
620
+ // number or null) and diagnosticsPath (a path, or null). Success needs exit 0 and no signal, as signed. `verdict` and `refreshed`
621
+ // are retired: the gate owns Class A, and RustRight refuses a receipt that carries either (rustright bc24d55, attestation.rs
622
+ // RETIRED_RESULT_FIELDS), so the gate refuses them too.
347
623
  const timingsOk = isObject(receipt.timingsMs) && TIMING_KEYS.every((k) => Object.hasOwn(receipt.timingsMs, k) && (receipt.timingsMs[k] === null || Number.isFinite(receipt.timingsMs[k])));
348
624
  const typed = [
349
- ["verdict", isObject(receipt.verdict)], ["timingsMs", timingsOk], ["exit", receipt.exit === null || Number.isSafeInteger(receipt.exit)],
625
+ ["timingsMs", timingsOk], ["exit", receipt.exit === null || Number.isSafeInteger(receipt.exit)],
350
626
  ["signal", receipt.signal === null || typeof receipt.signal === "string" || Number.isSafeInteger(receipt.signal)],
351
627
  ["diagnosticsPath", receipt.diagnosticsPath === null || (typeof receipt.diagnosticsPath === "string" && receipt.diagnosticsPath !== "")], ["brokerVersion", typeof receipt.brokerVersion === "string" && receipt.brokerVersion !== ""],
352
- ["executor", typeof receipt.executor === "string" && receipt.executor !== ""], ["refreshed", Array.isArray(receipt.refreshed)],
628
+ ["executor", typeof receipt.executor === "string" && receipt.executor !== ""],
353
629
  ];
354
630
  for (const [name, ok] of typed) if (!ok) fail("evidence", `receipt ${name} is missing or has the wrong type`);
631
+ for (const name of ["verdict", "refreshed"]) if (Object.hasOwn(receipt, name)) fail("evidence", `receipt carries the retired field ${name}`);
355
632
  if (receipt.exit !== 0 || receipt.signal !== null) fail("evidence", `receipt exit ${receipt.exit} and signal ${receipt.signal} are not a success (exit 0, no signal)`);
356
- // RustRight signs these top-level fields in the payload (item 4 row 4.18): when the signed payload carries one, the receipt's copy must equal it.
357
- for (const name of SIGNED_TOP_LEVEL_FIELDS) {
358
- if (payload[name] !== undefined && !same(receipt[name], payload[name])) fail("evidence", `receipt ${name} differs from the signed payload`);
633
+ // RustRight signs the result fields under payload.resultEvidence (service.rs provenance_payload; attestation.rs RESULT_EVIDENCE_FIELDS), never at
634
+ // the payload's top level. Every receipt copy must equal its signed counterpart, and a receipt with no signed resultEvidence is refused: the
635
+ // exit, signal, timings and diagnostics path it shows would otherwise be this receipt's own say-so.
636
+ const signedResult = payload.resultEvidence;
637
+ if (!isObject(signedResult)) fail("evidence", "the signed payload carries no resultEvidence: the receipt's exit, signal, timingsMs and diagnosticsPath are not covered by the signature");
638
+ for (const name of RESULT_EVIDENCE_FIELDS) {
639
+ if (!Object.hasOwn(signedResult, name) || !same(receipt[name], signedResult[name])) fail("evidence", `receipt ${name} differs from the signed result evidence`);
359
640
  }
360
- return { payload, evidence, attribution, toolchain };
641
+ for (const name of ["verdict", "refreshed"]) if (Object.hasOwn(signedResult, name)) fail("evidence", `the signed result evidence carries the retired field ${name}`);
642
+ // Symmetric both ways: a field the broker signed that the protocol does not name is as unexplained as one the receipt lacks (an added field).
643
+ for (const name of Object.keys(signedResult)) if (!RESULT_EVIDENCE_FIELDS.includes(name)) fail("evidence", `the signed result evidence carries the unknown field ${name}`);
644
+ for (const name of Object.keys(isObject(receipt.timingsMs) ? receipt.timingsMs : {})) if (!TIMING_KEYS.includes(name)) fail("evidence", `receipt timingsMs carries the unknown key ${name}`);
645
+ // A snapshot-bound adapter job signs the compiles its own children made (rustright#190 D8).
646
+ const nestedChildren = adapter === null ? null : verifyNestedChildren(evidence, receipt, { snapshotPath: carrier.snapshot.path, checkout, outputRoots, platform, canonicalPath, jobId });
647
+ // GB-10 / GB-11: whatever the checks above did not already name, the production strict verifier (`verify_receipt_strict`, provenance-verify.mjs, judged
648
+ // against RustRight's receipt vector case for case) must also accept: no signed-only or receipt-only field anywhere, the signed result evidence with exactly
649
+ // its four fields and six phases, and the BR19 shape (profile, stage, resources, environment; a value or a typed UNKNOWN, never absent).
650
+ verifyReceiptCoverage(receipt, payload);
651
+ verifyResultEvidence(receipt, payload);
652
+ verifyBr19Evidence(receipt, payload);
653
+ return { payload, evidence, attribution, toolchain, nestedChildren, requestDigest, causeEvidence };
361
654
  }
362
655
 
363
- export { ProvenanceError };
656
+ export { CAUSE_EVIDENCE_SCHEMA, CAUSE_UNEXPLAINED, CAUSE_UNKNOWN, ProvenanceError };