@rightkit/release 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +25 -4
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +11 -2
  10. package/codesign-environment.mjs +2 -2
  11. package/content-digest.mjs +209 -0
  12. package/development.mjs +17 -6
  13. package/direct-bootstrap.mjs +5 -6
  14. package/disk-command.mjs +1 -0
  15. package/disk-policy.json +6 -2
  16. package/disk-stable-root.mjs +13 -9
  17. package/github-updater-release.mjs +70 -7
  18. package/in-place-install.mjs +17 -14
  19. package/install-app-windows.mjs +24 -7
  20. package/install-app.mjs +21 -40
  21. package/ios-asc-credentials.mjs +163 -0
  22. package/ios-build-context.mjs +129 -0
  23. package/ios-build-upload.mjs +282 -0
  24. package/ios-credentials-command.mjs +70 -0
  25. package/ios-dev.mjs +248 -0
  26. package/ios-host.mjs +76 -0
  27. package/ios-lock.mjs +75 -0
  28. package/ios-package-recipe.mjs +707 -0
  29. package/ios-testflight.mjs +266 -0
  30. package/ios-upload.mjs +313 -0
  31. package/local-publish.mjs +92 -37
  32. package/managed-cargo.mjs +8 -0
  33. package/mirror-root-artifact.mjs +5 -1
  34. package/native-cargo-layout.mjs +47 -4
  35. package/ownership-command.mjs +2 -1
  36. package/ownership-dependency-allowlist.json +1 -1
  37. package/ownership-evidence.mjs +30 -18
  38. package/ownership-key-command.mjs +69 -2
  39. package/ownership-mandatory-set.mjs +66 -16
  40. package/ownership-repositories.json +1 -1
  41. package/ownership-scope.mjs +94 -0
  42. package/ownership.mjs +49 -7
  43. package/package-recipe-targets.mjs +41 -1
  44. package/package.json +1 -1
  45. package/preflight.mjs +14 -7
  46. package/project-type.mjs +3 -1
  47. package/protected-credentials.mjs +28 -1
  48. package/publication-artifacts.mjs +30 -5
  49. package/publication-gate.mjs +15 -4
  50. package/publish-cargo.mjs +109 -22
  51. package/publish-update.mjs +15 -2
  52. package/qa-build-features.mjs +16 -13
  53. package/qa-contract.mjs +30 -2
  54. package/qa-control-scan.mjs +4 -0
  55. package/release-invocation.mjs +118 -8
  56. package/release-switch-registry.json +126 -27
  57. package/release-switch-scan.mjs +45 -17
  58. package/release.mjs +69 -17
  59. package/rightkit-ownership.json +1 -1
  60. package/rightkit-versions.json +379 -189
  61. package/runtime-budgets.mjs +120 -16
  62. package/sign-updater.mjs +56 -59
  63. package/source-gate.mjs +8 -1
  64. package/stage-sidecar.mjs +160 -32
  65. package/target-distribution.mjs +5 -0
  66. package/upload-release.mjs +7 -2
package/release.mjs CHANGED
@@ -11,8 +11,8 @@ import { validateRightKitCargoContract } from "./cargo-contract.mjs";
11
11
  import { assertCargoProfileContract } from "./cargo-profile-contract.mjs";
12
12
  import { assertQaAdoption, assertQaBackdoorContract } from "./qa-contract.mjs";
