@rightkit/release 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -486,6 +486,19 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
486
486
 
487
487
  `outcome` is `allowed` (file created and removed), `blocked` (`EACCES`, `EPERM` or `EROFS`) or `error` (directory missing or another failure; `code` says which). `code` is `null` when allowed, except `UNLINK_<code>` when the probe file could not be removed. `internalWritable` is true when profile, local-temp or os-temp is `allowed`.
488
488
 
489
+ ## RightKit pins are proven against the registries (`rightkit-registry-resolver.mjs`)
490
+
491
+ At build admission the controller resolves the app's EXACT locked RightKit versions (every `Cargo.lock` entry and `pnpm-lock.yaml` resolution of a RightKit-owned package) against crates.io (index `cksum` + `yanked`) and npm (`dist.integrity` + `deprecated`). A new version of a known RightKit package needs no new release of this tool. The claim is scoped to KNOWN names: ownership comes from `rightkit-versions.json` and `rightkit-ownership.json`, a name prefix alone is refused, and there is no maintainer-membership proof, so a package first published after this tool still needs an ownership update. Only canonical registry sources count (no path, git, tarball or directory); the recorded minimum and `revoked` policy still apply; pins are never rewritten.
492
+
493
+ What the digest comparison is: the lock digest and the registry digest agree (metadata agreement). It is NOT proof of the bytes a build consumes; this tool does not materialize or hash the installed packages, so no byte-level claim is made.
494
+
495
+ - **Coverage.** Every RightKit dependency a manifest declares (`Cargo.toml`, `package.json`, including the directory of every canonical lock outside the app root) needs a lock entry for that package name somewhere in the canonical lock inventory; a missing or incomplete lock refuses admission, with no bundled-only fallback. This is a name-level check, not importer-specific lock proof. Lock inputs come from the build's canonical input inventory (`requiredInputs`, `pinnedCargoLocks`, native-iOS locks), not a separately built list.
496
+ - **Freshness is a build-admission gate.** A fresh `right-release build` reads each package's latest live (crates.io `max_stable_version`, npm `dist-tags.latest`) and REFUSES every pin behind it (`pinned X -> latest Y: run right-update run`), held or not: a `.rightkit/update.json` hold is a migration lane task, reported with the refusal, never an exception. If latest cannot be read live the failure is RECOVERABLE (a cached latest is only last-observed). The latest row is always the live registry state. The cache keeps a separate high-water mark as downgrade protection for derived answers only: a lower crates.io answer while the higher version is still published is a recoverable `eligibility-conflict`; a withdrawn high-water version resolves itself; an npm `latest` tag moved lower on purpose is followed.
497
+ - **Reuse is trusted-local, not authenticated.** The admitted snapshot (`<state root>/rightkit-registry-snapshot.json`), the admission record (`rightkit-admission.json`: release id, commit, platform, snapshot digest, hash of `release-inputs.lock.json`) and the lock payload are plain files in the release state root. The release worker (`RIGHT_RELEASE_RIGHTKIT_SNAPSHOT`), `--resume-from`, the seal and `upload` check that they agree with each other, with this release's identity and with the current locks, and refuse a missing, foreign, inconsistent or stale set without resolving again. That catches mix-ups and edits that leave the files inconsistent. It does NOT authenticate them: anyone who can write the state root can forge a consistent set, and a resume proves only that the recorded admission is consistent with this release and the current locks, not that the registry still agrees or that the controller wrote it. There is no signing layer, and no existing broker attestation is reused here. A publication after admission changes nothing and freshness is not re-checked.
498
+ - **Sealed evidence.** The sealed `release-manifest.json` carries `rightReleaseVersion`, `rightkitRegistryEvidence: 1` and `rightkitRegistry` (`digest`, `snapshot`). A manifest without the evidence is refused on every seal and upload path. The only exemption is an explicitly identified pre-0.6.0 release: its manifest records no tool version (or one below 0.6.0) and its release id is named in `RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES`. A stripped seal that records 0.6.0 or later is refused whatever is set.
499
+ - `right-release doctor` resolves live and lists newer versions as informational; `right-release doctor --admission` applies the admission refusals.
500
+ - Cache: `<RIGHT_RELEASE_CACHE_ROOT>/rightkit-registry/latest/` (external storage; none when no cache root is configured). There is no offline override for a fresh admission: a registry outage is a recoverable failure after bounded retries.
501
+
489
502
  ## Known issues
490
503
 
491
504
  - `smol-toml` is pinned exactly. A consumer that depends on a different `smol-toml` version gets a second copy.
Binary file
package/build-release.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import path from "node:path";
21
21
  import { iosOwnershipScope } from "./ownership-scope.mjs";
22
22
  import { spawn, spawnSync } from "./background-process.mjs";
23
+ import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord } from "./rightkit-registry-resolver.mjs";
23
24
  import { fileURLToPath, pathToFileURL } from "node:url";
24
25
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
25
26
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
@@ -106,6 +107,7 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
106
107
 
107
108
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
108
109
  const WORKER = path.join(TOOL_ROOT, "release.mjs");
110
+ const RELEASE_TOOL_VERSION = JSON.parse(readFileSync(path.join(TOOL_ROOT, "package.json"), "utf8")).version;
109
111
  const args = process.argv.slice(2);
110
112
  let configName = "right-release.config.mjs";
111
113
  let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
@@ -365,6 +367,59 @@ try {
365
367
  env.RIGHT_RELEASE_ARTIFACT_DIR = managedArtifactDir;
366
368
  const buildRoot = managedCargoTarget ? null : path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
367
369
  env.RIGHT_RELEASE_STATE_ROOT = stateRoot;
370
+ // Resolve the exact locked RightKit pins against the canonical registries ONCE, here (build admission), and keep
371
+ // the snapshot in this release's state root with an admission record naming the release (id, commit, platform) and
372
+ // the snapshot digest. The release worker, a --resume-from phase resume and the sealed manifest consume only that
373
+ // recorded snapshot; a missing one is refused, never re-resolved. These are trusted-local files: a resume proves the
374
+ // recorded admission is consistent with this release and the current locks, not that the registry still agrees nor
375
+ // that the controller wrote it (see rightkit-registry-resolver.mjs, TRUST SCOPE). Lock inputs come from the canonical build-input
376
+ // inventory (requiredInputs, pinnedCargoLocks), not a reconstructed list.
377
+ mkdirSync(stateRoot, { recursive: true });
378
+ const rightKitSnapshotFile = path.join(stateRoot, SNAPSHOT_FILE);
379
+ const admissionIdentity = { releaseId, commit, platform };
380
+ const sealedManifestPath = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir, "release-manifest.json");
381
+ const sealedExists = existsSync(sealedManifestPath);
382
+ const rightKitReuse = Boolean(resumeFrom) || sealedExists;
383
+ let boundSnapshot = null;
384
+ let legacySealed = false;
385
+ try {
386
+ if (sealedExists) {
387
+ const sealedManifest = JSON.parse(readFileSync(sealedManifestPath, "utf8"));
388
+ legacySealed = assertManifestRegistryEvidence(sealedManifest).legacy;
389
+ boundSnapshot = legacySealed ? null : sealedManifest.rightkitRegistry.snapshot;
390
+ } else if (resumeFrom) {
391
+ boundSnapshot = loadAdmittedSnapshot(stateRoot, { ...admissionIdentity, inPreflight: true });
392
+ }
393
+ } catch (error) {
394
+ fail(`${error instanceof RightKitRegistryError && error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
395
+ }
396
+ let rightKitResolution = null;
397
+ if (!legacySealed) {
398
+ try {
399
+ rightKitResolution = await prepareRightKitResolution({
400
+ root: appRoot,
401
+ extraLocks: rightKitLockInventory({ requiredInputs, cargoLockPaths: pinnedCargoLocks, nativeLayout, config, root: appRoot }),
402
+ versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
403
+ ownership: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-ownership.json"), "utf8")),
404
+ releaseVersion: RELEASE_TOOL_VERSION,
405
+ snapshotFile: rightKitSnapshotFile,
406
+ boundSnapshot,
407
+ reuse: rightKitReuse,
408
+ // Build admission: a fresh build samples registry latest live and refuses EVERY pin behind it (a hold is a lane
409
+ // task, not an exception). The snapshot is then frozen through build, seal, resume and upload.
410
+ admission: !rightKitReuse,
411
+ write: true,
412
+ });
413
+ } catch (error) {
414
+ if (!(error instanceof RightKitRegistryError)) throw error;
415
+ fail(`${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
416
+ }
417
+ for (const line of rightKitResolution.freshness.tasks) console.error(`[right-release] LANE TASK ${line}`);
418
+ for (const line of rightKitResolution.freshness.warnings) console.error(`[right-release] WARNING ${line}`);
419
+ console.error(`[right-release] rightkit-registry: ${rightKitResolution.summary}`);
420
+ if (rightKitReuse) console.error("[right-release] resume reuses the recorded admission snapshot: it is checked for consistency with this release and the current locks only; the registry is not asked again and the record is not authenticated");
421
+ env[SNAPSHOT_ENV] = rightKitSnapshotFile;
422
+ }
368
423
  env.RIGHT_RELEASE_VAULT_STATE_ROOT = path.join(vaultRoot, "state");
369
424
  const lockPayload = {
370
425
  schema: 1,
@@ -418,6 +473,8 @@ try {
418
473
  writeJson(path.join(stateRoot, "release-waits.json"), { schema: 1, recordedAt: new Date().toISOString(), ...preBrokerWaits });
419
474
  if (buildRoot) writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
420
475
  writeJson(path.join(stateRoot, "release-inputs.lock.json"), lockPayload);
476
+ // The controller's admission record: which snapshot was admitted for exactly this release and input identity.
477
+ if (rightKitResolution) writeAdmissionRecord(stateRoot, { ...admissionIdentity, snapshot: rightKitResolution.snapshot });
421
478
  checkpoint(stateRoot, "preflight_complete");
422
479
  },
423
480
  prepare: async () => {
@@ -474,7 +531,7 @@ try {
474
531
  });
475
532
  },
476
533
  seal: async ({ sealedDir }) => {
477
- sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot });
534
+ sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields: rightKitResolution ? registryManifestFields(rightKitResolution.snapshot, RELEASE_TOOL_VERSION) : undefined });
478
535
  verifySealedRelease(sealedDir);
479
536
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
480
537
  checkpoint(stateRoot, "sealed");
@@ -521,7 +578,7 @@ function sqlCipherFeatures(text) {
521
578
  .filter((value) => /sqlcipher|openssl/i.test(value));
522
579
  }
523
580
 
