Chapter 6

The Lockfile & Reproducibility

Reproduce an APM setup exactly and explain how the lockfile supports that guarantee.

Objective

After this chapter you can read the source identities, hashes, and deployment ownership in apm.lock.yaml, compare a restored setup with its recorded state, distinguish apm install --frozen's limited structural checks from content audit, and use apm lock and apm find to separate resolution from deployment and trace a tracked file's origin.

Edition 1.2 — inspected CLI: APM 0.31.0. Chapter 5 made the lockfile appear as a by-product; here it becomes the subject — Reproducibility made mechanical. Current examples use small recorded fixtures; the explicitly historical v0.23.1 snapshot keeps its original evidence boundary.

Concept/Theory

A manifest resolves; a lockfile freezes

The package installs in Chapter 5 wrote apm.lock.yaml. You noticed resolved_commit and content_hash in the Git example without yet studying them. Now ask the lockfile question familiar from npm, pip, or Cargo: how does another checkout reproduce a reviewed result rather than resolve whatever is newest?

The answer is a distinction. A manifest declares intent, and intent is often a moving target: a dependency's ref can be a branch (main), a tag (v1.2.0), a commit SHA, or a semver range (^1.2.0). A branch can move; a range can admit a newer matching tag. Two fresh resolutions without the same lock can therefore select different content from the same manifest. That is the agent-context version of “works on my machine.”

The lockfile records the resolution and deployment ownership. For unchanged Git declarations, install reuses the recorded graph, including transitive commits and manifests. Upstream changes do not silently become new inputs to that replay. Ordinary install can instead reconcile edited declarations or local sources; a missing lock permits fresh resolution. Keep the manifest, lock, local sources, targets, and CLI version together when comparing results (0.31.0 lockfile and replay contract).

Three questions, source-specific answers

Separate what you requested, what was selected, and how content is checked. For Git, resolved_ref → resolved_commit → content_hash illustrates those questions. It is not a universal field set:

Source identity in real 0.31.0 fixtures, not a synthetic combined lock schema.
Source and example Recorded selection Integrity boundary
Git — Chapter 5's pinned public skill Repository, host, virtual_path, full resolved_commit, and resolved_ref; here both ref and commit are fb285168…. Package-tree content_hash plus hashes for deployed files.
Local path — operations/repro repo_url: _local/package, source: local, local_path: ./package. This emitted entry has deployed hashes, no Git ref/commit or package-tree hash. Its source directory must travel with the project; a path is not a version pin.
Experimental registry — operations/registry's operations/exact source: registry, exact version/ref 1.0.0, archive resolved_url and resolved_hash; no Git commit. resolved_hash fingerprints the archive; package-tree and deployed hashes describe different content. This fixture used an anonymous loopback server, not a public hub.

A source coordinate answers where; a hash answers which content. Neither authenticates a publisher or proves a prompt benign. name and ordinary Git/local version are self-asserted inventory metadata. The pinned skill's version: unknown does not weaken its immutable Git commit; registry and Git-semver versions have separate resolution roles (identity and metadata fields).

The hash follows the bytes, not the ref

The historical v0.23.1 Meridian captures make this distinction concrete: separate resolutions selected transitive commits a4aebcd4… and 3169734b…, yet the selected skill's tree hash stayed sha256:9236d06a…. These were not two replays of one unchanged lock. The manifest's transitive edge floated; each generated lock still pinned its selected commit. The risk is the next fresh resolution, not an upstream branch overriding an existing pin.

  • Package-tree content_hash: sorted relative paths and actual materialized file bytes. GitCache-backed subpath checkouts set core.autocrlf=false, but that does not override every .gitattributes or core.eol rule, or rewrite CRLF committed in source.
  • Deployed-file hashes: UTF-8 text normalizes CRLF to LF; binary content is hashed raw. Changing only line endings can therefore change disk bytes without changing the canonical deployed-text hash. Copilot, Claude, and Cursor projections can legitimately have different frontmatter and different hashes.

The 0.31.0 instruction probe changed all six deployed files to CRLF: CI audit exited 0, with the lock unchanged. That proves the deployed-text normalization boundary, not universal cross-platform package-tree or cross-harness byte equality (hash definitions).

