Context
Pnpm v11 added a lockfile verification gate that re-applies the resolver's policy checks to every entry in pnpm-lock.yaml immediately after the lockfile is loaded, before any resolution or fetch happens. The threat model is a lockfile that someone else resolved — committed to the repo, restored from CI cache, or generated by a tool that bypassed local policy. Today two policies plug into the gate:
minimumReleaseAge — reject versions younger than the configured cutoff.
trustPolicy: 'no-downgrade' — reject versions whose trust evidence (provenance, trusted-publisher) is weaker than an earlier-published version's.
A passing verification is recorded in <cacheDir>/lockfile-verified.jsonl so the next install against an unchanged lockfile under the same policy can stat-and-skip.
Pacquet has none of this yet. No ResolutionVerifier trait, no runner, no JSONL cache, no on-disk packument mirror, no minimumReleaseAge / trustPolicy config fields, no abbreviated/full metadata distinction in pacquet-registry. This issue tracks the full port in PR-sized phases.
Confirmed decisions:
- Time parsing: promote
chrono to a runtime dep (already in workspace as dev-dep).
- Gate scope: run the gate only when
lockfile.is_some() — skip the no-lockfile install path.
Upstream commit pinned for permanent links in code/commits: 2a9bd897bf.
Crate layout
Three new crates, four existing crates extended:
| New crate |
Mirrors upstream |
Holds |
pacquet-resolving-resolver-base |
@pnpm/resolving.resolver-base (subset) |
ResolutionVerifier trait, ResolutionVerification, ResolutionPolicyViolation. No async, no IO. |
pacquet-resolving-npm-resolver |
@pnpm/resolving.npm-resolver (subset) |
NpmResolutionVerifier, trust-rank logic, attestation fetcher, cached full-metadata fetcher, violation codes. |
pacquet-lockfile-verification |
@pnpm/lockfile.verification (de facto) |
Fan-out runner, JSONL cache, in-memory lockfile hasher. |
| Existing crate |
Edit |
pacquet-config |
New fields (cache_dir, minimum_release_age, trust_policy, …), TrustPolicy enum, port createPackageVersionPolicy. |
pacquet-reporter |
New LogEvent::LockfileVerification variant. |
pacquet-registry |
Enrich Package / PackageVersion with time, _npmUser.trustedPublisher, dist.attestations, modified. |
pacquet-package-manager |
Wire the gate into install.rs::Install::run between lockfile load and frozen-lockfile dispatch. |
Rationale: upstream splits the trait into resolving/resolver-base/ because future verifiers plug into it; folding it into the runner crate would invert that layering. The npm verifier doesn't fit pacquet-registry (which is bare fetch+deserialize today); a dedicated pacquet-resolving-npm-resolver matches upstream's package and leaves slots for pickPackage etc. when those land.
Source files
Upstream (port from these, paths at SHA 2a9bd897bf)
Pacquet (existing patterns to follow)
pacquet/crates/reporter/src/lib.rs:26-178 — LogEvent enum, channel-rename convention, recording-fake-reporter style.
pacquet/crates/reporter/src/lib.rs:684-715 — Reporter trait (fn emit(event: &LogEvent), sync).
pacquet/crates/network/src/lib.rs:49-99 — ThrottledClient + Semaphore::new(default_network_concurrency()) pattern to mirror for the verifier's 16-way fan-out.
pacquet/crates/registry/src/package.rs:29-61 — Package::fetch_from_registry, the existing one-endpoint baseline we extend.
pacquet/crates/config/src/lib.rs — Config struct shape, SmartDefault, serde wiring.
pacquet/crates/config/src/matcher.rs — create_matcher for wildcard patterns; reuse for PackageVersionPolicy.
pacquet/crates/package-manager/src/version_policy.rs — expand_package_version_specs (the exact-version half already ported). Move + extend in Phase 2.
pacquet/crates/lockfile/src/lib.rs:43-91 — Lockfile shape; PackageKey = PkgNameVerPeer = PkgNameSuffix<PkgVerPeer>.
pacquet/crates/lockfile/src/resolution.rs:247-256 — LockfileResolution::{Tarball, Registry, Directory, Git, Binary, Variations} — what verifiers dispatch on.
pacquet/crates/package-manager/src/install.rs:239-253 — where the gate plugs in (after Lockfile::load_current_from_virtual_store_dir, before the pnpm:context emit).
pacquet/crates/testing-utils/src/bin.rs — CommandTempCwd, AddMockedRegistry — used for the end-to-end CLI test in Phase 7.
Phase-by-phase plan
Phase 1 — Reporter channel + verifier trait crate
Deliverable. Pacquet can emit pnpm:lockfile-verification events (no emit site yet). The verifier trait + violation type exist so later phases can implement it. Install behavior unchanged.
Edits.
pacquet/crates/reporter/src/lib.rs: add LogEvent::LockfileVerification(LockfileVerificationLog) variant with #[serde(rename = "pnpm:lockfile-verification")]. Payload shape mirrors ProgressLog — outer level + #[serde(flatten)] message: LockfileVerificationMessage where message is a status-tagged union (Started { entries, lockfile_path }, Done { entries, elapsed_ms, lockfile_path }, Failed { entries, elapsed_ms, lockfile_path }). Mirror upstream's lockfileVerificationLogger.ts:18-46 field-for-field. lockfile_path is Option<String> (camelCase wire lockfilePath).
pacquet/crates/reporter/src/tests.rs: byte-exact JSON round-trip for each of the three messages; cover the lockfile_path = None case.
Create. Crate at pacquet/crates/resolving-resolver-base/.
Cargo.toml: workspace deps serde, serde_json, derive_more, pacquet-lockfile (for LockfileResolution, PkgName).
src/lib.rs:
pub trait ResolutionVerifier: Send + Sync with async fn verify(&self, resolution: &LockfileResolution, ctx: VerifyCtx<'_>) -> ResolutionVerification, fn policy(&self) -> &serde_json::Map<String, serde_json::Value>, fn can_trust_past_check(&self, cached: &serde_json::Map<String, serde_json::Value>) -> bool. Native async fn in trait (toolchain is 1.95.0, stable since 1.75).
pub enum ResolutionVerification { Ok, Err { code: &'static str, reason: String } }.
pub struct ResolutionPolicyViolation { name: PkgName, version: String, resolution: LockfileResolution, code: &'static str, reason: String }.
pub struct VerifyCtx<'a> { pub name: &'a PkgName, pub version: &'a str }.
Tests. Reporter wire shape; smoke construction of ResolutionVerification::Err and ResolutionPolicyViolation.
Blocks. Everything downstream. Blocked by. Nothing.
Phase 2 — Config fields, cache_dir, PackageVersionPolicy
Deliverable. .npmrc / pnpm-workspace.yaml parses the seven new keys without error; Config::cache_dir resolves on every platform; createPackageVersionPolicy exists.
Edits.
pacquet/crates/config/src/lib.rs: add to Config:
pub cache_dir: PathBuf (with default_cache_dir() smart-default).
pub minimum_release_age: Option<u64> (milliseconds — match upstream's wire shape).
pub minimum_release_age_exclude: Option<Vec<String>>.
pub minimum_release_age_ignore_missing_time: bool (default false).
pub minimum_release_age_strict: bool (default false).
pub trust_policy: TrustPolicy (default Off).
pub trust_policy_exclude: Option<Vec<String>>.
pub trust_policy_ignore_after: Option<String> (RFC3339 string; parsed at verifier boundary).
pacquet/crates/config/src/lib.rs: pub enum TrustPolicy { Off, NoDowngrade } with serde rename_all = \"kebab-case\", default = Off. AGENTS rule 7 (string-literal union → enum).
pacquet/crates/config/src/defaults.rs: default_cache_dir() chain — \$PNPM_CACHE_DIR → \$XDG_CACHE_HOME/pnpm → platform default. Port from upstream config/reader/src/getCacheDir.ts. Reuse the existing EnvVar/Host DI seam established for default_store_dir.
pacquet/crates/config/src/workspace_yaml.rs: deserialize the new keys (camelCase yaml names).
Move + extend.
- Move
parse_exact_versions_union and expand_package_version_specs from pacquet/crates/package-manager/src/version_policy.rs into a new pacquet/crates/config/src/version_policy.rs. Leave a pub use re-export at the old site so allowBuilds callers don't break.
- Add
pub fn create_package_version_policy(patterns: &[String]) -> PackageVersionPolicy (port of upstream config/version-policy/src/index.ts). PackageVersionPolicy::matches(&self, name: &PkgName, version: &Version) -> PolicyMatch where PolicyMatch::{No, AnyVersion, ExactVersions(SmallVec<[Version; 1]>)}. Wildcards via pacquet_config::matcher::create_matcher; version unions via parse_exact_versions_union.
Promote chrono. Edit root Cargo.toml — move chrono from the dev section into [workspace.dependencies].
Tests. Yaml roundtrip for every new key; default_cache_dir() per-platform; port upstream's config/version-policy/test/index.ts.
Blocks. Phase 4. Blocked by. Nothing (independent of Phase 1).
Phase 3 — Packument metadata enrichment in pacquet-registry
Deliverable. Package / PackageVersion expose the fields the npm verifier needs. No new endpoints, no caching, no behavior change to existing fetch path.
Edits.
pacquet/crates/registry/src/package.rs:
- Add
pub time: Option<HashMap<String, String>> (per-version ISO timestamps).
- Add
pub modified: Option<String> (package-level last-modified).
pacquet/crates/registry/src/package_version.rs:
- Add
pub _npm_user: Option<NpmUser> with #[serde(rename = \"_npmUser\")]. NpmUser { trusted_publisher: Option<TrustedPublisher> }.
- Extend
PackageDistribution with attestations: Option<AttestationsDist> where AttestationsDist { provenance: Option<ProvenanceMeta>, url: Option<String> }. Match _npmUser.trustedPublisher and dist.attestations.provenance paths — what getTrustEvidence reads.
Tests. Fixture-based deserialize using real-shape packuments. One with full provenance, one without _npmUser, one with time missing. Each round-trips through serde.
Blocks. Phase 4. Blocked by. Nothing (independent of Phases 1 and 2).
Phase 4 — pacquet-resolving-npm-resolver crate
Deliverable. Working NpmResolutionVerifier that fetches the full packument on demand (no on-disk cache yet — Phase 5), evaluates both policies, returns ResolutionVerification. Not wired into install yet.
Create. Crate at pacquet/crates/resolving-npm-resolver/.
Cargo.toml: pacquet-resolving-resolver-base, pacquet-registry, pacquet-network, pacquet-config, pacquet-lockfile, node-semver, tokio (sync only), reqwest, serde, serde_json, derive_more, miette, chrono.
src/violation_codes.rs: 2 consts, verbatim from upstream's 12-line file.
src/create_npm_resolution_verifier.rs: constructor + NpmResolutionVerifier struct. Decompose like upstream:
pub fn create_npm_resolution_verifier(opts) -> Option<NpmResolutionVerifier> — None when neither policy is active.
impl ResolutionVerifier for NpmResolutionVerifier — verify short-circuits on non-registry resolutions.
- Private helpers per layer:
try_abbreviated_modified_shortcut, read_local_meta_time, fetch_attestation_time, fetch_full_meta_time.
policy() returns a per-install-built serde_json::Map with policy snapshot.
can_trust_past_check: loosened-can-trust-stricter on minimumReleaseAge; byte-exact match on exclude lists; exact match on trust policy + exclude + ignore-after.
src/trust_checks.rs: port trustChecks.ts:1-127. TRUST_RANK const (trusted_publisher=2, provenance=1). fail_if_trust_downgraded(meta, version, opts) -> Result<(), TrustViolation>. Prerelease-aware via node_semver::Version::is_prerelease().
src/fetch_attestation_published_at.rs: port. URL {registry}/-/npm/v1/attestations/{name}@{version}. Walk Sigstore bundles → pick earliest integratedTime → RFC3339 via chrono::DateTime::from_timestamp. Returns Option<String>.
src/fetch_full_metadata.rs: minimal no-cache fetcher ({registry}{name} with Accept: application/json).
src/named_registry.rs: port BUILTIN_NAMED_REGISTRIES + prefix-routing helper.
src/lookup_context.rs: per-install dedup caches (Arc<tokio::sync::Mutex<HashMap<...>>> for abbreviated_meta, published_at, local_meta, full_meta, full_meta_for_trust).
Tests. Per-scenario mockito tests for minimum-release-age (pass / fail / missing-time), trust-downgrade (publisher→provenance, provenance→unsigned, ignore-after, prerelease), attestation endpoint (hit / 404 / malformed), non-registry short-circuit, can_trust_past_check.
Blocks. Phases 5 and 6. Blocked by. Phases 1, 2, 3.
Phase 5 — On-disk packument mirror
Deliverable. fetch_full_metadata_cached with conditional GET against a mirror at <cache_dir>/v3/<full-meta-dir>/<scope>/<name>.json. Used by the verifier; regular resolver path untouched.
Edits. Inside pacquet/crates/resolving-npm-resolver/src/:
- New
fetch_full_metadata_cached.rs: port fetchFullMetadataCached.ts:30-99 and helpers from pickPackage.ts (get_pkg_mirror_path, load_meta, load_meta_headers, save_meta, prepare_json_for_disk). Conditional headers + 304 / 200 / 4xx-5xx fallback.
- Update
create_npm_resolution_verifier.rs to call fetch_full_metadata_cached instead of fetch_full_metadata.
Tests. Mockito: cold cache → 200 → mirror written; warm → 304 → mirror used; stale → 200 → overwrite; read-only cache dir → succeeds with debug-logged write failure.
Blocks. Phase 7. Blocked by. Phase 4.
Phase 6 — pacquet-lockfile-verification crate
Deliverable. End-to-end runner + JSONL cache + recorder. A test can call verify_lockfile_resolutions::<SilentReporter>(lockfile, &[verifier], &opts).await? and see violations, log events, and a fresh cache record on disk.
Create. Crate at pacquet/crates/lockfile-verification/.
Cargo.toml: pacquet-resolving-resolver-base, pacquet-lockfile, pacquet-reporter, pacquet-diagnostics, tokio (sync + time), futures-util, sha2, serde, serde_json, derive_more, miette. No reqwest / registry deps.
src/verify_lockfile_resolutions.rs: port verifyLockfileResolutions.ts:1-288.
- Public fn
pub async fn verify_lockfile_resolutions<R: Reporter>(lockfile, verifiers, opts) -> Result<(), VerifyError>.
- Public sibling
pub async fn collect_resolution_policy_violations(...) (data-returning).
collect_candidates(lockfile) iterates lockfile.packages + lockfile.snapshots, keys by (PkgName, version, serde_json::to_string(&resolution)) to dedupe peer-context / patch-hash duplicates.
- Concurrency:
tokio::sync::Semaphore::new(opts.concurrency.unwrap_or(16)) + futures_util::stream::iter(candidates).buffer_unordered(N). Per-candidate future acquires permit, fans across verifiers, stops at first failure per candidate.
- Error build: sort by
format!(\"{name}@{version}\"); cap at 20 visible; escalate to LOCKFILE_RESOLUTION_VERIFICATION when distinct codes > 1.
- Reporter: emit
Started before fan-out, Done or Failed after with elapsed_ms. Skip emit when candidate set is empty.
src/cache.rs: port verifyLockfileResolutionsCache.ts:1-428.
pub fn try_lockfile_verification_cache(cache_dir, key) -> CacheLookupResult.
pub fn record_verification(cache_dir, key, precomputed).
- Constants:
CACHE_FILE_NAME = \"lockfile-verified.jsonl\", MAX_CACHE_ENTRIES = 1000, COMPACT_TRIGGER_BYTES = 1_536_000.
- Stat:
MetadataExt::ino() on unix; 0 on Windows.
- Compaction: dedupe by
(path, hash), reverse-walk to keep newest, trim to MAX_CACHE_ENTRIES, write to <file>.<pid>.tmp then rename.
- All IO is synchronous (
std::fs::*).
src/record_lockfile_verified.rs: 5-line wrapper.
src/hash_lockfile.rs: pub fn hash_lockfile(lockfile: &Lockfile) -> String. Pacquet's Lockfile uses HashMap; normalize-into-BTreeMap inside hash_lockfile only (don't change the public type). Private Normalize<'a>(&'a Lockfile) newtype implements Serialize by reordering; serde_json::to_writer into Sha256; hex-format.
src/errors.rs: VerifyError enum — LockfileResolutionVerification (mixed-code batch), MinimumReleaseAgeViolation, TrustDowngrade. Use derive_more::Display + derive_more::Error + miette::Diagnostic. Help text: port upstream's hint verbatim.
Tests. Two layers — runner (empty list noop, ok-emit, single-violation, mixed-code escalation, 25-violations cap, dedup, concurrency cap) and cache (round-trip, stat-shortcut, content-hash hit, compaction at 1.5MB, concurrent append, policy trust comparison).
Blocks. Phase 7. Blocked by. Phases 1, 4.
Phase 7 — Wire-up in pacquet-package-manager::install
Deliverable. pacquet install --frozen-lockfile runs the gate. End-to-end: a fresh-published version + minimumReleaseAge = 14d in pnpm-workspace.yaml → install fails before any tarball is fetched.
Edits.
pacquet/crates/package-manager/Cargo.toml: add pacquet-lockfile-verification, pacquet-resolving-resolver-base, pacquet-resolving-npm-resolver.
pacquet/crates/package-manager/src/install.rs:
- Around line 241 (after
Lockfile::load_current_from_virtual_store_dir, before the pnpm:context emit at line 248): when lockfile.is_some(), build the verifier list from config. Use create_npm_resolution_verifier(...) — None when neither policy is set; otherwise Some(verifier). Collect into Vec<Arc<dyn ResolutionVerifier>>.
- Compute
lockfile_path from Install::lockfile_path (new field) or derive workspace_root.join(\"pnpm-lock.yaml\").
- Call
verify_lockfile_resolutions::<R>(lockfile, &verifiers, &VerifyOptions { cache_dir: Some(&config.cache_dir), lockfile_path: Some(&lockfile_path), concurrency: None }).await.map_err(InstallError::LockfileVerification)?;.
- The no-lockfile and
lockfile.is_none() paths skip the gate.
Install struct: add pub lockfile_path: Option<&'a Path>. CLI fills it.
InstallError enum: add #[diagnostic(transparent)] LockfileVerification(#[error(source)] VerifyError). Transparent so the inner miette code is what the user sees.
pacquet/crates/cli/src/cli_args/install.rs: pass lockfile_path.
Tests.
pacquet/crates/package-manager/src/install/tests.rs: real-fixture TempDir + AddMockedRegistry. Mocked registry returns a packument where the locked version's publish time is < cutoff. Run Install::run::<SilentReporter>. Expect Err(InstallError::LockfileVerification(_)) and assert the violation code.
pacquet/crates/cli/tests/cli_lockfile_verification.rs (new): full CLI integration via CommandTempCwd. Assert non-zero exit + stderr contains MINIMUM_RELEASE_AGE_VIOLATION.
- Recording-fake reporter test: emit order is
LockfileVerification(Started) → LockfileVerification(Done) → Context → Stage(ImportingStarted) → ….
Blocks. Nothing. Blocked by. Phases 1, 2, 4, 5, 6.
Phase 8 — Docs + TEST_PORTING checklist (follow-up)
Deliverable. pacquet/plans/TEST_PORTING.md lists upstream tests this work mirrored. Diagnostic help text matches upstream byte-for-byte.
Edits.
pacquet/plans/TEST_PORTING.md: add entries for verifyLockfileResolutions.ts, verifyLockfileResolutionsCache.ts, recordLockfileVerified.ts, minimumReleaseAge.ts, createNpmResolutionVerifier.test.ts, trustChecks.test.ts, fetchAttestationPublishedAt.test.ts with line ranges and "ported" checkboxes.
pacquet/crates/diagnostics/...: insta snapshot of the rendered error for one violation, three (single-code), three (mixed).
Blocks. Nothing. Blocked by. Phase 7.
Verification (end-to-end)
After Phase 7 lands the full chain is usable. Verify with:
just check — workspace compiles.
cargo nextest run -p pacquet-resolving-resolver-base -p pacquet-resolving-npm-resolver -p pacquet-lockfile-verification — unit tests in new crates.
cargo nextest run -p pacquet-package-manager install::tests::lockfile_verification — package-manager integration test.
cargo nextest run -p pacquet-cli cli_lockfile_verification — CLI end-to-end through mocked registry.
just lint — --deny warnings still passes.
just ready — full suite + clippy + fmt + typos.
Manual smoke:
just registry-mock launch + a fixture project with minimumReleaseAge: 1d in pnpm-workspace.yaml and a lockfile entry resolving to a version the mock claims was published 1 hour ago. Run just cli -- install --frozen-lockfile. Confirm stderr names the offending package, exit code != 0.
- Repeat with
minimumReleaseAge unset — install succeeds, no pnpm:lockfile-verification events fire.
- Re-run step 7 twice — second run takes the cache fast path (inspect
<cache_dir>/lockfile-verified.jsonl).
Per-PR strategy
One PR per phase. Each PR ships:
- Conventional-commits title scoped to the touched area:
feat(resolving-resolver-base): port ResolutionVerifier trait, feat(config): add minimumReleaseAge and trustPolicy fields, feat(resolving-npm-resolver): port createNpmResolutionVerifier, etc.
- Reference to the upstream PR / commit being ported.
- Agent-authored footer.
- Corresponding ported upstream tests.
Dependency graph:
- Phases 1, 2, 3 are independent — can be parallel.
- Phase 4 needs 1+2+3.
- Phase 5 needs 4.
- Phase 6 can land in parallel with Phase 5 (both depend on 4; 6 also depends on 1).
- Phase 7 needs all of 1–6.
- Phase 8 is follow-up.
Written by an agent (Claude Code, claude-opus-4-7).
Context
Pnpm v11 added a lockfile verification gate that re-applies the resolver's policy checks to every entry in
pnpm-lock.yamlimmediately after the lockfile is loaded, before any resolution or fetch happens. The threat model is a lockfile that someone else resolved — committed to the repo, restored from CI cache, or generated by a tool that bypassed local policy. Today two policies plug into the gate:minimumReleaseAge— reject versions younger than the configured cutoff.trustPolicy: 'no-downgrade'— reject versions whose trust evidence (provenance, trusted-publisher) is weaker than an earlier-published version's.A passing verification is recorded in
<cacheDir>/lockfile-verified.jsonlso the next install against an unchanged lockfile under the same policy can stat-and-skip.Pacquet has none of this yet. No
ResolutionVerifiertrait, no runner, no JSONL cache, no on-disk packument mirror, nominimumReleaseAge/trustPolicyconfig fields, no abbreviated/full metadata distinction inpacquet-registry. This issue tracks the full port in PR-sized phases.Confirmed decisions:
chronoto a runtime dep (already in workspace as dev-dep).lockfile.is_some()— skip the no-lockfile install path.Upstream commit pinned for permanent links in code/commits:
2a9bd897bf.Crate layout
Three new crates, four existing crates extended:
pacquet-resolving-resolver-base@pnpm/resolving.resolver-base(subset)ResolutionVerifiertrait,ResolutionVerification,ResolutionPolicyViolation. No async, no IO.pacquet-resolving-npm-resolver@pnpm/resolving.npm-resolver(subset)NpmResolutionVerifier, trust-rank logic, attestation fetcher, cached full-metadata fetcher, violation codes.pacquet-lockfile-verification@pnpm/lockfile.verification(de facto)pacquet-configcache_dir,minimum_release_age,trust_policy, …),TrustPolicyenum, portcreatePackageVersionPolicy.pacquet-reporterLogEvent::LockfileVerificationvariant.pacquet-registryPackage/PackageVersionwithtime,_npmUser.trustedPublisher,dist.attestations,modified.pacquet-package-managerinstall.rs::Install::runbetween lockfile load and frozen-lockfile dispatch.Rationale: upstream splits the trait into
resolving/resolver-base/because future verifiers plug into it; folding it into the runner crate would invert that layering. The npm verifier doesn't fitpacquet-registry(which is bare fetch+deserialize today); a dedicatedpacquet-resolving-npm-resolvermatches upstream's package and leaves slots forpickPackageetc. when those land.Source files
Upstream (port from these, paths at SHA
2a9bd897bf)installing/deps-installer/src/install/verifyLockfileResolutions.ts(288 lines) — runner.installing/deps-installer/src/install/verifyLockfileResolutionsCache.ts(428 lines) — JSONL cache.installing/deps-installer/src/install/recordLockfileVerified.ts— thin recorder wrapper.installing/deps-installer/src/install/index.ts:355-383— call site.resolving/resolver-base/src/index.ts:86-150— verifier trait + violation types.resolving/npm-resolver/src/createNpmResolutionVerifier.ts(657 lines) — npm verifier core.resolving/npm-resolver/src/trustChecks.ts(127 lines) — trust-downgrade algorithm.resolving/npm-resolver/src/fetchAttestationPublishedAt.ts(127 lines) — attestation endpoint.resolving/npm-resolver/src/fetchFullMetadataCached.ts(99 lines) — conditional-GET cached fetcher.resolving/npm-resolver/src/violationCodes.ts(12 lines) —MINIMUM_RELEASE_AGE_VIOLATION,TRUST_DOWNGRADE.core/core-loggers/src/lockfileVerificationLogger.ts(56 lines) — channel/payload definitions.cli/default-reporter/src/reporterForClient/reportLockfileVerification.ts(78 lines) — read-only, confirms wire shape.config/version-policy/src/index.ts—createPackageVersionPolicy(the wildcards-AND-versions half not yet ported).config/reader/src/Config.ts:264-272— config field definitions.config/reader/src/getCacheDir.ts— cache-dir resolution chain.core/types/src/config.ts:5—TrustPolicy = 'no-downgrade' | 'off'.crypto/object-hasher/src/index.ts—hashObjectsemantics for in-memory lockfile hashing.Pacquet (existing patterns to follow)
pacquet/crates/reporter/src/lib.rs:26-178—LogEventenum, channel-rename convention, recording-fake-reporter style.pacquet/crates/reporter/src/lib.rs:684-715—Reportertrait (fn emit(event: &LogEvent), sync).pacquet/crates/network/src/lib.rs:49-99—ThrottledClient+Semaphore::new(default_network_concurrency())pattern to mirror for the verifier's 16-way fan-out.pacquet/crates/registry/src/package.rs:29-61—Package::fetch_from_registry, the existing one-endpoint baseline we extend.pacquet/crates/config/src/lib.rs—Configstruct shape,SmartDefault, serde wiring.pacquet/crates/config/src/matcher.rs—create_matcherfor wildcard patterns; reuse forPackageVersionPolicy.pacquet/crates/package-manager/src/version_policy.rs—expand_package_version_specs(the exact-version half already ported). Move + extend in Phase 2.pacquet/crates/lockfile/src/lib.rs:43-91—Lockfileshape;PackageKey = PkgNameVerPeer=PkgNameSuffix<PkgVerPeer>.pacquet/crates/lockfile/src/resolution.rs:247-256—LockfileResolution::{Tarball, Registry, Directory, Git, Binary, Variations}— what verifiers dispatch on.pacquet/crates/package-manager/src/install.rs:239-253— where the gate plugs in (afterLockfile::load_current_from_virtual_store_dir, before thepnpm:contextemit).pacquet/crates/testing-utils/src/bin.rs—CommandTempCwd,AddMockedRegistry— used for the end-to-end CLI test in Phase 7.Phase-by-phase plan
Phase 1 — Reporter channel + verifier trait crate
Deliverable. Pacquet can emit
pnpm:lockfile-verificationevents (no emit site yet). The verifier trait + violation type exist so later phases can implement it. Install behavior unchanged.Edits.
pacquet/crates/reporter/src/lib.rs: addLogEvent::LockfileVerification(LockfileVerificationLog)variant with#[serde(rename = "pnpm:lockfile-verification")]. Payload shape mirrorsProgressLog— outerlevel+#[serde(flatten)] message: LockfileVerificationMessagewheremessageis a status-tagged union (Started { entries, lockfile_path },Done { entries, elapsed_ms, lockfile_path },Failed { entries, elapsed_ms, lockfile_path }). Mirror upstream'slockfileVerificationLogger.ts:18-46field-for-field.lockfile_pathisOption<String>(camelCase wirelockfilePath).pacquet/crates/reporter/src/tests.rs: byte-exact JSON round-trip for each of the three messages; cover thelockfile_path = Nonecase.Create. Crate at
pacquet/crates/resolving-resolver-base/.Cargo.toml: workspace depsserde,serde_json,derive_more,pacquet-lockfile(forLockfileResolution,PkgName).src/lib.rs:pub trait ResolutionVerifier: Send + Syncwithasync fn verify(&self, resolution: &LockfileResolution, ctx: VerifyCtx<'_>) -> ResolutionVerification,fn policy(&self) -> &serde_json::Map<String, serde_json::Value>,fn can_trust_past_check(&self, cached: &serde_json::Map<String, serde_json::Value>) -> bool. Nativeasync fn in trait(toolchain is 1.95.0, stable since 1.75).pub enum ResolutionVerification { Ok, Err { code: &'static str, reason: String } }.pub struct ResolutionPolicyViolation { name: PkgName, version: String, resolution: LockfileResolution, code: &'static str, reason: String }.pub struct VerifyCtx<'a> { pub name: &'a PkgName, pub version: &'a str }.Tests. Reporter wire shape; smoke construction of
ResolutionVerification::ErrandResolutionPolicyViolation.Blocks. Everything downstream. Blocked by. Nothing.
Phase 2 — Config fields,
cache_dir,PackageVersionPolicyDeliverable.
.npmrc/pnpm-workspace.yamlparses the seven new keys without error;Config::cache_dirresolves on every platform;createPackageVersionPolicyexists.Edits.
pacquet/crates/config/src/lib.rs: add toConfig:pub cache_dir: PathBuf(withdefault_cache_dir()smart-default).pub minimum_release_age: Option<u64>(milliseconds — match upstream's wire shape).pub minimum_release_age_exclude: Option<Vec<String>>.pub minimum_release_age_ignore_missing_time: bool(defaultfalse).pub minimum_release_age_strict: bool(defaultfalse).pub trust_policy: TrustPolicy(defaultOff).pub trust_policy_exclude: Option<Vec<String>>.pub trust_policy_ignore_after: Option<String>(RFC3339 string; parsed at verifier boundary).pacquet/crates/config/src/lib.rs:pub enum TrustPolicy { Off, NoDowngrade }with serderename_all = \"kebab-case\",default = Off. AGENTS rule 7 (string-literal union → enum).pacquet/crates/config/src/defaults.rs:default_cache_dir()chain —\$PNPM_CACHE_DIR→\$XDG_CACHE_HOME/pnpm→ platform default. Port from upstreamconfig/reader/src/getCacheDir.ts. Reuse the existingEnvVar/HostDI seam established fordefault_store_dir.pacquet/crates/config/src/workspace_yaml.rs: deserialize the new keys (camelCase yaml names).Move + extend.
parse_exact_versions_unionandexpand_package_version_specsfrompacquet/crates/package-manager/src/version_policy.rsinto a newpacquet/crates/config/src/version_policy.rs. Leave apub usere-export at the old site soallowBuildscallers don't break.pub fn create_package_version_policy(patterns: &[String]) -> PackageVersionPolicy(port of upstreamconfig/version-policy/src/index.ts).PackageVersionPolicy::matches(&self, name: &PkgName, version: &Version) -> PolicyMatchwherePolicyMatch::{No, AnyVersion, ExactVersions(SmallVec<[Version; 1]>)}. Wildcards viapacquet_config::matcher::create_matcher; version unions viaparse_exact_versions_union.Promote chrono. Edit root
Cargo.toml— movechronofrom the dev section into[workspace.dependencies].Tests. Yaml roundtrip for every new key;
default_cache_dir()per-platform; port upstream'sconfig/version-policy/test/index.ts.Blocks. Phase 4. Blocked by. Nothing (independent of Phase 1).
Phase 3 — Packument metadata enrichment in
pacquet-registryDeliverable.
Package/PackageVersionexpose the fields the npm verifier needs. No new endpoints, no caching, no behavior change to existing fetch path.Edits.
pacquet/crates/registry/src/package.rs:pub time: Option<HashMap<String, String>>(per-version ISO timestamps).pub modified: Option<String>(package-level last-modified).pacquet/crates/registry/src/package_version.rs:pub _npm_user: Option<NpmUser>with#[serde(rename = \"_npmUser\")].NpmUser { trusted_publisher: Option<TrustedPublisher> }.PackageDistributionwithattestations: Option<AttestationsDist>whereAttestationsDist { provenance: Option<ProvenanceMeta>, url: Option<String> }. Match_npmUser.trustedPublisheranddist.attestations.provenancepaths — whatgetTrustEvidencereads.Tests. Fixture-based deserialize using real-shape packuments. One with full provenance, one without
_npmUser, one withtimemissing. Each round-trips through serde.Blocks. Phase 4. Blocked by. Nothing (independent of Phases 1 and 2).
Phase 4 —
pacquet-resolving-npm-resolvercrateDeliverable. Working
NpmResolutionVerifierthat fetches the full packument on demand (no on-disk cache yet — Phase 5), evaluates both policies, returnsResolutionVerification. Not wired into install yet.Create. Crate at
pacquet/crates/resolving-npm-resolver/.Cargo.toml:pacquet-resolving-resolver-base,pacquet-registry,pacquet-network,pacquet-config,pacquet-lockfile,node-semver,tokio(sync only),reqwest,serde,serde_json,derive_more,miette,chrono.src/violation_codes.rs: 2 consts, verbatim from upstream's 12-line file.src/create_npm_resolution_verifier.rs: constructor +NpmResolutionVerifierstruct. Decompose like upstream:pub fn create_npm_resolution_verifier(opts) -> Option<NpmResolutionVerifier>—Nonewhen neither policy is active.impl ResolutionVerifier for NpmResolutionVerifier—verifyshort-circuits on non-registry resolutions.try_abbreviated_modified_shortcut,read_local_meta_time,fetch_attestation_time,fetch_full_meta_time.policy()returns a per-install-builtserde_json::Mapwith policy snapshot.can_trust_past_check: loosened-can-trust-stricter onminimumReleaseAge; byte-exact match on exclude lists; exact match on trust policy + exclude + ignore-after.src/trust_checks.rs: porttrustChecks.ts:1-127.TRUST_RANKconst (trusted_publisher=2, provenance=1).fail_if_trust_downgraded(meta, version, opts) -> Result<(), TrustViolation>. Prerelease-aware vianode_semver::Version::is_prerelease().src/fetch_attestation_published_at.rs: port. URL{registry}/-/npm/v1/attestations/{name}@{version}. Walk Sigstore bundles → pick earliestintegratedTime→ RFC3339 viachrono::DateTime::from_timestamp. ReturnsOption<String>.src/fetch_full_metadata.rs: minimal no-cache fetcher ({registry}{name}withAccept: application/json).src/named_registry.rs: portBUILTIN_NAMED_REGISTRIES+ prefix-routing helper.src/lookup_context.rs: per-install dedup caches (Arc<tokio::sync::Mutex<HashMap<...>>>forabbreviated_meta,published_at,local_meta,full_meta,full_meta_for_trust).Tests. Per-scenario mockito tests for minimum-release-age (pass / fail / missing-time), trust-downgrade (publisher→provenance, provenance→unsigned, ignore-after, prerelease), attestation endpoint (hit / 404 / malformed), non-registry short-circuit,
can_trust_past_check.Blocks. Phases 5 and 6. Blocked by. Phases 1, 2, 3.
Phase 5 — On-disk packument mirror
Deliverable.
fetch_full_metadata_cachedwith conditional GET against a mirror at<cache_dir>/v3/<full-meta-dir>/<scope>/<name>.json. Used by the verifier; regular resolver path untouched.Edits. Inside
pacquet/crates/resolving-npm-resolver/src/:fetch_full_metadata_cached.rs: portfetchFullMetadataCached.ts:30-99and helpers frompickPackage.ts(get_pkg_mirror_path,load_meta,load_meta_headers,save_meta,prepare_json_for_disk). Conditional headers + 304 / 200 / 4xx-5xx fallback.create_npm_resolution_verifier.rsto callfetch_full_metadata_cachedinstead offetch_full_metadata.Tests. Mockito: cold cache → 200 → mirror written; warm → 304 → mirror used; stale → 200 → overwrite; read-only cache dir → succeeds with debug-logged write failure.
Blocks. Phase 7. Blocked by. Phase 4.
Phase 6 —
pacquet-lockfile-verificationcrateDeliverable. End-to-end runner + JSONL cache + recorder. A test can call
verify_lockfile_resolutions::<SilentReporter>(lockfile, &[verifier], &opts).await?and see violations, log events, and a fresh cache record on disk.Create. Crate at
pacquet/crates/lockfile-verification/.Cargo.toml:pacquet-resolving-resolver-base,pacquet-lockfile,pacquet-reporter,pacquet-diagnostics,tokio(sync + time),futures-util,sha2,serde,serde_json,derive_more,miette. No reqwest / registry deps.src/verify_lockfile_resolutions.rs: portverifyLockfileResolutions.ts:1-288.pub async fn verify_lockfile_resolutions<R: Reporter>(lockfile, verifiers, opts) -> Result<(), VerifyError>.pub async fn collect_resolution_policy_violations(...)(data-returning).collect_candidates(lockfile)iterateslockfile.packages+lockfile.snapshots, keys by(PkgName, version, serde_json::to_string(&resolution))to dedupe peer-context / patch-hash duplicates.tokio::sync::Semaphore::new(opts.concurrency.unwrap_or(16))+futures_util::stream::iter(candidates).buffer_unordered(N). Per-candidate future acquires permit, fans across verifiers, stops at first failure per candidate.format!(\"{name}@{version}\"); cap at 20 visible; escalate toLOCKFILE_RESOLUTION_VERIFICATIONwhen distinct codes > 1.Startedbefore fan-out,DoneorFailedafter withelapsed_ms. Skip emit when candidate set is empty.src/cache.rs: portverifyLockfileResolutionsCache.ts:1-428.pub fn try_lockfile_verification_cache(cache_dir, key) -> CacheLookupResult.pub fn record_verification(cache_dir, key, precomputed).CACHE_FILE_NAME = \"lockfile-verified.jsonl\",MAX_CACHE_ENTRIES = 1000,COMPACT_TRIGGER_BYTES = 1_536_000.MetadataExt::ino()on unix;0on Windows.(path, hash), reverse-walk to keep newest, trim toMAX_CACHE_ENTRIES, write to<file>.<pid>.tmpthenrename.std::fs::*).src/record_lockfile_verified.rs: 5-line wrapper.src/hash_lockfile.rs:pub fn hash_lockfile(lockfile: &Lockfile) -> String. Pacquet'sLockfileusesHashMap; normalize-into-BTreeMapinsidehash_lockfileonly (don't change the public type). PrivateNormalize<'a>(&'a Lockfile)newtype implementsSerializeby reordering;serde_json::to_writerintoSha256; hex-format.src/errors.rs:VerifyErrorenum —LockfileResolutionVerification(mixed-code batch),MinimumReleaseAgeViolation,TrustDowngrade. Usederive_more::Display+derive_more::Error+miette::Diagnostic. Help text: port upstream's hint verbatim.Tests. Two layers — runner (empty list noop, ok-emit, single-violation, mixed-code escalation, 25-violations cap, dedup, concurrency cap) and cache (round-trip, stat-shortcut, content-hash hit, compaction at 1.5MB, concurrent append, policy trust comparison).
Blocks. Phase 7. Blocked by. Phases 1, 4.
Phase 7 — Wire-up in
pacquet-package-manager::installDeliverable.
pacquet install --frozen-lockfileruns the gate. End-to-end: a fresh-published version +minimumReleaseAge = 14dinpnpm-workspace.yaml→ install fails before any tarball is fetched.Edits.
pacquet/crates/package-manager/Cargo.toml: addpacquet-lockfile-verification,pacquet-resolving-resolver-base,pacquet-resolving-npm-resolver.pacquet/crates/package-manager/src/install.rs:Lockfile::load_current_from_virtual_store_dir, before thepnpm:contextemit at line 248): whenlockfile.is_some(), build the verifier list fromconfig. Usecreate_npm_resolution_verifier(...)—Nonewhen neither policy is set; otherwiseSome(verifier). Collect intoVec<Arc<dyn ResolutionVerifier>>.lockfile_pathfromInstall::lockfile_path(new field) or deriveworkspace_root.join(\"pnpm-lock.yaml\").verify_lockfile_resolutions::<R>(lockfile, &verifiers, &VerifyOptions { cache_dir: Some(&config.cache_dir), lockfile_path: Some(&lockfile_path), concurrency: None }).await.map_err(InstallError::LockfileVerification)?;.lockfile.is_none()paths skip the gate.Installstruct: addpub lockfile_path: Option<&'a Path>. CLI fills it.InstallErrorenum: add#[diagnostic(transparent)] LockfileVerification(#[error(source)] VerifyError). Transparent so the inner miette code is what the user sees.pacquet/crates/cli/src/cli_args/install.rs: passlockfile_path.Tests.
pacquet/crates/package-manager/src/install/tests.rs: real-fixtureTempDir + AddMockedRegistry. Mocked registry returns a packument where the locked version's publish time is < cutoff. RunInstall::run::<SilentReporter>. ExpectErr(InstallError::LockfileVerification(_))and assert the violation code.pacquet/crates/cli/tests/cli_lockfile_verification.rs(new): full CLI integration viaCommandTempCwd. Assert non-zero exit + stderr containsMINIMUM_RELEASE_AGE_VIOLATION.LockfileVerification(Started) → LockfileVerification(Done) → Context → Stage(ImportingStarted) → ….Blocks. Nothing. Blocked by. Phases 1, 2, 4, 5, 6.
Phase 8 — Docs + TEST_PORTING checklist (follow-up)
Deliverable.
pacquet/plans/TEST_PORTING.mdlists upstream tests this work mirrored. Diagnostic help text matches upstream byte-for-byte.Edits.
pacquet/plans/TEST_PORTING.md: add entries forverifyLockfileResolutions.ts,verifyLockfileResolutionsCache.ts,recordLockfileVerified.ts,minimumReleaseAge.ts,createNpmResolutionVerifier.test.ts,trustChecks.test.ts,fetchAttestationPublishedAt.test.tswith line ranges and "ported" checkboxes.pacquet/crates/diagnostics/...: insta snapshot of the rendered error for one violation, three (single-code), three (mixed).Blocks. Nothing. Blocked by. Phase 7.
Verification (end-to-end)
After Phase 7 lands the full chain is usable. Verify with:
just check— workspace compiles.cargo nextest run -p pacquet-resolving-resolver-base -p pacquet-resolving-npm-resolver -p pacquet-lockfile-verification— unit tests in new crates.cargo nextest run -p pacquet-package-manager install::tests::lockfile_verification— package-manager integration test.cargo nextest run -p pacquet-cli cli_lockfile_verification— CLI end-to-end through mocked registry.just lint—--deny warningsstill passes.just ready— full suite + clippy + fmt + typos.Manual smoke:
just registry-mock launch+ a fixture project withminimumReleaseAge: 1dinpnpm-workspace.yamland a lockfile entry resolving to a version the mock claims was published 1 hour ago. Runjust cli -- install --frozen-lockfile. Confirm stderr names the offending package, exit code != 0.minimumReleaseAgeunset — install succeeds, nopnpm:lockfile-verificationevents fire.<cache_dir>/lockfile-verified.jsonl).Per-PR strategy
One PR per phase. Each PR ships:
feat(resolving-resolver-base): port ResolutionVerifier trait,feat(config): add minimumReleaseAge and trustPolicy fields,feat(resolving-npm-resolver): port createNpmResolutionVerifier, etc.Dependency graph:
Written by an agent (Claude Code, claude-opus-4-7).