13
13
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
14
- import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
15
- import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
14
+ import { assertNativeIosPrepareHasManifest, assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
15
+ import { declaredCargoManifest, hasDefaultTauriManifest, resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
16
16
  import {
17
17
  canonicalPackageIdentity,
18
18
  createNativeFinalizationReceipt,
@@ -48,9 +48,12 @@ import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs
48
48
  import { assertReleaseDependencies } from "./release-dependencies.mjs";
49
49
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
50
50
  import { expandRecipeBuildCommands } from "./recipe-build.mjs";
51
- import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
51
+ import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure } from "./release-switch-scan.mjs";
52
+ import { iosOwnershipScope } from "./ownership-scope.mjs";
52
53
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
53
54
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
55
+ import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
56
+ import { releaseSwitchInputs } from "./ios-build-context.mjs";
54
57
 
55
58
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
56
59
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -180,11 +183,14 @@ const SUPERSEDE_JOB_HEAD = /^\n supersede:\n needs: \[private-repo-guard\]\n
180
183
 
181
184
  const root = path.dirname(configPath);
182
185
  const workdir = path.resolve(root, config.workdir ?? ".");
183
- const nativeLayout = resolveNativeCargoLayout({ appRoot: root, config });
186
+ // A native iOS app (xcodegen project; Rust built by iosPackage.prepare) with no src-tauri and no declared nativeAssembly
187
+ // manifest owns no Cargo layout: null, so the Cargo-derived preflight, sweep and package-contract steps are skipped for it.
188
+ const nativeIos = opts.platform === "ios" && !hasDefaultTauriManifest({ appRoot: root });
189
+ const nativeLayout = nativeIos ? null : resolveNativeCargoLayout({ appRoot: root, config });
184
190
  const nativeAssembly = config.nativeAssembly;
185
191
  const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
186
- // A SwiftPM app without a package.json has no Node package contract to validate.
187
- if (!(resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm" && !existsSync(path.join(root, "package.json")))) {
192
+ // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
193
+ if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
188
194
  await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
189
195
  }
190
196
  validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
@@ -257,12 +263,18 @@ let androidTarget; // validated Android AAB target (android only; see android-re
257
263
  if (opts.platform === "android") {
258
264
  try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { fail(`${config.app ?? "app"} android ${error.message}`); }
259
265
  }
266
+ let iosSpec; // validated iOS iosPackage spec (ios only; package-recipe-targets.mjs expands targets.ios, ios-package-recipe.mjs runs it)
267
+ if (opts.platform === "ios") {
268
+ if (target.recipe?.kind !== "iosPackage") fail(`${config.app ?? "app"} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
269
+ iosSpec = target.recipe.spec;
270
+ if (nativeIos) { try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { fail(`${config.app ?? "app"} ios ${error.message}`); } }
271
+ }
260
272
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
261
273
  /**
262
274
  * Assemble preflight inputs from the app's own files. Kept here (not in
263
275
  * preflight.mjs) so the check module stays free of release.mjs's config shape.
264
276
  */
265
- function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout, projectTarget = config.targets?.[platform] }) {
277
+ function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout, projectTarget = config.targets?.[platform], pinnedCargoLocks = [] }) {
266
278
  let version;
267
279
  try {
268
280
  version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
@@ -273,6 +285,7 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
273
285
  // them means the build needs a real Windows Perl.
274
286
  const cargoLockPaths = [
275
287
  ...(suppliedNativeLayout?.lockCandidates ?? []),
288
+ ...pinnedCargoLocks,
276
289
  ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
277
290
  ].filter((entry, index, entries) => entries.indexOf(entry) === index);
278
291
  return collectPreflight({
@@ -286,12 +299,14 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
286
299
  nativeLayout: suppliedNativeLayout,
287
300
  targetLink: suppliedNativeLayout?.targetLink,
288
301
  projectType: resolveProjectType({ appRoot: root, config, target: projectTarget }),
302
+ rust: Boolean(suppliedNativeLayout) || pinnedCargoLocks.length > 0,
303
+ bridge: Boolean(suppliedNativeLayout),
289
304
  msix: targetUsesMsix(projectTarget),
290
305
  });
291
306
  }
292
307
 
293
308
  if (opts.doctor) {
294
- const { buildInputs, nativeLayout: resolvedNativeLayout } = resolveReleaseBuildInputs({
309
+ const { buildInputs, nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({
295
310
  repoRoot: doctorInvocation.repoRoot,
296
311
  appRoot: root,
297
312
  configPath,
@@ -335,7 +350,7 @@ if (opts.doctor) {
335
350
 
336
351
  // Config above, MACHINE below. Printing config never told anyone whether this
337
352
  // box could finish a build; these checks do.
338
- const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout });
353
+ const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
339
354
  console.log("preflight:");
340
355
  console.log(formatPreflight(checks));
341
356
  const failures = preflightFailures(checks);
@@ -350,10 +365,15 @@ let prePackScanned = false; // set once a pre-pack payload scan has seen a nativ
350
365
  // or app Cargo manifest enables devtools, qa-native, qa-control or dev-native (also in --dry-run).
351
366
  // DevTools: refused for every app except an allow-listed one that declares `devtools: "content-only"` (devtools-policy.mjs).
352
367
  let devtoolsAllowance = { mode: "denied" };
368
+ // The Rust a build compiles is checked from the manifest it compiles: targets.<os>.cargoManifest, else the declared
369
+ // nativeAssembly.cargoManifest. iosPackage.prepare commands run cargo for a native iOS app, so they are scanned like any build command.
370
+ const switchInputs = releaseSwitchInputs({ appRoot: root, config, target, prepare: iosSpec?.prepare ?? [], nativeIos });
371
+ if (switchInputs.problems.length) fail(`${config.app ?? "app"} ios release-switch scan cannot see what is built:\n- ${switchInputs.problems.join("\n- ")}`);
353
372
  try {
354
373
  devtoolsAllowance = resolveDevtoolsAllowance({ config, root });
355
374
  if (devtoolsAllowance.mode === "content-only") console.log(`right-release: devtools allowance recorded: ${JSON.stringify(devtoolsEvidence(devtoolsAllowance))}`);
356
- assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: target.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
375
+ assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: switchInputs.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
376
+ ...switchInputs.commands,
357
377
  { label: "target.prePackage", command: target.prePackage },
358
378
  { label: "target.package", command: target.package },
359
379
  { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
@@ -374,17 +394,18 @@ const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? read
374
394
  try {
375
395
  // A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
376
396
  // command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
377
- const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
397
+ const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle, ...switchInputs.cargoCommands].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
378
398
  assertReleaseDependencies({ root, config, target, commands: closureCommands });
379
399
  let treeText;
380
400
  try {
381
401
  const closure = resolveFeatureClosure({
382
402
  root,
383
403
  manifest: config.releaseSwitches?.manifest,
384
- cargoManifest: target.cargoManifest,
404
+ cargoManifest: switchInputs.cargoManifest,
385
405
  rust: config.releaseSwitches?.rust,
386
406
  features: config.releaseSwitches?.features ?? [],
387
- triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
407
+ // iOS ships one device triple; "all" is refused by the build broker and would pull in desktop-only targets.
408
+ triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? (opts.platform === "ios" ? "aarch64-apple-ios" : undefined),
388
409
  commandTexts: closureCommands,
389
410
  });
390
411
  if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
@@ -394,9 +415,26 @@ try {
394
415
  console.log(`right-release: dry run: ${error.message}`);
395
416
  }
396
417
  // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
397
- const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
418
+ const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
398
419
  if (releaseSwitchInventoryText != null) {
399
- assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
420
+ // iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
421
+ // resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
422
+ let sourceRoots;
423
+ if (opts.platform === "ios") {
424
+ const repoTop = git(root, ["rev-parse", "--show-toplevel"]);
425
+ // Swift side only: the Rust side is the resolved cargo graph below. The ownership scope also folds in the whole
426
+ // nativeAssembly workspace, which would scan crates (desktop tools, the TUI) the iPhone never links.
427
+ const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
428
+ sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
429
+ // A prepare step can stage sources from anywhere (a web bundle, a generated resource) into the app; its working directory must be
430
+ // inside the scanned roots, or the release fails instead of shipping what the scan never saw. Add the directory to ownershipScope.
431
+ for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
432
+ const cwd = path.resolve(root, step?.cwd ?? ".");
433
+ if (!sourceRoots.some((dir) => cwd === dir || cwd.startsWith(dir + path.sep))) throw new Error(`source switch inventory: targets.ios.iosPackage.prepare runs in ${path.relative(repoTop, cwd) || "."}, outside the iOS scan roots (${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}); list that directory in targets.ios.ownershipScope`);
434
+ }
435
+ console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
436
+ }
437
+ assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, sourceRoots, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
400
438
  } else if (treeText != null || closureInput.commands.length) {
401
439
  assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
402
440
  }
@@ -408,7 +446,7 @@ try {
408
446
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
409
447
  const mode = "package";
410
448
  const command = target.package;
411
- if (!command && opts.platform !== "android" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
449
+ if (!command && opts.platform !== "android" && opts.platform !== "ios" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
412
450
  if (opts.upload && !target.publish && !target.upload) {
413
451
  fail(`${config.app ?? "app"} has no ${opts.platform} upload config`);
414
452
  }
@@ -524,6 +562,16 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
524
562
  receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
525
563
  allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only",
526
564
  });
565
+ } else if (opts.platform === "ios") {
566
+ // Archive and export with Xcode automatic signing in the GUI session, then the IPA verification and the receipt (macOS only;
567
+ // --dry-run prints the plan anywhere). Upload is a separate command: `right-release upload --platform ios`.
568
+ await runIosPackageRecipe({
569
+ root, app: config.app ?? path.basename(root), spec: iosSpec, dryRun: opts.dryRun,
570
+ receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
571
+ scanFiles: (label, files, scanOptions) => assertNoQaControlBinaries(label, files, scanOptions),
572
+ // The build number counter outlives every release id: it lives in the vault state root that right-release build exports.
573
+ ...(process.env.RIGHT_RELEASE_VAULT_STATE_ROOT ? { counterDir: path.join(path.resolve(process.env.RIGHT_RELEASE_VAULT_STATE_ROOT), "ios-build-numbers") } : {}),
574
+ });
527
575
  } else if (nativeAssembly) {
528
576
  if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
529
577
  if (nativeFinalizer) {
@@ -647,7 +695,7 @@ function assertNoQaControlBinaries(label, files, { requireBinary = false, prePac
647
695
  try {
648
696
  const stats = assertNoQaControl(files, label, { requireBinary, allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only" });
649
697
  // D4 binary layer, same inputs: no test-mode marker and no S1-S4 selector name in any native binary.
650
- assertNoReleaseSwitches({ binaryPaths: files, inventoryText: releaseSwitchInventoryText ?? undefined, appForbid: config.releaseSwitches?.forbid ?? [], requireBinary: false }, `${label} (release switches)`);
698
+ assertNoReleaseSwitches({ binaryPaths: files, devtools: devtoolsAllowance, inventoryText: releaseSwitchInventoryText ?? undefined, appForbid: config.releaseSwitches?.forbid ?? [], requireBinary: false }, `${label} (release switches)`);
651
699
  if (prePack && stats.binaries > 0) prePackScanned = true;
652
700
  for (const opaque of stats.opaque) {
653
701
  console.log(`right-release: ${label}: ${opaque.kind.toUpperCase()} payload is compressed and cannot be scanned (${opaque.file}); the raw payload was scanned before packing`);
@@ -908,6 +956,8 @@ function hasNativeTargetSpecificPrePackage(target, packageHook) {
908
956
  // publish so it cannot race an artifact, and is best-effort: a missing
909
957
  // cargo-sweep or a sweep failure must never fail a release.
910
958
  function sweepStaleRustArtifacts() {
959
+ // No Cargo host, nothing of ours to sweep.
960
+ if (!nativeLayout) return;
911
961
  const candidates = [...new Set([root, workdir, nativeLayout.manifestDir, nativeLayout.workspaceRoot])];
912
962
  const projects = candidates.filter((dir) => existsSync(path.join(dir, "target")));
913
963
  for (const dir of projects) {
@@ -1432,6 +1482,8 @@ function expandEnv(value) {
1432
1482
  // notices and the source offer, optionally run the app's stage command, and (before signing) check the staged files.
1433
1483
  async function prepareMediaSidecar(root, { stage = true } = {}) {
1434
1484
  if (!mediaSidecarConfig) return null;
1485
+ // Unreachable for iOS (mediaSidecar is mac and win only, refused above); a Cargo-less layout must still never be dereferenced.
1486
+ if (!nativeLayout) fail(`${config.app ?? "app"} ${opts.platform} mediaSidecar needs a native Cargo manifest to read the sidecar catalog, and this app has none`);
1435
1487
  let plan;
1436
1488
  try {
1437
1489
  const loaded = loadSidecarCatalog({ config: mediaSidecarConfig, root, manifestPath: nativeLayout.manifestPath });
@@ -2979,7 +2979,7 @@
2979
2979
  {
2980
2980
  "package": "rightkit-spend",
2981
2981
  "capability": "spend-ledger",
2982
- "summary": "Spend reservation, allowance and compare-and-swap ledger on SQLite: integer micro-units, idempotent reserve on request id, in-flight and overrun handling, opaque allowance periods, optional account key and per-reservation currency, billed-unknown commit, caller-transaction (`_in`) variants, strict transition mode",
2982
+ "summary": "Spend reservation, allowance and compare-and-swap ledger on SQLite: integer micro-units, idempotent reserve on request id, in-flight and overrun handling, opaque allowance periods, optional account key and per-reservation currency, billed-unknown commit, caller-transaction (`_in`/`_on`) variants, strict transition mode, carry-over, atomic multi-cap reserve with epoch-reset device allowance, caller and process budgets, provider-reported cost and invoice reconciliation, cap-setting audit, optional overrun gate",
2983
2983
  "fingerprints": [],
2984
2984
  "detection": "declaration-only",
2985
2985
  "policyNotes": [