Generated, never hand-edited

apm.lock.yaml is a generated record, not an authored file. You edit apm.yml; APM writes the lockfile. The review story lives in the diff: regenerate after an intended change, then review which identities, hashes, and ownership rows moved. Generated does not mean authenticated — protect and review both files. Reproducibility is not staleness, either: refreshing an unchanged declaration deliberately is the apm update workflow in Chapter 7.

In APM

Inside apm.lock.yaml

Read the file as a resolved inventory plus an ownership record, not just a list of versions. The worked fixture contains one local package and one workspace instruction, each projected to Meridian's three targets. Its real lock makes the local-versus-Git distinction visible:

Selected fields and two intact ownership rows from backend/examples/updates/1.2/operations/repro/apm.lock.yaml. Other rows and compatible file lists/hash maps are omitted. Use the complete fixture lock for replay, never this excerpt. apm v0.31.0 — observed
lockfile_version: '1'
apm_version: 0.31.0
dependencies:
- repo_url: _local/package
  name: operations-library
  version: 1.0.0
  package_type: apm_package
  source: local
  local_path: ./package
deployments:
- kind: project-relative
  target: copilot
  value: .github/instructions/library.instructions.md
  runtime: null
  scope: project
  owners:
  - ./package
  active_owner: ./package
  content_hash: sha256:bbd17dce3e54d910f4ce715acc9af57e80a18a7ca766a810e454e755db56214c
- kind: project-relative
  target: copilot
  value: .github/instructions/workspace.instructions.md
  runtime: null
  scope: project
  owners:
  - .
  active_owner: .
  content_hash: sha256:2399c0e96cf03bf9b386d6a1f00ae3afadee2b1d58c21f321765549e27e716b3

Each deployments row identifies a destination through kind, target, value, runtime, and scope. owners records its claimants, oldest to newest; active_owner names the current one and must be a member of that list. Here the dependency's ownership key is ./package, while . owns the workspace instruction. A row's content_hash describes that deployed content, not the dependency's entire tree. Native-entry and directory rows need not carry a file hash (canonical ownership rows).

The compatible per-dependency deployed_files list and deployed_file_hashes map remain in the full file. Root .apm/ outputs also retain local_deployed_files/local_deployed_file_hashes. A larger Git graph adds depth and resolved_by for transitive provenance. An MCP-only project can have an empty APM dependency list alongside mcp_servers, mcp_configs, mcp_target_servers, and URI ownership rows. Do not infer one dependency shape from another.

New locks omit generated_at. A legacy timestamp was retained on an unchanged restore, but it is not a change oracle. Git-semver locks still record constraint, resolved_tag, and resolved_at under format '2'; registry locks also use format '2'. Even apm_version is optional: Chapter 5's root-local-only lock omitted it. Prove a no-op by comparing complete lock bytes and expected outputs, as in the current restore exercise, not by watching a timestamp or an installed-package counter.

apm install --frozen — a limited structural gate

CI should not silently invent an unreviewed dependency resolution. Frozen install requires an existing lock and checks structural conditions, including missing direct Git dependencies and declared MCP configuration. But it is not a universal declaration-change veto. The independent Chapter 5 check added a new local path declaration while retaining the old lock: frozen install exited 0, deployed the new skill, and rewrote that lock. The direct-dependency presence checker skips local paths (0.31.0 structural check).

Bounded 0.31.0 observations for apm install --frozen, not a universal consistency test.
Situation Exit Observed boundary
Lockfile missing entirely 1 Requires a lock; no project writes.
New direct Git package absent from the old lock 1 Names the missing package; no project writes. Priya's case.
New local path declaration, old lock retained 0 Installed the new local skill and changed the lock.
Declared local source directory missing 1 A local source must actually exist; it cannot be fetched from a Git pin.
Full-SHA selector changed to the public tag at the same commit 0 Lock metadata changed; not a literal-ref-string gate.
Already locked transitive skill declared directly at its locked SHA 0 Lock changed to represent the direct declaration.
Root MCP arguments changed in the manifest, old lock retained 1 Manifest-to-lock MCP configuration differs.
Native output edited, declaration unchanged 0 The tested instruction was restored; the tested native MCP edit was left unchanged. Neither was rejected as content drift by the frozen preflight.