524
- function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot }) {
581
+ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields }) {
525
582
  // Every app's right-release.config.mjs names installer/updater artifacts
526
583
  // relative to its resolved native target. On a broker-managed host that
527
584
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -669,6 +726,9 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
669
726
  version: config.version,
670
727
  commit,
671
728
  ownershipEvidence,
729
+ // The exact registry resolution (identities, digests, registries, policy revision, observation time, observed latest)
730
+ // this release was built against. Upload and resume consume it; neither resolves again.
731
+ ...rightkitRegistryFields,
672
732
  qa: qaAdmission,
673
733
  // DevTools allowance (owner's fixed design): { mode: "denied" } for every app; content-only only for an allow-listed app.
674
734
  devtools: devtoolsEvidence(resolveDevtoolsAllowance({ config, root: configRoot })),
@@ -291,13 +291,17 @@ function versionAtLeast(actual, minimum) {
291
291
  export const TRUST_SYNC_RETIRED = "right-release ownership trust-sync is retired: no release, ownership or QA check needs a human-approved policy bundle any more. The bundle is still fingerprinted; `right-release ownership trust-compare` reports drift as a warning with a semantic diff.";
292
292
 
293
293
  export const POLICY_BUNDLE_DRIFT_WARNING = "ownership policy bundle (scanner source, catalog, registry or dependency allowlist) differs from the bundle pinned in protected trust (warning only; trust-sync is retired)";
294
+ export const CATALOG_DIGEST_DRIFT_WARNING = "ownership catalog digest differs from the digest pinned in protected trust (warning only; the catalog version still meets the protected minimum; trust-sync is retired)";
294
295
  export function validateOwnershipFloor(trust, { scannerVersion, catalogVersion, catalogDigest, policyBundleDigest } = {}) {
295
296
  const problems = [];
296
297
  const warnings = [];
297
298
  if (!trust) return { ok: false, problems: ["external ownership trust unavailable"], warnings };
298
299
  if (!versionAtLeast(scannerVersion, trust.minimumScannerVersion)) problems.push("scanner version missing, malformed, or below protected minimum");
299
300
  if (!versionAtLeast(catalogVersion, trust.minimumCatalogVersion)) problems.push("catalog version missing, malformed, or below protected minimum");
300
- if (trust.minimumCatalogDigest && trust.minimumCatalogDigest !== catalogDigest) problems.push("catalog digest below protected pinned digest");
301
+ // Digest drift is policy-bundle drift (the catalog is part of the bundle): a warning, never a refusal, because
302
+ // trust-sync, the only way to re-pin it, is retired (decisions.md 2026-10-15, docs/OWNERSHIP-ENFORCEMENT.md).
303
+ // The numeric scanner and catalog minimums above stay hard floors.
304
+ if (trust.minimumCatalogDigest && trust.minimumCatalogDigest !== catalogDigest) warnings.push(CATALOG_DIGEST_DRIFT_WARNING);
301
305
  // Trust written before the bundle existed has no policyBundle: nothing to compare.
302
306
  if (trust.policyBundle?.digest && policyBundleDigest !== undefined && trust.policyBundle.digest !== policyBundleDigest) warnings.push(POLICY_BUNDLE_DRIFT_WARNING);
303
307
  return { ok: problems.length === 0, problems, warnings };
@@ -59,7 +59,7 @@ function scalar(raw) {
59
59
  if (/[\[\]{}]/.test(value)) fail("invalid YAML scalar");
60
60
  return value.replace(/\s+#.*$/, "");
61
61
  }
62
- function parsePnpmYaml(text) {
62
+ export function parsePnpmYaml(text) {
63
63
  const root = Object.create(null), stack = [{ indent: -1, value: root }];
64
64
  for (const line of text.split(/\r?\n/)) {
65
65
  if (!line.trim() || /^\s*#/.test(line)) continue;
@@ -55,8 +55,9 @@ export function ownershipVerdict(report, trust) {
55
55
  const scannedRoots = report.metadata.roots?.map((entry) => entry.root) ?? [report.metadata.pathPrefix?.replace(/\/$/, "") || "."];
56
56
  if (!noRegistry && (new Set(scannedRoots).size !== scannedRoots.length || sha256([...scannedRoots].sort()) !== sha256([...registration.registration.roots].sort())))
57
57
  return refuse("RK_OWNERSHIP_INVALID_REGISTRATION", ["scan roots do not exactly cover protected repository registration"]);
58
- const floor = noRegistry ? { ok: true, problems: [] } : validateOwnershipFloor(trust, report.metadata);
58
+ const floor = noRegistry ? { ok: true, problems: [], warnings: [] } : validateOwnershipFloor(trust, report.metadata);
59
59
  if (!floor.ok) return refuse("RK_OWNERSHIP_POLICY_DOWNGRADE", floor.problems);
60
+ warnings.push(...(floor.warnings ?? []));
60
61
  const incomplete = [...report.coverage, ...report.metadata.coverage].filter((entry) => entry.status !== "complete");
61
62
  if (incomplete.length) return refuse("RK_OWNERSHIP_INCOMPLETE_COVERAGE", [...new Set(incomplete.map((entry) => entry.path + ": " + entry.reason))]);
62
63
  const blocking = report.violations.filter(isBlockingFinding);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
package/release-state.mjs CHANGED
@@ -6,6 +6,7 @@ import { readCachePolicy, resolveCacheLayout } from "./cache-policy.mjs";
6
6
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
7
7
  import { bufferQaFindings } from "./qa-control-scan.mjs";
8
8
  import { sealedManifestAllowsContentDevtools } from "./devtools-policy.mjs";
9
+ import { assertManifestRegistryEvidence } from "./rightkit-registry-resolver.mjs";
9
10
 
10
11
  export function cacheFingerprint({ cargoLockSha256, rustc, target, architecture, features = [] }) {
11
12
  const payload = JSON.stringify({
@@ -117,13 +118,14 @@ export function releaseEnvironment({ root, cacheRoot, platform, architecture, ap
117
118
  };
118
119
  }
119
120
 
120
- export function verifySealedRelease(sealedDir) {
121
+ export function verifySealedRelease(sealedDir, options = {}) {
121
122
  const manifestPath = path.join(sealedDir, "release-manifest.json");
122
123
  if (!existsSync(manifestPath)) throw new Error(`sealed manifest missing: ${manifestPath}`);
123
124
  const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
124
125
  if (manifest.schema !== 1) throw new Error(`unsupported sealed manifest schema: ${manifest.schema}`);
125
126
  if (manifest.kind != null && typeof manifest.kind !== "string") throw new Error("sealed manifest kind must be a string when present");
126
127
  if (!manifest.checkpoints?.includes("sealed")) throw new Error("release is not sealed");
128
+ assertManifestRegistryEvidence(manifest, options.legacyReleaseIds ? { legacyReleaseIds: options.legacyReleaseIds } : undefined);
127
129
  for (const item of manifest.files ?? []) {
128
130
  if (typeof item.name !== "string" || path.basename(item.name) !== item.name || item.name === "." || item.name === "..") throw new Error("unsafe sealed artifact name");
129
131
  const file = path.join(sealedDir, item.name);
package/release.mjs CHANGED
@@ -25,6 +25,7 @@ import { MSIX_SIGNING_CONTRACT, assertValidMsixTarget } from "./msix-config.mjs"
25
25
  import { buildMsix } from "./msix-build.mjs";
26
26
  import { msixBuildOptions } from "./msix-target.mjs";
27
27
  import { rightKitVersionAllowed } from "./rightkit-version-policy.mjs";
28
+ import { RightKitRegistryError, SNAPSHOT_ENV, loadAdmittedSnapshot, prepareRightKitResolution, rightKitLockInventory } from "./rightkit-registry-resolver.mjs";
28
29
  import { collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
29
30
  import { patchTauriBundleType } from "./tauri-bundle-marker.mjs";
30
31
  import { verifyNsisEmbeddedBinary } from "./nsis-payload.mjs";
@@ -73,7 +74,16 @@ const RIGHTKIT_ALLOWED = buildAllowedVersions(
73
74
  RIGHTKIT_VERSIONS.legacyNpm,
74
75
  RIGHTKIT_VERSIONS.registry?.npm,
75
76
  );
76
- const RIGHTKIT_CARGO_ALLOWED = buildAllowedVersions(RIGHTKIT_VERSIONS.cargo, RIGHTKIT_VERSIONS.stagedCargo, RIGHTKIT_VERSIONS.legacyCargo);
77
+ const RIGHTKIT_CARGO_ALLOWED = buildAllowedVersions(
78
+ RIGHTKIT_VERSIONS.cargo,
79
+ RIGHTKIT_VERSIONS.stagedCargo,
80
+ RIGHTKIT_VERSIONS.legacyCargo,
81
+ RIGHTKIT_VERSIONS.registry?.cargo,
82
+ );
83
+ const RIGHTKIT_OWNERSHIP = JSON.parse(readFileSync(path.resolve(TOOL_ROOT, "rightkit-ownership.json"), "utf8"));
84
+ // Exact RightKit versions proven against the canonical registries for THIS app's locks (see rightkit-registry-resolver.mjs).
85
+ // Exact membership only: a verified version never widens the bundled min..max range.
86
+ let rightKitVerifiedNpm = new Map();
77
87
  const FORBIDDEN_RIGHTKIT_SPEC = /^(?:git|file|link|workspace):|github|github\.com/i;
78
88
  const WINDOWS_NSIS_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-nsis-v1";
79
89
  const WINDOWS_PORTABLE_SIGNING_CONTRACT = "windows-raw-exe-authenticode-before-portable-v1";
@@ -135,6 +145,7 @@ for (let i = 0; i < args.length; i++) {
135
145
  else if (arg === "--skip-checks") opts.skipChecks = true;
136
146
  else if (arg === "--skip-hardening") fail("--skip-hardening is forbidden; use exact artifact/source-bound hardeningAllowances");
137
147
  else if (arg === "--doctor") opts.doctor = true;
148
+ else if (arg === "--admission") opts.admission = true;
138
149
  else if (arg === "--resume") opts.resume = true;
139
150
  else if (arg === "--resume-from") { opts.resume = true; opts.resumeFrom = args[++i]; }
140
151
  else if (arg === "--tier") opts.tier = args[++i];
@@ -190,11 +201,59 @@ const nativeIos = opts.platform === "ios" && !hasDefaultTauriManifest({ appRoot:
190
201
  const nativeLayout = nativeIos ? null : resolveNativeCargoLayout({ appRoot: root, config });
191
202
  const nativeAssembly = config.nativeAssembly;
192
203
  const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
204
+ // RightKit pins are proven against the canonical registries once per release, at build admission by the controller
205
+ // (build-release.mjs). This worker, a resume and the upload consume only that admitted snapshot, through the release's own
206
+ // state root and admission record; a snapshot from anywhere else is refused. The standalone doctor resolves live.
207
+ const rightKitSnapshotFile = process.env[SNAPSHOT_ENV] || null;
208
+ let rightKitResolution;
209
+ try {
210
+ const lockInventory = () => {
211
+ try {
212
+ const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
213
+ let canonicalTarget = config.targets?.[opts.platform];
214
+ try { canonicalTarget = expandPackageRecipeTarget(canonicalTarget, { platform: opts.platform, appRoot: root, config }); } catch { /* reported later */ }
215
+ const inputs = resolveReleaseBuildInputs({ repoRoot, appRoot: root, configPath, config, target: canonicalTarget });
216
+ return rightKitLockInventory({ requiredInputs: inputs.requiredInputs, cargoLockPaths: inputs.cargoLockPaths, nativeLayout: inputs.nativeLayout, config, root });
217
+ } catch {
218
+ return rightKitLockInventory({ nativeLayout, config, root });
219
+ }
220
+ };
221
+ let boundSnapshot = null;
222
+ if (!opts.doctor) {
223
+ if (!rightKitSnapshotFile) throw new RightKitRegistryError("no-admission", "no admitted RightKit registry snapshot was handed to the release worker; run it through `right-release build`, which admits the release");
224
+ const stateRoot = process.env.RIGHT_RELEASE_STATE_ROOT;
225
+ if (!stateRoot) throw new RightKitRegistryError("no-admission", "RIGHT_RELEASE_STATE_ROOT is not set; the admitted RightKit registry snapshot cannot be located");
226
+ const commit = git(root, ["rev-parse", "HEAD"]);
227
+ boundSnapshot = loadAdmittedSnapshot(stateRoot, { releaseId: `${config.app}-${config.version}-${commit.slice(0, 8)}`, commit, platform: opts.platform, snapshotFile: rightKitSnapshotFile });
228
+ }
229
+ rightKitResolution = await prepareRightKitResolution({
230
+ root,
231
+ extraLocks: lockInventory(),
232
+ versions: RIGHTKIT_VERSIONS,
233
+ ownership: RIGHTKIT_OWNERSHIP,
234
+ releaseVersion: VERSION,
235
+ boundSnapshot,
236
+ // The doctor reports newer versions; run as the admission gate (--admission) it refuses a pin behind latest.
237
+ admission: opts.doctor && opts.admission,
238
+ });
239
+ } catch (error) {
240
+ if (!(error instanceof RightKitRegistryError)) throw error;
241
+ fail(`${config.app ?? path.basename(root)} ${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
242
+ }
243
+ {
244
+ const { newer, tasks, warnings, notes } = rightKitResolution.freshness;
245
+ const out = opts.doctor ? console.log : console.error;
246
+ for (const line of newer) out(`right-release: newer RightKit version (informational): ${line}`);
247
+ for (const line of tasks) out(`right-release: LANE TASK ${line}`);
248
+ for (const line of warnings) console.error(`right-release: WARNING ${line}`);
249
+ for (const line of notes) console.error(`right-release: note ${line}`);
250
+ }
251
+ rightKitVerifiedNpm = rightKitResolution.verified.npm;
193
252
  // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
194
253
  if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
195
254
  await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
196
255
  }
197
- validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
256
+ validateRightKitCargoContract(root, mergeVerifiedVersions(RIGHTKIT_CARGO_ALLOWED, rightKitResolution.verified.cargo), config.app ?? path.basename(root));
198
257
  // One standard dev profile and panic = "unwind" in every app (templates/cargo-profiles.toml).
199
258
  assertCargoProfileContract(root, config.app ?? path.basename(root));
200
259
  // RightKit QA is mandatory for every app (also SwiftPM apps without a package.json): no QA lane refuses the release.
@@ -324,6 +383,7 @@ if (opts.doctor) {
324
383
  console.log(`platform: ${opts.platform}`);
325
384
  console.log(`tier: ${opts.tier ?? "<required for release/publish>"}`);
326
385
  console.log(`packageManager: ${config.packageManager}`);
386
+ console.log(`rightkit-registry: ${rightKitResolution.summary}`);
327
387
  console.log(`workdir: ${workdir}`);
328
388
  console.log(`hardeningscan: ${HARDENING_SCAN}`);
329
389
  if (sccacheVersion) console.log(`sccache: ${sccacheVersion}`);
@@ -1050,12 +1110,13 @@ async function validateRightKitPackageContract(root, appName, hostedWorkflows) {
1050
1110
  fail(`${appName ?? pkg.name ?? "app"} ${name} must come from the published npm package, not ${specifier}`);
1051
1111
  }
1052
1112
  const allowed = RIGHTKIT_ALLOWED.get(name);
1053
- if (allowed && !rightKitVersionAllowed(allowed, specifier)) {
1113
+ if (allowed && !rightKitVerifiedNpm.get(name)?.has(specifier) && !rightKitVersionAllowed(allowed, specifier)) {
1054
1114
  fail(`${appName ?? pkg.name ?? "app"} ${name} must be one of ${[...allowed].join(", ")}, got ${specifier}`);
1055
1115
  }
1056
1116
  }
1057
1117
  const releaseAllowed = RIGHTKIT_ALLOWED.get("@rightkit/release");
1058
- if (!releaseAllowed || !rightKitVersionAllowed(releaseAllowed, pkg.devDependencies?.["@rightkit/release"])) {
1118
+ const releaseSpecifier = pkg.devDependencies?.["@rightkit/release"];
1119
+ if (!releaseAllowed || !(rightKitVerifiedNpm.get("@rightkit/release")?.has(releaseSpecifier) || rightKitVersionAllowed(releaseAllowed, releaseSpecifier))) {
1059
1120
  fail(`${appName ?? pkg.name ?? "app"} must depend on a supported @rightkit/release version in devDependencies`);
1060
1121
  }
1061
1122
  }
@@ -1459,6 +1520,16 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1459
1520
  }
1460
1521
  }
1461
1522
 
1523
+ function mergeVerifiedVersions(bundled, verified) {
1524
+ const merged = new Map([...bundled].map(([name, versions]) => [name, new Set(versions)]));
1525
+ for (const [name, versions] of verified) {
1526
+ const set = merged.get(name) ?? new Set();
1527
+ for (const version of versions) set.add(version);
1528
+ merged.set(name, set);
1529
+ }
1530
+ return merged;
1531
+ }
1532
+
1462
1533
  function buildAllowedVersions(published = {}, staged = {}, legacy = {}, registry = {}) {
1463
1534
  const allowed = new Map();
1464
1535
  for (const [name, value] of [
@@ -0,0 +1,732 @@
1
+ // Registry-bound RightKit pins: at build admission the controller resolves the app's EXACT locked RightKit versions
2
+ // (Cargo.lock entries, pnpm-lock.yaml resolutions) against the canonical registries, so a new version of a known
3
+ // RightKit package needs no new release of this tool.
4
+ //
5
+ // What admission checks, per exact locked name+version of a RightKit-OWNED package (known names only; there is no
6
+ // maintainer-membership proof, so a name first published after this tool is refused until the ownership data lists it):
7
+ // - ownership: named in the bundled ownership/inventory data (a rightkit-* or @rightkit/* prefix alone is not enough)
8
+ // - source: canonical crates.io / npmjs.org registry resolution only (no path, git, tarball or directory overrides)
9
+ // - policy: not revoked, not below the recorded minimum (rightkit-versions.json keeps those policies)
10
+ // - registry: the exact version exists, is not yanked (crates.io) / deprecated (npm), and the registry digest
11
+ // (Cargo index `cksum`, npm `dist.integrity`) equals the lock's digest. That is METADATA agreement between the
12
+ // registry and the lock; it is not proof of the bytes a build consumes (nothing here materializes or hashes packages)
13
+ // - freshness (build admission only): a pin behind the registry's latest (crates.io max_stable_version, npm
14
+ // dist-tags.latest) is refused. A .rightkit/update.json hold is a migration lane task and grants no exception
15
+ // - coverage: every RightKit dependency a manifest declares needs a lock entry for that package name somewhere in the
16
+ // canonical lock inventory (a name-level check, not importer-specific proof)
17
+ // Registry existence proves publication only; every other qualification requirement stays separate.
18
+ //
19
+ // TRUST SCOPE: the admitted snapshot, its admission record and the release-inputs payload are plain JSON files in the
20
+ // release state root. Reuse (worker, resume, seal) checks that they agree with each other, with this release's identity
21
+ // and with the current locks. That is a TRUSTED-LOCAL consistency check, not an authenticated provenance boundary:
22
+ // anyone who can write the state root can forge a consistent set. A resume therefore proves "the recorded admission
23
+ // is internally consistent and still matches the locks", and does NOT prove the registry still says so or that the
24
+ // controller wrote it. Nothing here rewrites a pin.
25
+ import { createHash } from "node:crypto";
26
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
27
+ import path from "node:path";
28
+ import { parse as parseToml } from "smol-toml";
29
+ import { isExcludedScanDirectory } from "./cargo-contract.mjs";
30
+ import { resolveSharedCacheRoot } from "./cache-policy.mjs";
31
+ import { parsePnpmYaml } from "./ownership-lockfiles.mjs";
32
+ import { compareVersions } from "./rightkit-sync.mjs";
33
+
34
+ export const SNAPSHOT_SCHEMA = 1;
35
+ export const SNAPSHOT_ENV = "RIGHT_RELEASE_RIGHTKIT_SNAPSHOT";
36
+ export const EVIDENCE_VERSION = 1;
37
+ export const REGISTRIES = Object.freeze({
38
+ cargo: "https://index.crates.io/",
39
+ cargoApi: "https://crates.io/api/v1/crates/",
40
+ npm: "https://registry.npmjs.org/",
41
+ });
42
+ const CRATES_IO_SOURCES = new Set(["registry+https://github.com/rust-lang/crates.io-index", "registry+https://index.crates.io/"]);
43
+ const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
44
+ const USER_AGENT = "right-release-resolver/1 (https://github.com/bogusyogi/rightkit)";
45
+ const FIX = "run `right-update run` (@rightkit/outdated 0.2.0) to move the pin";
46
+ const DEFAULT_ATTEMPTS = 3;
47
+ const DEFAULT_TIMEOUT_MS = 15000;
48
+ const CONCURRENCY = 6;
49
+
50
+ export class RightKitRegistryError extends Error {
51
+ constructor(code, message, { recoverable = false, problems = [] } = {}) {
52
+ super(message);
53
+ this.name = "RightKitRegistryError";
54
+ this.code = code;
55
+ this.recoverable = recoverable;
56
+ this.problems = problems;
57
+ }
58
+ }
59
+
60
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
61
+ const stable = (value) => {
62
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
63
+ if (value && typeof value === "object") return `{${Object.keys(value).sort().filter((k) => value[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
64
+ return JSON.stringify(value);
65
+ };
66
+
67
+ // ---------------------------------------------------------------------------------------------- ownership + policy
68
+
69
+ /** Owned names per registry, from the bundled versions file and ownership inventory. */
70
+ export function ownedRightKitNames(versions = {}, ownership = {}) {
71
+ const owned = { cargo: new Set(), npm: new Set() };
72
+ for (const key of ["npm", "stagedNpm", "legacyNpm"]) for (const name of Object.keys(versions[key] ?? {})) owned.npm.add(name);
73
+ for (const name of Object.keys(versions.registry?.npm ?? {})) owned.npm.add(name);
74
+ for (const key of ["cargo", "stagedCargo", "legacyCargo"]) for (const name of Object.keys(versions[key] ?? {})) owned.cargo.add(name);
75
+ for (const name of Object.keys(versions.registry?.cargo ?? {})) owned.cargo.add(name);
76
+ for (const entry of ownership.capabilities ?? []) {
77
+ if (typeof entry?.package !== "string") continue;
78
+ (entry.package.startsWith("@") ? owned.npm : owned.cargo).add(entry.package);
79
+ }
80
+ return owned;
81
+ }
82
+
83
+ const prefixed = (ecosystem, name) => (ecosystem === "cargo" ? name.startsWith("rightkit-") : name.startsWith("@rightkit/"));
84
+
85
+ function recordedVersions(versions, ecosystem, name) {
86
+ const keys = ecosystem === "npm" ? ["npm", "stagedNpm", "legacyNpm"] : ["cargo", "stagedCargo", "legacyCargo"];
87
+ const found = new Set();
88
+ for (const value of [...keys.map((key) => versions[key]?.[name]), versions.registry?.[ecosystem]?.[name]]) {
89
+ for (const version of Array.isArray(value) ? value : value === undefined ? [] : [value]) if (typeof version === "string" && EXACT_SEMVER.test(version)) found.add(version);
90
+ }
91
+ return [...found].sort(compareVersions);
92
+ }
93
+
94
+ /** Policy that survives the move to registry resolution: recorded minimum and explicit revocations. */
95
+ function policyProblem(versions, ecosystem, name, version) {
96
+ const revoked = versions.revoked?.[ecosystem]?.[name];
97
+ if ((Array.isArray(revoked) ? revoked : revoked === undefined ? [] : [revoked]).includes(version)) return `${name} ${version} is revoked by release policy`;
98
+ const known = recordedVersions(versions, ecosystem, name);
99
+ if (known.length && EXACT_SEMVER.test(version) && compareVersions(version, known[0]) < 0) return `${name} ${version} is below the recorded minimum ${known[0]}`;
100
+ return null;
101
+ }
102
+
103
+ export function policyRevision(versions, releaseVersion = "") {
104
+ return sha256(stable({ versions, releaseVersion })).slice(0, 16);
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------------------------- lockfiles
108
+
109
+ function scanFiles(root, wanted) {
110
+ const found = [];
111
+ const visit = (dir) => {
112
+ let entries;
113
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
114
+ for (const entry of entries) {
115
+ if (entry.isDirectory()) {
116
+ if (!isExcludedScanDirectory(entry.name, path.basename(dir))) visit(path.join(dir, entry.name));
117
+ } else if (entry.isFile() && wanted.has(entry.name)) found.push(path.join(dir, entry.name));
118
+ }
119
+ };
120
+ visit(root);
121
+ return found.sort();
122
+ }
123
+
124
+ function cargoLockEntries(file) {
125
+ let doc;
126
+ try { doc = parseToml(readFileSync(file, "utf8")); } catch (error) { throw new RightKitRegistryError("bad-lock", `${file} is not a readable Cargo.lock: ${error.message}`); }
127
+ return (doc.package ?? []).map((pkg) => ({ ecosystem: "cargo", name: pkg.name, version: pkg.version, source: pkg.source ?? null, digest: pkg.checksum ? { algo: "sha256", value: String(pkg.checksum).toLowerCase() } : null, lockFile: file }));
128
+ }
129
+
130
+ function pnpmLockEntries(file) {
131
+ let lock;
132
+ try { lock = parsePnpmYaml(readFileSync(file, "utf8")); } catch (error) { throw new RightKitRegistryError("bad-lock", `${file} is not a readable pnpm-lock.yaml: ${error.message}`); }
133
+ const entries = [];
134
+ for (const [key, meta] of Object.entries(lock.packages ?? {})) {
135
+ const match = /^(@[^/]+\/[^@]+|[^@]+)@(.+)$/.exec(String(key).replace(/^\//, ""));
136
+ if (!match) continue;
137
+ const resolution = meta && typeof meta === "object" ? meta.resolution : undefined;
138
+ const registryOnly = resolution && typeof resolution === "object" && typeof resolution.integrity === "string"
139
+ && !resolution.tarball && !resolution.directory && !resolution.type && !resolution.repo;
140
+ entries.push({
141
+ ecosystem: "npm", name: match[1], version: match[2].split(/[(_]/)[0],
142
+ source: registryOnly ? "registry" : null,
143
+ digest: registryOnly ? { algo: "integrity", value: resolution.integrity } : null,
144
+ lockFile: file,
145
+ });
146
+ }
147
+ return entries;
148
+ }
149
+
150
+ /** Every RightKit-candidate lock entry (owned, or carrying a RightKit prefix) under root. */
151
+ export function collectLockedRightKit(root, owned, { extraLocks = [] } = {}) {
152
+ const lockFiles = [...new Set([...scanFiles(root, new Set(["Cargo.lock", "pnpm-lock.yaml"])), ...extraLocks.map((f) => path.resolve(f)).filter((f) => existsSync(f) && /^(?:Cargo\.lock|pnpm-lock\.yaml)$/.test(path.basename(f)))])];
153
+ const entries = [];
154
+ for (const file of lockFiles) {
155
+ const all = path.basename(file) === "Cargo.lock" ? cargoLockEntries(file) : pnpmLockEntries(file);
156
+ for (const entry of all) if (owned[entry.ecosystem].has(entry.name) || prefixed(entry.ecosystem, entry.name)) entries.push(entry);
157
+ }
158
+ return { lockFiles, entries };
159
+ }
160
+
161
+ const MANIFEST_DEP_TABLES = ["dependencies", "dev-dependencies", "build-dependencies"];
162
+ const NPM_DEP_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"];
163
+
164
+ /** Directories whose manifests need lock proof: the app root plus the directory of every canonical lock outside it. */
165
+ function manifestRoots(root, extraLocks) {
166
+ const roots = [path.resolve(root)];
167
+ for (const lock of extraLocks) {
168
+ const dir = path.dirname(path.resolve(lock));
169
+ if (!roots.some((r) => dir === r || dir.startsWith(r + path.sep))) roots.push(dir);
170
+ }
171
+ return roots;
172
+ }
173
+
174
+ /** RightKit dependencies the manifests under root declare (Cargo.toml, package.json): the inputs that need lock proof. */
175
+ export function declaredRightKitDependencies(root, owned, extraLocks = []) {
176
+ const found = [];
177
+ const take = (ecosystem, name, file) => { if (owned[ecosystem].has(name) || prefixed(ecosystem, name)) found.push({ ecosystem, name, file }); };
178
+ for (const file of manifestRoots(root, extraLocks).flatMap((dir) => scanFiles(dir, new Set(["Cargo.toml", "package.json"])))) {
179
+ try {
180
+ if (path.basename(file) === "package.json") {
181
+ const pkg = JSON.parse(readFileSync(file, "utf8"));
182
+ // A git/file/link/workspace specifier never resolves from the registry; the package-contract validator refuses it by name.
183
+ for (const section of NPM_DEP_SECTIONS) for (const [name, spec] of Object.entries(pkg[section] ?? {})) if (!/[:/]/.test(String(spec))) take("npm", name, file);
184
+ } else {
185
+ const doc = parseToml(readFileSync(file, "utf8"));
186
+ const tables = [...MANIFEST_DEP_TABLES.map((t) => doc[t]), doc.workspace?.dependencies, ...Object.values(doc.target ?? {}).flatMap((target) => MANIFEST_DEP_TABLES.map((t) => target?.[t]))];
187
+ for (const table of tables) for (const [key, spec] of Object.entries(table ?? {})) take("cargo", typeof spec === "object" && spec && typeof spec.package === "string" ? spec.package : key, file);
188
+ }
189
+ } catch { /* an unreadable manifest is the manifest validators' finding */ }
190
+ }
191
+ return found;
192
+ }
193
+
194
+ /** Declared RightKit dependencies that no lock entry proves: no bundled-only fallback. */
195
+ export function coverageProblems(declared, lockedEntries) {
196
+ const have = new Set(lockedEntries.map((e) => `${e.ecosystem}\0${e.name}`));
197
+ return [...new Set(declared.filter((d) => !have.has(`${d.ecosystem}\0${d.name}`)).map((d) => `${path.basename(d.file)} declares ${d.name} but no ${d.ecosystem === "cargo" ? "Cargo.lock" : "pnpm-lock.yaml"} entry proves it (${d.file}); a missing or incomplete lockfile cannot be admitted`))];
198
+ }
199
+
200
+ /** Pre-network refusals for one lock entry. */
201
+ function lockProblems(entry, owned, versions) {
202
+ const label = `${entry.name} ${entry.version}`;
203
+ const where = `(${path.basename(entry.lockFile)})`;
204
+ if (!owned[entry.ecosystem].has(entry.name)) return [`${label} ${where} is not a RightKit-owned package in the ownership data (a ${entry.ecosystem === "cargo" ? "rightkit-" : "@rightkit/"} prefix alone does not establish ownership)`];
205
+ const problems = [];
206
+ if (entry.ecosystem === "cargo" ? !CRATES_IO_SOURCES.has(entry.source) : entry.source !== "registry") problems.push(`${label} ${where} does not resolve from the canonical ${entry.ecosystem === "cargo" ? "crates.io" : "npm"} registry (path, git, tarball and directory sources are refused)`);
207
+ if (!EXACT_SEMVER.test(String(entry.version))) problems.push(`${label} ${where} is not an exact version`);
208
+ if (!entry.digest) problems.push(`${label} ${where} has no ${entry.ecosystem === "cargo" ? "checksum" : "integrity"} in the lock`);
209
+ const policy = policyProblem(versions, entry.ecosystem, entry.name, entry.version);
210
+ if (policy) problems.push(policy);
211
+ return problems;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------------------------- registry client
215
+
216
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
217
+
218
+ function makeClient({ fetchImpl, sleep, attempts, timeoutMs }) {
219
+ async function getJson(url, { accept = "application/json", text = false } = {}) {
220
+ let last;
221
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
222
+ try {
223
+ const response = await fetchImpl(url, { headers: { accept, "user-agent": USER_AGENT }, signal: AbortSignal.timeout(timeoutMs) });
224
+ if (response.status === 404) return { status: 404 };
225
+ if (response.status === 429 || response.status >= 500) { last = new Error(`HTTP ${response.status}`); }
226
+ else if (!response.ok) throw Object.assign(new Error(`HTTP ${response.status}`), { final: true });
227
+ else return { status: 200, body: text ? await response.text() : await response.json() };
228
+ } catch (error) {
229
+ if (error?.final) throw new RightKitRegistryError("registry-outage", `${url} answered ${error.message}`, { recoverable: true });
230
+ last = error;
231
+ }
232
+ if (attempt < attempts) await sleep(250 * 4 ** (attempt - 1));
233
+ }
234
+ throw new RightKitRegistryError("registry-outage", `${url} unreachable after ${attempts} attempts: ${last?.message ?? last}`, { recoverable: true });
235
+ }
236
+ return { getJson };
237
+ }
238
+
239
+ function cargoIndexUrl(name) {
240
+ const n = name.toLowerCase();
241
+ const dir = n.length === 1 ? "1" : n.length === 2 ? "2" : n.length === 3 ? `3/${n[0]}` : `${n.slice(0, 2)}/${n.slice(2, 4)}`;
242
+ return `${REGISTRIES.cargo}${dir}/${n}`;
243
+ }
244
+ const npmUrl = (name) => `${REGISTRIES.npm}${name.replace("/", "%2f")}`;
245
+
246
+ // ---------------------------------------------------------------------------------------------- cache (external storage only)
247
+
248
+ /** Cache root under the release tool's configured shared cache root; null when none is configured (never internal disk). */
249
+ export function defaultRegistryCacheDir(env = process.env, platform = process.platform) {
250
+ try { return path.join(resolveSharedCacheRoot({ env, platform }), "rightkit-registry"); } catch { return null; }
251
+ }
252
+
253
+ function makeCache(dir, warn) {
254
+ const file = (...parts) => path.join(dir, ...parts.map((p) => p.replaceAll("/", "%2f").replaceAll("..", "_")));
255
+ return {
256
+ dir,
257
+ read(...parts) {
258
+ if (!dir) return null;
259
+ try { return JSON.parse(readFileSync(`${file(...parts)}.json`, "utf8")); } catch { return null; }
260
+ },
261
+ write(record, ...parts) {
262
+ if (!dir) return;
263
+ try {
264
+ const target = `${file(...parts)}.json`;
265
+ mkdirSync(path.dirname(target), { recursive: true });
266
+ const temp = `${target}.tmp-${process.pid}`;
267
+ writeFileSync(temp, `${JSON.stringify(record)}\n`);
268
+ renameSync(temp, target);
269
+ } catch (error) { warn(`registry cache not written (${error.message})`); }
270
+ },
271
+ };
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------------------------- resolution
275
+
276
+ /**
277
+ * Resolve every distinct exact RightKit lock entry once. Returns the snapshot; throws RightKitRegistryError.
278
+ * Options are injectable for tests: fetchImpl, sleep, now, cacheDir.
279
+ */
280
+ export async function resolveRightKitPins({
281
+ root, extraLocks = [], versions, ownership, releaseVersion = "",
282
+ fetchImpl = globalThis.fetch, sleep = defaultSleep, now = () => Date.now(),
283
+ cacheDir = defaultRegistryCacheDir(), requireLiveLatest = false, attempts = DEFAULT_ATTEMPTS, timeoutMs = DEFAULT_TIMEOUT_MS,
284
+ warn = (message) => console.error(`right-release: ${message}`),
285
+ }) {
286
+ const owned = ownedRightKitNames(versions, ownership);
287
+ const { lockFiles, entries: locked } = collectLockedRightKit(root, owned, { extraLocks });
288
+ const problems = [...locked.flatMap((entry) => lockProblems(entry, owned, versions)), ...coverageProblems(declaredRightKitDependencies(root, owned, extraLocks), locked)];
289
+ if (problems.length) throw new RightKitRegistryError("refused", `RightKit pins refused:\n${[...new Set(problems)].map((p) => `- ${p}`).join("\n")}`, { problems });
290
+
291
+ const distinct = new Map();
292
+ for (const entry of locked) {
293
+ const key = `${entry.ecosystem}\0${entry.name}\0${entry.version}`;
294
+ const known = distinct.get(key);
295
+ if (!known) distinct.set(key, { ...entry, lockFiles: [entry.lockFile] });
296
+ else if (known.digest.value !== entry.digest.value) problems.push(`${entry.name} ${entry.version} is locked with two different digests (${known.lockFiles[0]} and ${entry.lockFile})`);
297
+ else known.lockFiles.push(entry.lockFile);
298
+ }
299
+ if (problems.length) throw new RightKitRegistryError("digest-mismatch", `RightKit pins refused:\n${problems.map((p) => `- ${p}`).join("\n")}`, { problems });
300
+
301
+ const client = makeClient({ fetchImpl, sleep, attempts, timeoutMs });
302
+ const cache = makeCache(cacheDir, warn);
303
+ const observedAt = new Date(now()).toISOString();
304
+ const packuments = new Map();
305
+ const packument = (name) => { if (!packuments.has(name)) packuments.set(name, client.getJson(npmUrl(name), { accept: "application/vnd.npm.install-v1+json" })); return packuments.get(name); };
306
+ const indexes = new Map();
307
+ const index = (name) => { if (!indexes.has(name)) indexes.set(name, client.getJson(cargoIndexUrl(name), { text: true })); return indexes.get(name); };
308
+
309
+ const refused = [];
310
+ const outages = [];
311
+ const verified = [];
312
+ async function verify(entry) {
313
+ const label = `${entry.name} ${entry.version}`;
314
+ let registryDigest;
315
+ try {
316
+ if (entry.ecosystem === "cargo") {
317
+ const response = await index(entry.name);
318
+ if (response.status === 404) { refused.push(`${label} is not published on crates.io (no index entry for ${entry.name})`); return; }
319
+ const rows = String(response.body).split("\n").filter(Boolean).map((line) => { try { return JSON.parse(line); } catch { return null; } });
320
+ const row = rows.find((candidate) => candidate?.vers === entry.version);
321
+ if (!row) { refused.push(`${label} is not published on crates.io (staged or unpublished version)`); return; }
322
+ if (row.yanked) { refused.push(`${label} is yanked on crates.io`); return; }
323
+ registryDigest = String(row.cksum ?? "").toLowerCase();
324
+ } else {
325
+ const response = await packument(entry.name);
326
+ if (response.status === 404) { refused.push(`${label} is not published on npm (no package ${entry.name})`); return; }
327
+ const doc = response.body?.versions?.[entry.version];
328
+ if (!doc) { refused.push(`${label} is not published on npm (staged or unpublished version)`); return; }
329
+ if (doc.deprecated) { refused.push(`${label} is deprecated on npm: ${String(doc.deprecated).slice(0, 200)}`); return; }
330
+ registryDigest = doc.dist?.integrity ?? "";
331
+ }
332
+ } catch (error) {
333
+ if (!(error instanceof RightKitRegistryError) || !error.recoverable) throw error;
334
+ outages.push({ label, message: error.message });
335
+ return;
336
+ }
337
+ if (!registryDigest || registryDigest !== entry.digest.value) {
338
+ refused.push(`${label} ${entry.ecosystem === "cargo" ? "checksum" : "integrity"} mismatch: lock has ${entry.digest.value}, registry has ${registryDigest || "none"}`);
339
+ return;
340
+ }
341
+ verified.push({ ...entryRecord(entry), registryDigest, verifiedAt: observedAt, via: "live" });
342
+ }
343
+ await pool([...distinct.values()], verify);
344
+ if (refused.length) throw new RightKitRegistryError("refused", `RightKit pins refused:\n${[...new Set(refused)].map((p) => `- ${p}`).join("\n")}`, { problems: refused });
345
+ if (outages.length) {
346
+ throw new RightKitRegistryError("registry-outage", `registry unreachable; cannot prove exact RightKit pins (recoverable: retry; there is no offline override for a fresh admission):\n${outages.map((o) => `- ${o.label}: ${o.message}`).join("\n")}`, { recoverable: true, problems: outages.map((o) => o.label) });
347
+ }
348
+
349
+ // Latest per distinct package. `latest` is ALWAYS the value the registry reports now. The cache keeps a separate
350
+ // high-water mark that only guards against stale or out-of-order answers; it is never reported as the live state.
351
+ const latest = [];
352
+ const freshOutages = [];
353
+ const conflicts = [];
354
+ const names = [...new Map([...distinct.values()].map((e) => [`${e.ecosystem}\0${e.name}`, e])).values()];
355
+ const stillPublished = async (entry, version) => {
356
+ if (entry.ecosystem === "npm") {
357
+ const doc = (await packument(entry.name)).body?.versions?.[version];
358
+ return Boolean(doc) && !doc.deprecated;
359
+ }
360
+ const response = await index(entry.name);
361
+ return String(response.body ?? "").split("\n").filter(Boolean).some((line) => { try { const row = JSON.parse(line); return row.vers === version && !row.yanked; } catch { return false; } });
362
+ };
363
+ await pool(names, async (entry) => {
364
+ try {
365
+ let value;
366
+ if (entry.ecosystem === "npm") {
367
+ value = (await packument(entry.name)).body?.["dist-tags"]?.latest;
368
+ } else {
369
+ const response = await client.getJson(`${REGISTRIES.cargoApi}${entry.name}`);
370
+ value = response.body?.crate?.max_stable_version;
371
+ }
372
+ if (typeof value !== "string" || !EXACT_SEMVER.test(value)) throw new RightKitRegistryError("registry-outage", `${entry.name} registry answer carries no usable latest version`, { recoverable: true });
373
+ const previous = cache.read("latest", entry.ecosystem, entry.name);
374
+ const mark = previous?.highWater?.version ?? previous?.latest;
375
+ const row = { ecosystem: entry.ecosystem, name: entry.name, latest: value, observedAt, via: "live" };
376
+ let highWater = { version: value, observedAt };
377
+ if (typeof mark === "string" && EXACT_SEMVER.test(mark) && compareVersions(mark, value) > 0) {
378
+ if (entry.ecosystem === "npm" && await stillPublished(entry, mark)) {
379
+ // An npm `latest` dist-tag is set by the maintainer and may be moved lower on purpose while the higher version stays
380
+ // published: follow the registry. The high-water mark only protects against event-order downgrades of derived answers.
381
+ row.moved = { from: mark, observedAt: previous?.highWater?.observedAt ?? previous?.observedAt };
382
+ } else if (await stillPublished(entry, mark)) {
383
+ // The higher version is still published, so the lower answer is stale or out of order: keep the mark, never downgrade.
384
+ conflicts.push(`${entry.name}: registry reports latest ${value} but ${mark} (observed ${previous?.highWater?.observedAt ?? previous?.observedAt}) is still published; the answer is stale or out of order`);
385
+ highWater = { version: mark, observedAt: previous?.highWater?.observedAt ?? previous?.observedAt };
386
+ row.conflict = { highWater: mark };
387
+ } else {
388
+ // The previously observed version was withdrawn (yanked, deprecated or unpublished): the live state wins, no cache deletion needed.
389
+ row.withdrawn = { version: mark, observedAt: previous?.highWater?.observedAt ?? previous?.observedAt };
390
+ }
391
+ }
392
+ cache.write({ schema: 2, registry: entry.ecosystem, name: entry.name, latest: value, observedAt, highWater }, "latest", entry.ecosystem, entry.name);
393
+ latest.push(row);
394
+ } catch (error) {
395
+ if (!(error instanceof RightKitRegistryError) || !error.recoverable) throw error;
396
+ const record = cache.read("latest", entry.ecosystem, entry.name);
397
+ if (!requireLiveLatest && record && typeof record.latest === "string") latest.push({ ecosystem: entry.ecosystem, name: entry.name, latest: record.latest, observedAt: record.observedAt, via: "cache", ageMs: Math.max(0, now() - Date.parse(record.observedAt)) });
398
+ else freshOutages.push(`${entry.name}: ${error.message}${record?.latest ? ` (cached latest ${record.latest} observed ${record.observedAt} is last-observed, not current)` : ""}`);
399
+ }
400
+ });
401
+ if (freshOutages.length) throw new RightKitRegistryError("registry-outage", `${requireLiveLatest ? "freshness cannot be established at admission" : "registry unreachable and no cached latest observation"} (recoverable: retry${requireLiveLatest ? "; a cached latest is only last-observed, never current" : ""}):\n${freshOutages.map((p) => `- ${p}`).join("\n")}`, { recoverable: true, problems: freshOutages });
402
+ if (conflicts.length && requireLiveLatest) throw new RightKitRegistryError("eligibility-conflict", `eligibility conflict (recoverable: retry; no cache deletion is needed, a withdrawn version resolves itself):\n${conflicts.map((p) => `- ${p}`).join("\n")}`, { recoverable: true, problems: conflicts });
403
+
404
+ const sort = (rows) => rows.sort((a, b) => `${a.ecosystem}${a.name}${a.version ?? ""}`.localeCompare(`${b.ecosystem}${b.name}${b.version ?? ""}`));
405
+ const snapshot = {
406
+ schema: SNAPSHOT_SCHEMA,
407
+ // admission: latest was read live at build admission; inspection: doctor/informational (never bindable to a build).
408
+ mode: requireLiveLatest ? "admission" : "inspection",
409
+ observedAt,
410
+ registries: { cargo: REGISTRIES.cargo, npm: REGISTRIES.npm },
411
+ policyRevision: policyRevision(versions, releaseVersion),
412
+ lockFiles: lockFiles.map((file) => path.relative(root, file).replaceAll("\\", "/")),
413
+ entries: sort(verified.map((e) => ({ ...e, lockFiles: e.lockFiles.map((file) => path.relative(root, file).replaceAll("\\", "/")) }))),
414
+ latest: sort(latest),
415
+ };
416
+ snapshot.digest = snapshotDigest(snapshot);
417
+ return snapshot;
418
+ }
419
+
420
+ function entryRecord(entry) {
421
+ return { ecosystem: entry.ecosystem, name: entry.name, version: entry.version, lockDigest: entry.digest.value, lockFiles: entry.lockFiles };
422
+ }
423
+
424
+ async function pool(items, task) {
425
+ const queue = [...items];
426
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
427
+ while (queue.length) await task(queue.shift());
428
+ }));
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------------------------- snapshot
432
+
433
+ export function snapshotDigest(snapshot) {
434
+ const { digest, ...rest } = snapshot;
435
+ return sha256(stable(rest));
436
+ }
437
+
438
+ export function writeSnapshot(file, snapshot) {
439
+ mkdirSync(path.dirname(file), { recursive: true });
440
+ const temp = `${file}.tmp-${process.pid}`;
441
+ writeFileSync(temp, `${JSON.stringify(snapshot, null, 2)}\n`);
442
+ renameSync(temp, file);
443
+ }
444
+
445
+ export function loadSnapshot(file) {
446
+ let snapshot;
447
+ try { snapshot = JSON.parse(readFileSync(file, "utf8")); } catch (error) { throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot unreadable (${file}): ${error.message}`); }
448
+ assertSnapshotIntegrity(snapshot);
449
+ return snapshot;
450
+ }
451
+
452
+ const key3 = (e) => `${e.ecosystem}\0${e.name}\0${e.version}`;
453
+
454
+ /** Structure, digest and internal consistency: entries agree with their lock digest, latest covers exactly the entries. */
455
+ export function assertSnapshotIntegrity(snapshot) {
456
+ if (!snapshot || snapshot.schema !== SNAPSHOT_SCHEMA || !Array.isArray(snapshot.entries) || !Array.isArray(snapshot.latest)) throw new RightKitRegistryError("bad-snapshot", "RightKit registry snapshot has an unsupported shape");
457
+ if (snapshot.digest !== snapshotDigest(snapshot)) throw new RightKitRegistryError("bad-snapshot", "RightKit registry snapshot digest does not match its content");
458
+ const problems = [];
459
+ const seen = new Set();
460
+ for (const entry of snapshot.entries) {
461
+ const label = `${entry?.name} ${entry?.version}`;
462
+ if (!["cargo", "npm"].includes(entry?.ecosystem) || typeof entry.name !== "string" || !EXACT_SEMVER.test(String(entry.version))) { problems.push(`malformed entry ${label}`); continue; }
463
+ if (seen.has(key3(entry))) problems.push(`duplicate entry ${label}`);
464
+ seen.add(key3(entry));
465
+ if (typeof entry.lockDigest !== "string" || !entry.lockDigest || entry.registryDigest !== entry.lockDigest) problems.push(`${label}: registry digest does not equal the lock digest`);
466
+ if (entry.via !== "live") problems.push(`${label}: not verified live`);
467
+ }
468
+ const wanted = new Set(snapshot.entries.map((e) => `${e.ecosystem}\0${e.name}`));
469
+ const rows = new Set();
470
+ for (const row of snapshot.latest) {
471
+ const k = `${row?.ecosystem}\0${row?.name}`;
472
+ if (!wanted.has(k)) problems.push(`latest row for ${row?.name} has no verified entry`);
473
+ else if (rows.has(k)) problems.push(`duplicate latest row for ${row.name}`);
474
+ else if (typeof row.latest !== "string" || !EXACT_SEMVER.test(row.latest)) problems.push(`latest row for ${row.name} has no usable version`);
475
+ rows.add(k);
476
+ }
477
+ for (const k of wanted) if (!rows.has(k)) problems.push(`no latest observation for ${k.split("\0")[1]}`);
478
+ if (problems.length) throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot is inconsistent:\n${problems.map((p) => `- ${p}`).join("\n")}`, { problems });
479
+ }
480
+
481
+ /** A snapshot that may be bound to a build: it must be an admission, with every latest read live. */
482
+ export function assertAdmissionSnapshot(snapshot) {
483
+ assertSnapshotIntegrity(snapshot);
484
+ if (snapshot.mode !== "admission") throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot was not taken at build admission (mode ${snapshot.mode ?? "<missing>"})`);
485
+ const stale = snapshot.latest.filter((row) => row.via !== "live").map((row) => row.name);
486
+ if (stale.length) throw new RightKitRegistryError("bad-snapshot", `RightKit registry snapshot carries non-live latest observations: ${stale.join(", ")}`);
487
+ }
488
+
489
+ /** The bound snapshot must describe exactly the current locked RightKit inputs: nothing missing, nothing extra, same digests. */
490
+ export function assertSnapshotCoversLocks(snapshot, { root, versions, ownership, extraLocks = [] }) {
491
+ const owned = ownedRightKitNames(versions, ownership);
492
+ const { entries } = collectLockedRightKit(root, owned, { extraLocks });
493
+ const bound = new Map(snapshot.entries.map((e) => [key3(e), e]));
494
+ const problems = [...coverageProblems(declaredRightKitDependencies(root, owned, extraLocks), entries)];
495
+ const current = new Set();
496
+ for (const entry of entries) {
497
+ problems.push(...lockProblems(entry, owned, versions));
498
+ current.add(key3(entry));
499
+ const match = bound.get(key3(entry));
500
+ if (!match) problems.push(`${entry.name} ${entry.version} is locked but not in the bound registry snapshot`);
501
+ else if (entry.digest && match.lockDigest !== entry.digest.value) problems.push(`${entry.name} ${entry.version} lock digest changed since the bound registry snapshot`);
502
+ }
503
+ for (const e of snapshot.entries) if (!current.has(key3(e))) problems.push(`${e.name} ${e.version} is in the bound registry snapshot but not in the locked inputs`);
504
+ if (problems.length) throw new RightKitRegistryError("snapshot-mismatch", `bound RightKit registry snapshot does not match the locked inputs:\n${[...new Set(problems)].map((p) => `- ${p}`).join("\n")}`, { problems });
505
+ }
506
+
507
+ /** Canonical lock inventory: the build's own inputs (requiredInputs, native Cargo locks, declared extra locks), not a rebuilt partial list. */
508
+ export function rightKitLockInventory({ requiredInputs = [], cargoLockPaths = [], nativeLayout = null, config = {}, root }) {
509
+ const isLock = (file) => /^(?:Cargo\.lock|pnpm-lock\.yaml)$/.test(path.basename(file));
510
+ return [...new Set([
511
+ ...requiredInputs.filter(isLock), ...cargoLockPaths, ...(nativeLayout?.lockCandidates ?? []), ...(nativeLayout?.lockPath ? [nativeLayout.lockPath] : []),
512
+ ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
513
+ ].map((file) => path.resolve(file)))];
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------------------------- admitted state (controller-owned)
517
+
518
+ export const SNAPSHOT_FILE = "rightkit-registry-snapshot.json";
519
+ export const ADMISSION_FILE = "rightkit-admission.json";
520
+ const LOCK_PAYLOAD_FILE = "release-inputs.lock.json";
521
+ const fileSha = (file) => sha256(readFileSync(file));
522
+
523
+ /**
524
+ * The controller (build-release) retains, in the release state root it already owns, which snapshot was admitted for
525
+ * which release: release id, commit, platform, the snapshot digest and the hash of the release-inputs lock payload.
526
+ * Workers and resumes accept a snapshot only when this record, the snapshot and the lock payload agree. These are
527
+ * trusted-local files (the state root the controller already uses for release-inputs.lock.json): the check catches
528
+ * mix-ups, stale or foreign state and edits that leave the files inconsistent; it does NOT authenticate them, and a
529
+ * writer of the state root can forge a consistent set. There is no signing layer.
530
+ */
531
+ export function writeAdmissionRecord(stateRoot, { releaseId, commit, platform, snapshot }) {
532
+ const record = { schema: 1, releaseId, commit, platform, snapshotDigest: snapshot.digest, lockPayloadSha256: fileSha(path.join(stateRoot, LOCK_PAYLOAD_FILE)) };
533
+ const file = path.join(stateRoot, ADMISSION_FILE);
534
+ const temp = `${file}.tmp-${process.pid}`;
535
+ writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
536
+ renameSync(temp, file);
537
+ return record;
538
+ }
539
+
540
+ /** Load the admitted snapshot of THIS release from its state root, or refuse. `inPreflight` skips the lock payload hash (the controller rewrites it next). */
541
+ export function loadAdmittedSnapshot(stateRoot, { releaseId, commit, platform, snapshotFile = path.join(stateRoot, SNAPSHOT_FILE), inPreflight = false }) {
542
+ const refuse = (message) => { throw new RightKitRegistryError("no-admission", `${message}; the RightKit registry snapshot is only accepted from this release's admitted state (${stateRoot}). Start a fresh build to re-admit.`); };
543
+ if (path.resolve(snapshotFile) !== path.resolve(stateRoot, SNAPSHOT_FILE)) refuse(`snapshot ${snapshotFile} is not the admitted snapshot of this release`);
544
+ const recordFile = path.join(stateRoot, ADMISSION_FILE);
545
+ if (!existsSync(recordFile)) refuse("no admission record exists for this release");
546
+ let record;
547
+ try { record = JSON.parse(readFileSync(recordFile, "utf8")); } catch { refuse("the admission record is unreadable"); }
548
+ if (record?.schema !== 1 || record.releaseId !== releaseId || record.commit !== commit || record.platform !== platform) refuse(`the admission record belongs to another release (${record?.releaseId ?? "?"} ${String(record?.commit ?? "").slice(0, 8)} ${record?.platform ?? "?"})`);
549
+ if (!existsSync(snapshotFile)) refuse("the admitted snapshot file is missing");
550
+ const snapshot = loadSnapshot(snapshotFile);
551
+ assertAdmissionSnapshot(snapshot);
552
+ if (snapshot.digest !== record.snapshotDigest) refuse("the snapshot is not the one admitted for this release (digest differs from the admission record)");
553
+ if (!inPreflight) {
554
+ const payloadFile = path.join(stateRoot, LOCK_PAYLOAD_FILE);
555
+ if (!existsSync(payloadFile) || fileSha(payloadFile) !== record.lockPayloadSha256) refuse("the release input identity changed since admission (release-inputs.lock.json)");
556
+ const payload = JSON.parse(readFileSync(payloadFile, "utf8"));
557
+ if (payload.releaseId !== releaseId || payload.commit !== commit || payload.platform !== platform) refuse("the release input identity names another release");
558
+ }
559
+ return snapshot;
560
+ }
561
+
562
+ export function verifiedVersionMaps(snapshot) {
563
+ const maps = { cargo: new Map(), npm: new Map() };
564
+ for (const entry of snapshot.entries) {
565
+ const set = maps[entry.ecosystem].get(entry.name) ?? new Set();
566
+ set.add(entry.version);
567
+ maps[entry.ecosystem].set(entry.name, set);
568
+ }
569
+ return maps;
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------------------------- freshness + holds
573
+
574
+ function matchesPattern(name, pattern) {
575
+ if (pattern.startsWith("/")) {
576
+ try { return new RegExp(pattern.replace(/^\/|\/[a-z]*$/g, "")).test(name); } catch { return false; }
577
+ }
578
+ return name === pattern;
579
+ }
580
+ /**
581
+ * Holds named in the nearest .rightkit/update.json (app root up to the repository boundary). A hold is a migration
582
+ * lane task for right-update; it is reported next to a stale pin and never grants permission to seal it.
583
+ * Only holds the file itself declares are read (policy.held, policy.tier.held); no built-in pattern is applied here.
584
+ */
585
+ export function readUpdateHolds(root) {
586
+ let dir = path.resolve(root);
587
+ for (;;) {
588
+ const file = path.join(dir, ".rightkit", "update.json");
589
+ if (existsSync(file)) {
590
+ try {
591
+ const policy = JSON.parse(readFileSync(file, "utf8")).policy ?? {};
592
+ return { file, names: Array.isArray(policy.held) ? policy.held.filter((v) => typeof v === "string") : [], patterns: Array.isArray(policy.tier?.held) ? policy.tier.held.filter((v) => typeof v === "string") : [] };
593
+ } catch (error) {
594
+ return { file, names: [], patterns: [], malformed: error.message };
595
+ }
596
+ }
597
+ if (existsSync(path.join(dir, ".git")) || path.dirname(dir) === dir) return null;
598
+ dir = path.dirname(dir);
599
+ }
600
+ }
601
+
602
+ function holdFor(holds, name) {
603
+ if (!holds) return null;
604
+ if (holds.names.includes(name)) return "policy.held";
605
+ const pattern = holds.patterns.find((p) => matchesPattern(name, p));
606
+ return pattern ? `tier.held ${pattern}` : null;
607
+ }
608
+
609
+ /**
610
+ * Pure verdict over a snapshot's observed latest values.
611
+ * - enforce (admission): EVERY pin behind latest is refused, held or not. A hold is a migration lane task
612
+ * (`tasks`), never permission to seal a stale pin; no exception is derived from right-update's hold list.
613
+ * - otherwise (doctor): the same rows are informational `newer` lines.
614
+ */
615
+ export function evaluateRightKitFreshness(snapshot, holds, { enforce = false } = {}) {
616
+ const refusals = [];
617
+ const newer = [];
618
+ const tasks = [];
619
+ const warnings = [];
620
+ const notes = [];
621
+ if (holds?.malformed) warnings.push(`${holds.file} is unreadable (${holds.malformed}); no holds read`);
622
+ const newest = new Map(snapshot.latest.map((l) => [`${l.ecosystem}\0${l.name}`, l]));
623
+ for (const entry of snapshot.entries) {
624
+ const seen = newest.get(`${entry.ecosystem}\0${entry.name}`);
625
+ if (!seen || compareVersions(entry.version, seen.latest) >= 0) continue;
626
+ const age = seen.via === "cache" ? ` (latest from cached observation ${seen.observedAt}, ${formatAge(seen.ageMs)} old; last-observed, not current)` : "";
627
+ const hold = holdFor(holds, entry.name);
628
+ const line = `${entry.name} pinned ${entry.version} -> latest ${seen.latest}${age}: ${FIX}`;
629
+ if (hold) tasks.push(`${entry.name} pinned ${entry.version}, latest ${seen.latest}: held by .rightkit/update.json (${hold}); migration lane task, not current and not permission to seal`);
630
+ (enforce ? refusals : newer).push(hold ? `${line} [held by .rightkit/update.json (${hold}): a hold is a lane task, not permission to seal stale]` : line);
631
+ }
632
+ for (const l of snapshot.latest) {
633
+ if (l.via === "cache") notes.push(`${l.name} latest ${l.latest} taken from cache, ${formatAge(l.ageMs)} old`);
634
+ if (l.conflict) notes.push(`${l.name}: registry reports ${l.latest} but ${l.conflict.highWater} is still published (stale or out-of-order answer)`);
635
+ if (l.moved) notes.push(`${l.name}: the npm latest tag moved from ${l.moved.from} to ${l.latest}; the registry is followed`);
636
+ if (l.withdrawn) notes.push(`${l.name}: previously observed latest ${l.withdrawn.version} was withdrawn; the live registry state (${l.latest}) is used`);
637
+ }
638
+ return { refusals, newer, tasks, warnings, notes };
639
+ }
640
+
641
+ export function formatAge(ms) {
642
+ const minutes = Math.round((ms ?? 0) / 60000);
643
+ if (minutes < 120) return `${minutes} minutes`;
644
+ const hours = Math.round(minutes / 60);
645
+ return hours < 72 ? `${hours} hours` : `${Math.round(hours / 24)} days`;
646
+ }
647
+
648
+ // ---------------------------------------------------------------------------------------------- orchestration
649
+
650
+ /**
651
+ * One entry point for the doctor, the build and the release worker.
652
+ * - snapshotFile + reuse: consume that bound snapshot (no network) after checking it covers the current locks.
653
+ * - otherwise resolve live; with snapshotFile + write, bind the result there for later phases.
654
+ * The freshness verdict is recomputed from the snapshot every time, so a changed hold takes effect on resume.
655
+ */
656
+ export async function prepareRightKitResolution({ root, snapshotFile = null, boundSnapshot = null, reuse = false, write = false, admission = false, env = process.env, ...options }) {
657
+ const { versions, ownership } = options;
658
+ let snapshot;
659
+ let source;
660
+ if (boundSnapshot) {
661
+ assertAdmissionSnapshot(boundSnapshot);
662
+ snapshot = boundSnapshot;
663
+ assertSnapshotCoversLocks(snapshot, { root, versions, ownership, extraLocks: options.extraLocks });
664
+ source = "bound";
665
+ } else if (reuse) {
666
+ // A resume or a worker never replaces frozen evidence: a missing admitted snapshot is refused, not re-resolved.
667
+ throw new RightKitRegistryError("snapshot-missing", "the admitted RightKit registry snapshot is missing; refusing to resolve again over frozen evidence. Start a fresh build to re-admit.");
668
+ } else {
669
+ snapshot = await resolveRightKitPins({ root, cacheDir: options.cacheDir ?? defaultRegistryCacheDir(env), requireLiveLatest: admission, ...options });
670
+ source = "resolved";
671
+ }
672
+ // Freshness is an admission gate. A bound snapshot is frozen evidence: seal, resume and upload never re-check it,
673
+ // so a publication after admission neither mutates nor restarts an admitted build.
674
+ const enforce = admission && source === "resolved";
675
+ const freshness = evaluateRightKitFreshness(snapshot, readUpdateHolds(root), { enforce });
676
+ if (freshness.refusals.length) {
677
+ throw new RightKitRegistryError("stale-pin", `RightKit pins are behind the latest published version (build admission refused):\n${freshness.refusals.map((p) => `- ${p}`).join("\n")}`, { problems: freshness.refusals });
678
+ }
679
+ if (source === "resolved" && snapshotFile && write) writeSnapshot(snapshotFile, snapshot);
680
+ return { snapshot, source, freshness, verified: verifiedVersionMaps(snapshot), summary: summarizeSnapshot(snapshot, source) };
681
+ }
682
+
683
+ export function summarizeSnapshot(snapshot, source) {
684
+ const count = (eco) => snapshot.entries.filter((e) => e.ecosystem === eco).length;
685
+ const how = source === "bound" ? "bound snapshot (trusted-local record, not re-checked against the registry, not authenticated)" : "resolved snapshot";
686
+ return `${snapshot.entries.length} exact pins verified against the registries (cargo ${count("cargo")}, npm ${count("npm")}); ${how} ${snapshot.digest.slice(0, 12)} observed ${snapshot.observedAt}; policy ${snapshot.policyRevision}`;
687
+ }
688
+
689
+ /** Evidence block for the sealed manifest. Metadata agreement (lock digest = registry digest) only, not byte proof. */
690
+ export function registryEvidence(snapshot) {
691
+ return { schema: EVIDENCE_VERSION, digest: snapshot.digest, snapshot };
692
+ }
693
+
694
+ /** Manifest fields a new seal carries: the evidence and an explicit marker that makes its absence detectable. */
695
+ export function registryManifestFields(snapshot, toolVersion) {
696
+ return { rightkitRegistryEvidence: EVIDENCE_VERSION, ...(toolVersion ? { rightReleaseVersion: toolVersion } : {}), rightkitRegistry: registryEvidence(snapshot) };
697
+ }
698
+
699
+ export const LEGACY_RELEASES_ENV = "RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES";
700
+ const NEW_SEAL_FROM = "0.6.0";
701
+ const legacyReleaseIdsFromEnv = (env = process.env) => String(env[LEGACY_RELEASES_ENV] ?? "").split(",").map((v) => v.trim()).filter(Boolean);
702
+
703
+ /**
704
+ * A manifest must carry versioned registry evidence. New seals record `rightReleaseVersion` (>= 0.6.0) with the
705
+ * evidence; stripping the evidence from such a seal is refused whatever else is set. Only an explicitly identified
706
+ * pre-0.6.0 artifact is exempt: its manifest records no tool version (or one below 0.6.0) AND its release id is
707
+ * named in `legacyReleaseIds` / RIGHT_RELEASE_LEGACY_REGISTRY_RELEASES. There is no global switch.
708
+ */
709
+ export function assertManifestRegistryEvidence(manifest, { legacyReleaseIds = legacyReleaseIdsFromEnv() } = {}) {
710
+ const marker = manifest?.rightkitRegistryEvidence;
711
+ const evidence = manifest?.rightkitRegistry;
712
+ const recorded = manifest?.rightReleaseVersion;
713
+ if (recorded !== undefined && !(typeof recorded === "string" && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(recorded))) {
714
+ throw new Error(`sealed manifest rightReleaseVersion is malformed (${JSON.stringify(recorded)}); refusing (only an absent or valid pre-${NEW_SEAL_FROM} version is legacy)`);
715
+ }
716
+ if (marker === undefined && evidence === undefined) {
717
+ let newSeal = false;
718
+ try { newSeal = typeof recorded === "string" && compareVersions(recorded, NEW_SEAL_FROM) >= 0; } catch { newSeal = false; }
719
+ if (newSeal) throw new Error(`sealed manifest was produced by @rightkit/release ${recorded} but carries no RightKit registry evidence; refusing (a stripped new seal is never a legacy seal)`);
720
+ if (typeof manifest?.releaseId === "string" && legacyReleaseIds.includes(manifest.releaseId)) return { legacy: true };
721
+ throw new Error(`sealed manifest carries no RightKit registry evidence; refusing (only a pre-${NEW_SEAL_FROM} release named in ${LEGACY_RELEASES_ENV} is exempt)`);
722
+ }
723
+ if (marker !== EVIDENCE_VERSION) throw new Error(`sealed manifest registry evidence marker must be ${EVIDENCE_VERSION}, got ${marker === undefined ? "<missing>" : JSON.stringify(marker)}`);
724
+ assertRegistryEvidence(evidence);
725
+ return { legacy: false };
726
+ }
727
+
728
+ export function assertRegistryEvidence(evidence) {
729
+ if (!evidence || evidence.schema !== EVIDENCE_VERSION) throw new Error("sealed manifest rightkitRegistry evidence is missing or has an unsupported schema");
730
+ if (evidence.digest !== evidence.snapshot?.digest) throw new Error("sealed manifest rightkitRegistry digest does not match its snapshot");
731
+ try { assertAdmissionSnapshot(evidence.snapshot); } catch (error) { throw new Error(`sealed manifest rightkitRegistry rejected: ${error.message}`); }
732
+ }
@@ -20,7 +20,7 @@
20
20
  "@rightkit/hooks": "0.1.2",
21
21
  "@rightkit/i18n": "0.2.0",
22
22
  "@rightkit/issues-tracker": "0.1.0",
23
- "@rightkit/legal": "0.6.1",
23
+ "@rightkit/legal": "0.6.2",
24
24
  "@rightkit/legal-ui": "0.4.1",
25
25
  "@rightkit/license": "0.3.0",
26
26
  "@rightkit/logs": "0.1.6",
@@ -29,10 +29,10 @@
29
29
  "@rightkit/outdated": "0.2.0",
30
30
  "@rightkit/pdf-ui": "0.2.0",
31
31
  "@rightkit/platform-ui": "0.4.0",
32
- "@rightkit/qa": "0.5.2",
33
- "@rightkit/qa-chrome": "0.1.10",
32
+ "@rightkit/qa": "0.6.1",
33
+ "@rightkit/qa-chrome": "0.1.11",
34
34
  "@rightkit/react-shell": "0.7.2",
35
- "@rightkit/release": "0.5.0",
35
+ "@rightkit/release": "0.6.0",
36
36
  "@rightkit/settings-ui": "0.7.0",
37
37
  "@rightkit/tauri": "0.3.0",
38
38
  "@rightkit/tauri-shell": "0.1.2",
@@ -106,7 +106,8 @@
106
106
  "0.3.0",
107
107
  "0.3.1",
108
108
  "0.5.0",
109
- "0.6.0"
109
+ "0.6.0",
110
+ "0.6.1"
110
111
  ],
111
112
  "@rightkit/license": [
112
113
  "0.1.6",
@@ -222,7 +223,9 @@
222
223
  "0.2.174",
223
224
  "0.3.0",
224
225
  "0.4.0",
225
- "0.4.1"
226
+ "0.4.1",
227
+ "0.5.0",
228
+ "0.5.1"
226
229
  ],
227
230
  "@rightkit/qa": [
228
231
  "0.1.0",
@@ -245,7 +248,8 @@
245
248
  "0.4.8",
246
249
  "0.4.9",
247
250
  "0.5.0",
248
- "0.5.1"
251
+ "0.5.1",
252
+ "0.5.2"
249
253
  ],
250
254
  "@rightkit/app-shell": [
251
255
  "0.1.0",
@@ -362,7 +366,8 @@
362
366
  "0.1.4",
363
367
  "0.1.5",
364
368
  "0.1.6",
365
- "0.1.8"
369
+ "0.1.8",
370
+ "0.1.10"
366
371
  ],
367
372
  "@rightkit/updates": [
368
373
  "0.2.4"
@@ -472,18 +477,18 @@
472
477
  "rightkit-pdf": "0.3.3",
473
478
  "rightkit-platform-rules": "0.1.0",
474
479
  "rightkit-playback": "0.3.0",
475
- "rightkit-qa": "0.7.1",
476
- "rightkit-qa-driver": "0.6.0",
480
+ "rightkit-qa": "0.8.0",
481
+ "rightkit-qa-driver": "0.7.0",
477
482
  "rightkit-record": "0.3.3",
478
483
  "rightkit-review": "0.4.0",
479
- "rightkit-sandbox": "0.5.0",
484
+ "rightkit-sandbox": "0.6.1",
480
485
  "rightkit-search": "0.4.0",
481
486
  "rightkit-send": "0.2.0",
482
487
  "rightkit-sidecar": "0.3.0",
483
488
  "rightkit-speech-client": "0.4.0",
484
489
  "rightkit-spend": "0.2.0",
485
490
  "rightkit-sync": "0.2.2",
486
- "rightkit-tauri-shell": "0.4.0",
491
+ "rightkit-tauri-shell": "0.5.0",
487
492
  "rightkit-time": "0.1.0",
488
493
  "rightkit-transcript": "0.4.0",
489
494
  "rightkit-tts": "0.2.3",
@@ -561,14 +566,14 @@
561
566
  "rightkit-platform-rules": "0.1.0",
562
567
  "rightkit-playback": "0.3.0",
563
568
  "rightkit-process": "0.4.0",
564
- "rightkit-qa": "0.7.1",
565
- "rightkit-qa-driver": "0.6.0",
569
+ "rightkit-qa": "0.8.0",
570
+ "rightkit-qa-driver": "0.7.0",
566
571
  "rightkit-qts": "0.2.6",
567
572
  "rightkit-qts-ggml": "0.1.2",
568
573
  "rightkit-qts-ggml-sys": "0.1.2",
569
574
  "rightkit-record": "0.3.3",
570
575
  "rightkit-review": "0.4.0",
571
- "rightkit-sandbox": "0.5.0",
576
+ "rightkit-sandbox": "0.6.1",
572
577
  "rightkit-search": "0.4.0",
573
578
  "rightkit-secrets": "0.2.0",
574
579
  "rightkit-send": "0.2.0",
@@ -580,7 +585,7 @@
580
585
  "rightkit-spend": "0.2.0",
581
586
  "rightkit-sync": "0.2.2",
582
587
  "rightkit-tauri": "0.1.3",
583
- "rightkit-tauri-shell": "0.4.0",
588
+ "rightkit-tauri-shell": "0.5.0",
584
589
  "rightkit-time": "0.1.0",
585
590
  "rightkit-transcript": "0.4.0",
586
591
  "rightkit-tts": "0.2.3",
@@ -601,7 +606,8 @@
601
606
  "@rightkit/git": "0.2.42",
602
607
  "@rightkit/hooks": "0.1.2",
603
608
  "@rightkit/i18n": "0.2.0",
604
- "@rightkit/legal": "0.6.1",
609
+ "@rightkit/issues-tracker": "0.1.0",
610
+ "@rightkit/legal": "0.6.2",
605
611
  "@rightkit/legal-ui": "0.4.1",
606
612
  "@rightkit/license": "0.3.0",
607
613
  "@rightkit/logs": "0.1.6",
@@ -610,10 +616,10 @@
610
616
  "@rightkit/outdated": "0.2.0",
611
617
  "@rightkit/pdf-ui": "0.2.0",
612
618
  "@rightkit/platform-ui": "0.4.0",
613
- "@rightkit/qa": "0.5.2",
614
- "@rightkit/qa-chrome": "0.1.10",
619
+ "@rightkit/qa": "0.6.1",
620
+ "@rightkit/qa-chrome": "0.1.11",
615
621
  "@rightkit/react-shell": "0.7.2",
616
- "@rightkit/release": "0.4.1",
622
+ "@rightkit/release": "0.5.1",
617
623
  "@rightkit/settings-ui": "0.7.0",
618
624
  "@rightkit/shell": "0.1.1",
619
625
  "@rightkit/tauri": "0.3.0",
@@ -741,7 +747,8 @@
741
747
  "0.6.0",
742
748
  "0.6.1",
743
749
  "0.6.4",
744
- "0.7.0"
750
+ "0.7.0",
751
+ "0.7.1"
745
752
  ],
746
753
  "rightkit-sandbox": [
747
754
  "0.1.0",
@@ -756,7 +763,8 @@
756
763
  "0.3.3",
757
764
  "0.3.5",
758
765
  "0.4.0",
759
- "0.5.0"
766
+ "0.5.0",
767
+ "0.6.0"
760
768
  ],
761
769
  "rightkit-shell": [
762
770
  "0.1.0",
@@ -928,7 +936,8 @@
928
936
  "0.3.1",
929
937
  "0.4.0",
930
938
  "0.4.1",
931
- "0.5.0"
939
+ "0.5.0",
940
+ "0.6.0"
932
941
  ],
933
942
  "rightkit-agent-tools": [
934
943
  "0.1.2",