Frozen install can materialize files and change lock metadata; its successful exit does not prove that the committed contract stayed unchanged. Nor is it an audit of native file bytes. The CLI's reminder is literal: Lockfile presence verified. Run 'apm audit' for on-disk content integrity. With unchanged Git declarations, a moving upstream branch is handled by replaying the locked commit, not by comparing the remote branch during preflight (frozen mode and locked replay).

Audit can replay in scratch — cold is not offline

apm audit --ci checks supported content, consistency, and ownership separately. It can acquire locked sources into an owned scratch replay when modules/cache are absent. Clean public and local cold probes left the checkout unchanged; public replay with acquisition deliberately unavailable exited 1, not a harmless skip. Cold public restore or CI audit may need network access. A warm cache or local-only success is not proof of an offline deployment plan (CI scratch replay).

Audit is not a universal native-configuration check either: in the tested MCP-only project, CI audit did not detect missing or edited native MCP configuration. Frozen install recreated the missing file but left the edit intact. Check such harness-specific state separately rather than treating a clean exit as byte certification. Chapter 7 develops audit; Chapter 8 develops its security boundaries.

apm lock — write the contract without deploying

Sometimes you want to resolve a declaration for review without altering harness files. apm lock separates those jobs: it can acquire/materialize dependency metadata and write the lock, but does not deploy or delete harness files. Crucially, it preserves existing deployment state for later reconciliation (lock-only behavior).

Run this command once in each separate local scratch copy described below. Both recorded runs exited 0; neither deployed harness files. Offline fixture. apm v0.31.0 — observed
apm lock
Same lock-only command, different prior deployment state.
Scratch setupRecorded result
Installed repro; keep its lock, delete only .github/instructions/library.instructions.md as a canary. Canary stayed absent; previous file hashes, ownership, and complete lock bytes remained.
Source-only repro; no previous lock or harness outputs. Resolution-only lock and modules appeared; no deployment rows or harness files.

Keeping ownership is not proof that files are still present. After a target contraction, lock-only generation also retained Claude/Cursor files and their ownership; ordinary install performed the cleanup. Use apm lock to compute the record without deployment, not to repair a missing file or erase cleanup history.

An SBOM timestamp is a different clock

Inventory export serializes a lock; it does not re-resolve or authenticate its contents. The inspected help documents apm lock export --timestamp as an SBOM timestamp control, with fallback to SOURCE_DATE_EPOCH, a legacy lock timestamp, then the Unix epoch. That help/source contract is distinct from execution evidence: separate producer probes actually exported CycloneDX and SPDX and repeated the fixed-timestamp CycloneDX export byte-identically. Neither operation restores generated_at to new locks (inventory export).

Recorded example using the genuine lock in backend/examples/updates/1.2/producer/pinned-bundle, not a run on repro. Export reads the existing lock without fetching packages; both identical invocations exited 0. apm v0.31.0 — observed
apm lock export --format cyclonedx --timestamp '2026-09-16T00:00:00+00:00' -o sbom.cdx.json

apm find — trace a tracked file to its origin

“Where did this instruction come from?” is the provenance question behind apm find. It reads the lock's deployed-file records; --source adds the source coordinate. This is a lookup, not a fresh hash check or publisher authentication (provenance lookup).

Lookup in the installed repro fixture: dependency, workspace source, then a deliberate untracked-path query. Recorded exits: 0 / 0 / 1. Offline. apm v0.31.0 — observed
apm find .github/instructions/library.instructions.md --source
apm find .github/instructions/workspace.instructions.md --source
apm find .github/not-owned.md

The first reports _local/package ./package; the second reports . (workspace). The third reports that the path is not tracked. Compare those answers with the two ownership rows: source identity and deployment ownership answer related but different questions.

Historical Meridian provenance snapshot — APM 0.23.1
Original full-package lookup capture, 2026-07-02. --path shows the transitive chain; it remains in the 0.31.0 help, but this transcript was not rerun at that version. The commit prefix is historical: an unpinned manifest edge still had a pinned commit in this lock. Lookup is offline and says nothing about current on-disk integrity. apm v0.23.1
$ apm find .github/instructions/design-standards.instructions.md
  microsoft/apm-sample-package                         # the owning package

$ apm find .github/instructions/design-standards.instructions.md --source
  microsoft/apm-sample-package  microsoft/apm-sample-package@v1.0.0    # pinned -> @tag

$ apm find .agents/skills/review-and-refactor/SKILL.md --path
  github/awesome-copilot
    apm.yml -> microsoft/apm-sample-package -> github/awesome-copilot  # the transitive chain

$ apm find .agents/skills/review-and-refactor/SKILL.md --source
  github/awesome-copilot  github/awesome-copilot@3169734bc2fb          # unpinned -> @commit

$ apm find .github/instructions/meridian-checkout.instructions.md --source
  .  (workspace)                                       # your own .apm/ source, not a package

$ apm find .github/does-not-exist.md
  [x] '.github/does-not-exist.md' is not tracked by any installed package in apm.lock.yaml.   # exit 1

When to use / pitfalls

Treat the lockfile as a contract

Choose the job first: replay, reconcile, verify, resolve without deployment, or trace provenance. A successful command answers its own question, not all five.

Which lockfile-related command to reach for, by intent.
You want to… Run… Because
set up or re-materialize a cloned repo apm install (bare) Replays unchanged Git intent; reconciles changed declarations or local sources (Chapter 5).
materialize a reviewed contract in CI apm install --frozen Requires a lock and performs limited structural checks; also reject unexpected manifest/lock changes.
check supported deployed content and ownership apm audit --ci Separate integrity/replay checks; cold acquisition can need network and native coverage varies.
resolve/review without changing harness files apm lock Writes resolution state, retains existing deployment ownership, deploys/deletes no harness files.
find where a deployed file came from apm find <file> Looks up recorded ownership/source, not current integrity or publisher identity.
move to newer versions apm update (Chapter 7) Reproducibility is not staleness; review intentional refreshes separately.

Worked example

The CI break, mechanically

Meridian shipped v0.2.0 in Chapter 5 with one pinned dependency and one review script. Priya's next lesson is to commit the regenerated lock, not merely the new declaration. Rehearse that boundary with a small separate fixture, rather than an inaccessible private package or an invented lockfile diff. This exercise does not change Meridian's milestone or canonical manifest.

1. Start with real local inputs

Use APM 0.31.0, not a moving latest release. From backend/examples/updates/1.2/operations/repro/, copy the two manifests below plus .apm/instructions/workspace.instructions.md and package/.apm/instructions/library.instructions.md into a new short scratch directory outside the book checkout. Include the hidden directories. These sources contain only small instructions: no executable primitives or network dependencies.

For this first-generation exercise, keep the fixture's genuine apm.lock.yaml separately for comparison; start without modules, native outputs, or a lock in scratch. A real clone-and-restore workflow instead brings the committed lock from the start.

Complete repro/apm.yml: a local dependency and three explicit destinations. Offline fixture. apm v0.31.0 — observed
# Recorded identity, three target projections, and a local dependency.
name: operations-repro
version: 1.0.0
targets: [copilot, claude, cursor]
dependencies:
  apm:
    - path: ./package
  mcp: []
includes: auto
Complete repro/package/apm.yml, accompanying its library instruction. Local identity is a path to these sources, not a Git SHA. apm v0.31.0 — observed
# A safe local source; no network or executable primitives.
name: operations-library
version: 1.0.0
dependencies: {}

2. Generate, replay, then check

Run these consecutively in the source-only scratch copy. The first install creates the recorded state; the next two replay unchanged inputs. The last command asks the separate integrity question. --no-fail-fast -f json requests all diagnostic checks in JSON, not a different integrity policy.

Generation, bare replay, frozen replay, and CI audit of repro. Recorded exits: 0 / 0 / 0 / 0. Offline. apm v0.31.0 — observed
apm install
apm install
apm install --frozen
apm audit --ci --no-fail-fast -f json

The complete lock's recorded SHA-256 after each command was 79fe2a70ae0c8b9987ddd0105d013c6593a6c3fd8135ae94601d4df58fd22772. This is a hash of the entire lockfile, not a dependency field. generated_at was absent. Compare your complete lock and the native files below; do not substitute a timestamp, summary message, or exit code for those observations.

Six native outputs of the local fixture; the authored inputs remain in place.
TargetProject-relative files
Copilot .github/instructions/library.instructions.md
.github/instructions/workspace.instructions.md
Claude .claude/rules/library.md
.claude/rules/workspace.md
Cursor .cursor/rules/library.mdc
.cursor/rules/workspace.mdc

This clean instruction fixture exercises content and ownership, not live harness behavior or organization-policy enforcement. For acquisition from an empty cache, use Chapter 5's pinned public skill: its cold frozen restore preserved the lock while fetching the recorded source. That variant requires public Git access on a cold cache.

3. Reproduce the forgotten Git lock

Make a separate copy of the successfully installed fixture. Keep its old lock and all its sources, but replace only the manifest's dependencies block with this one. The extra package is real and pinned; it is deliberately absent from this copy's lock:

Edited dependency block only — retain the rest of repro/apm.yml and its old lock. This negative test stops before fetching the public package. apm v0.31.0 — observed
# Scratch-only edit: new Git declaration, old local-only lock.
dependencies:
  apm:
    - path: ./package
    - microsoft/apm-sample-package#v1.0.0
  mcp: []
Frozen install in the mismatched scratch copy: expected exit 1, no project writes or package fetch. apm v0.31.0 — observed
apm install --frozen

The diagnostic names microsoft/apm-sample-package as declared in apm.yml but missing from apm.lock.yaml. This is Priya's mistake mechanically, not an upstream ref moving. Do not replace the Git addition with a new local path and expect the same refusal: the independent local-path result was exit 0 with a changed lock.

Discard the disposable mismatch copy when the exercise is complete. In a real pull request where the addition is intended, Priya reconciles with ordinary apm install, reviews the genuinely generated lock and deployments, and commits them alongside the manifest. Acquiring that public Git graph can require network access. The rule is regenerate, review, commit — not hand-author a plausible SHA or accept whatever a CI install happened to write.

Repeat the bounded checks automatically

The existing runner rehearses frozen replay, per-target drift, invalid ownership, CRLF hashes, and the retained-lock canary in owned scratch copies. It needs Python 3.10+ and PyYAML 6; use the absolute prepared APM executable. Expected negative audit exits are assertions of detection, not a claim that every package type is covered.

Reusable local checks; substitute your prepared executable's absolute path. The runner preserves results and test projects outside the checkout. apm v0.31.0 — explorer fixture suite
$Apm = '<absolute path to the prepared APM 0.31.0 executable>'
python .\backend\examples\updates\1.2\operations\verify.py --apm $Apm --suite repro

Recap & next

Recap

  • The manifest names intent; the lockfile freezes the fact. apm.lock.yaml records resolved source identities and deployment ownership. Unchanged Git intent replays the locked graph; changed declarations or local sources require reconciliation.
  • Identity depends on source type. Git commits, local paths, and registry archive identities are not interchangeable. Package-tree hashes use actual bytes; deployed UTF-8 hashes normalize CRLF.
  • Frozen checks are limited. Missing locks and the tested missing Git declaration fail; a newly declared local path can install and rewrite the old lock. Compare the committed contract before/after, and audit supported content separately. Cold CI replay may need network.
  • apm lock leaves harness files alone and retains existing deployment state. A fresh resolution-only lock has no previous ownership to retain. apm find traces recorded files; neither a lookup nor an SBOM authenticates a publisher.
  • Never hand-edit the lockfile. Fix the manifest, regenerate, review the diff like a dependency change, and commit the re-locked file. New locks omit generated_at; the current exercise proves a no-op with complete lock bytes and expected native outputs.
  • Meridian's CI break was a new Git declaration missing from the committed lock, not an upstream ref moving. Priya's lasting fix is reconcile-review-commit.

Next

Reproducibility holds the graph still; the next question is how to move it deliberately. Chapter 7 — Lifecycle opens the intentional-change door named here: apm outdated to report available versions, apm update to refresh on purpose, and apm audit — the separate integrity companion to --frozen — to check the resulting supported content and ownership.