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 apm.lock.yaml and say exactly what it guarantees — the three levels of identity it records (resolved_refresolved_commitcontent_hash), why bare apm install reproduces the same bytes instead of chasing whatever main points at today, and how to turn that guarantee into a fail-closed CI gate with apm install --frozen. You will also use apm lock to write the contract without deploying, and apm find to trace any deployed file back to the package that produced it. Chapter 5 made the lockfile appear as a by-product; here it becomes the subject — the moment Reproducibility stops being a promise and becomes a mechanism.

Concept/Theory

A manifest resolves; a lockfile freezes

Every install in Chapter 5 quietly wrote apm.lock.yaml, and you were told to notice two fields inside it — resolved_commit and content_hash — without yet studying them. This is the chapter that opens that file up, because it answers the question Chapter 5 deliberately deferred: why does re-running apm install reproduce the same bytes instead of fetching 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 pointer moves with every upstream push; a range admits whatever tag is newest the moment you run install. So two developers installing the same apm.yml a week apart — or a laptop and a CI runner installing it the same minute — can resolve different bytes. That is the agent-context version of “works on my machine.”

The lockfile records the resolution: the exact commit each declared dependency resolved to, for the whole transitive graph, plus a content fingerprint of the bytes that landed. Where the manifest says “give me something matching this,” the lockfile says “here is exactly what you got.” Its very first job, per the spec, is reproducibility: “apm install --frozen reinstalls the exact commits recorded here — no resolution, no network drift” (Lockfile specification). Subsequent installs replay the lockfile rather than re-resolving — which is why restore is deterministic, and why the manifest, not the lockfile, is the thing you edit by hand.

Three levels of identity

The heart of the chapter is that each lockfile entry pins identity at three levels, and keeping them straight is what makes “the same skill” a falsifiable claim rather than a hope:

The three identity levels every lockfile entry records, from intent to bytes. Field definitions from the Lockfile specification.
Level Field The question it answers
Intent resolved_ref What did you ask for? — the ref from apm.yml (v1.0.0, main, a SHA)
Resolution resolved_commit What did it resolve to? — the exact 40-char commit SHA. The pin.
Content content_hash What are the bytes, exactly? — a SHA-256 fingerprint of the source tree

A commit SHA answers where the content came from; a content hash answers what the content is. Agent context is executable — a prompt is a program for a model — so “the same review prompt” has to mean the same characters, not merely the same source pointer. That is why the lockfile records both the pin and the hash: “content_hash — SHA-256 of the package file tree — makes ‘same install on every clone’ mean byte-for-byte the same” (The three promises). The hash is also the layer that Security and provenance rest on: if the recorded hash and the on-disk bytes ever diverge, something changed — full stop.

The hash follows the bytes, not the ref

Pinning is by commit; integrity is by content — and the two can move independently. Meridian's one transitive dependency makes this concrete. In Chapter 5 the direct dependency was pinned (microsoft/apm-sample-package#v1.0.0), but it pulled in a skill from github/awesome-copilot that resolved unpinned — APM even warned [!] 1 dependency unpinned. Because #main floats, its resolved_commit moved between sessions (from a4aebcd4… in Chapter 5 to 3169734b… here). Yet its content_hash stayed byte-identical (sha256:9236d06a…): the ref floated onto a new commit whose bytes were the same, so the fingerprint did not budge. That is content-addressing — and it is also the warning behind the unpinned notice. Pinning freezes the commit; if you want the commit frozen too, pin the ref. Deliberately moving to a newer commit is the drift door Chapter 7 opens with apm update.

Generated, never hand-edited

apm.lock.yaml is a build product of resolution, not an authored file. You edit apm.yml; APM writes the lockfile. That one rule is what keeps it trustworthy: its entire value is that it is a faithful, mechanical record of what resolution produced, so the review story lives in the diff. Change the manifest, regenerate, and the lockfile diff shows exactly which commits and hashes moved as a consequence — a dependency change you review like any other. And reproducibility is not staleness: the lockfile guarantees the same bytes on replay; moving to newer bytes is a separate, consent-gated act (apm update, Chapter 7).

In APM

Inside apm.lock.yaml

Here is the actual lockfile apm install wrote for Meridian v0.2.0, annotated field by field. Read it top to bottom: a small header, then a flat dependencies list holding both direct and transitive packages, then a block for your project's own materialized files. Every dependency pins by resolved_commit and fingerprints by content_hash.

The lockfile apm install --target copilot,claude,cursor generated for Meridian v0.2.0, annotated. Repetitive path/hash lines are elided () for readability; every distinct field is shown. Verified on apm v0.23.1.
lockfile_version: '1'                         # schema version of the lockfile FORMAT itself
generated_at: '2026-07-02T00:53:23...+00:00'  # UTC time the RESOLVED CONTENT last changed; stable across restores
apm_version: 0.23.1                           # the CLI that wrote this lock (provenance / debugging)
dependencies:                                 # every resolved package: DIRECT and TRANSITIVE, one flat list
- repo_url: microsoft/apm-sample-package      # the source repo (owner/repo shorthand)
  name: apm-sample-package
  host: github.com
  resolved_ref: v1.0.0                        # INTENT: the ref you pinned in apm.yml
  resolved_commit: fb2851683be0e0e7711421d518bd8dba23b0b1f6   # RESOLUTION: the exact 40-char SHA -- the pin
  version: 1.0.0                              # the package's declared semver (from its own apm.yml)
  package_type: apm_package                   # kind of package; drives how it deploys
  deployed_files:                             # DEPLOYMENT RECORD: every file this package materialized
  - .github/agents/design-reviewer.agent.md
  - .github/instructions/design-standards.instructions.md
  - .claude/agents/design-reviewer.md
  - .cursor/agents/design-reviewer.md
  - .agents/skills/style-checker/SKILL.md
  # ... 11 more deployed paths across .claude/ .cursor/ .github/ ...
  deployed_file_hashes:                       # CONTENT: SHA-256 of each deployed file -- what `apm audit` checks
    .github/agents/design-reviewer.agent.md: sha256:616988766e...   # one source primitive ->
    .claude/agents/design-reviewer.md:        sha256:616988766e...  #   identical bytes in every harness
    .agents/skills/style-checker/SKILL.md:    sha256:1142700284...
    # ... 11 more path -> sha256 entries ...
  content_hash: sha256:744cca54cc8ff7ca90aa1dd621c2f98c6291cd793815afe8518001cc94b8aba9  # fingerprint of the SOURCE tree
- repo_url: github/awesome-copilot            # the TRANSITIVE dep (pulled in by the package above)
  name: awesome-copilot
  host: github.com
  resolved_commit: 3169734bc2fb25d5e092130fc93d24b0dee3ac3a   # FLOATS: unpinned #main -- do not treat as fixed
  version: unknown                            # unpinned: no tag resolved (hence the [!] warning at install)
  virtual_path: skills/review-and-refactor    # the sub-path carved from the monorepo (one primitive, not a whole pkg)
  is_virtual: true                            # a "virtual" package: a slice of a repo
  depth: 2                                     # 1 = direct from your apm.yml; 2 = transitive
  resolved_by: microsoft/apm-sample-package    # PROVENANCE: the parent edge that introduced this dep
  package_type: claude_skill
  deployed_files:
  - .agents/skills/review-and-refactor/SKILL.md
  - .claude/skills/review-and-refactor/SKILL.md
  content_hash: sha256:9236d06a1500089ddb46975b866e9a63478e502afe7095b1980c618678a7c7fe  # IDENTICAL to Ch5 though the commit moved
local_deployed_files:                         # your project's OWN .apm/ sources, materialized (no dependency involved)
- .github/instructions/meridian-checkout.instructions.md
- .github/prompts/checkout-review.prompt.md
- .claude/rules/meridian-checkout.md
# ... 3 more local paths across .claude/ .cursor/ ...
local_deployed_file_hashes:                   # SHA-256 of each local file (environment-specific: line endings)
  .github/instructions/meridian-checkout.instructions.md: sha256:bf0a9567...
  # ... 5 more local path -> sha256 entries ...

A few things are worth naming. The deployed_files / deployed_file_hashes pair is the deployment record — the map apm audit checks against disk; notice the two design-reviewer entries share one hash (616988766e…), proof that “one source primitive → many harness files” is still one set of bytes. The transitive entry adds three provenance fields — is_virtual (a slice of a repo, not a whole package), depth (2 = transitive), and resolved_by (the edge that pulled it in). The local_deployed_* block is your own .apm/ content; its hashes are environment-specific (line endings), so treat them as local, not as a cross-machine assertion. And there is no mcp: section, because this project declares mcp: [] — how MCP servers appear in the lockfile is Chapter 8's story, not something to infer here.

One field deserves a caveat: generated_at stamps “the time the resolved content last changed,” not “when you last ran install.” It advances only when resolution changes, and is preserved byte-for-byte across restores — which is exactly what makes restore provably deterministic:

Determinism: two consecutive bare apm install runs on the unchanged project leave the lockfile byte-identical — generated_at does not even advance. Cache-served, offline. apm v0.23.1
$ apm install ; apm install            # restore twice, no changes
RUN1  generated_at: 2026-07-02T00:58:03...+00:00   sha=EAD3EE9B...747D
RUN2  generated_at: 2026-07-02T00:58:03...+00:00   sha=EAD3EE9B...747D   # <- identical
BYTE_STABLE = True                     # generated_at frozen; file unchanged

That is the headline made mechanical: git clone + apm install lays down the same bytes on any machine, and re-running install is a byte-stable no-op. Reproducibility, proven each time you restore.

apm install --frozen — the fail-closed CI gate

Reproducibility is only real if something enforces it. A regular install is forgiving — if apm.yml gained a dependency that is not locked yet, it will resolve and lock it for you. That convenience is exactly wrong in CI, where a passing build must prove that what is committed is internally consistent. apm install --frozen makes the lockfile a contract: it “mirrors npm ci” and refuses to install when the lockfile is missing or has drifted from the manifest (apm install). Critically, it is a structural presence check, not a byte comparator. On an in-sync project it behaves like a normal restore and ends with a tell that names its own boundary:

apm install --frozen on the in-sync project: a normal restore that ends with the --frozen tell — it verified presence and points you at apm audit for on-disk content. Offline (cache-served), exit 0. apm v0.23.1
$ apm install --frozen
  [i] Targets: claude, copilot, cursor  (source: apm.yml)
    [+] microsoft/apm-sample-package #v1.0.0 @fb285168 (cached)   |-- adopted
    [+] github.com/.../review-and-refactor @3169734b (cached)     |-- (files unchanged)
    [+] <project root> (local)                                    |-- adopted
  [*] Installed 2 APM dependencies in 13.0s.
  [i] Lockfile presence verified. Run 'apm audit' for on-disk content integrity.   # <- the --frozen tell

That last line is the load-bearing nuance of the whole chapter. --frozen guarantees the graph is the locked graph — the lockfile exists and the manifest agrees with it. It does not re-hash your deployed files. Here is precisely what does and does not trip it, all verified on v0.23.1:

What apm install --frozen catches — and, just as importantly, what it does not.
Situation --frozen Why
Lockfile missing entirely FAIL (exit 1) nothing to reproduce from
A package declared in apm.yml is absent from the lock FAIL (exit 1) manifest ↔ lockfile disagree — the real CI break
Change a pinned ref to another ref at the same commit (#v1.0.0#main) PASS the package is still present; the ref string is not what --frozen guards
Add a dependency whose repo is already locked (even transitively) PASS the repo is present; it is promoted, not “missing”
A floating ref drifts to a different commit PASS (not caught) structural, not a commit comparator — the resolved_commit pin holds this
A deployed file is hand-edited on disk PASS (not caught) structural, not content — that is apm audit's job

So the running-example break is not “a ref moved upstream.” It is a package that is declared in the manifest but missing from the committed lockfile — someone added a dependency and did not commit the re-locked file. The docs are explicit about the boundary: “This is a structural check, not a content check — run apm audit --ci for hash verification” (apm install). Pair them and never conflate them: --frozen = the graph is the locked graph; apm audit = the bytes are the locked bytes (the audit command lands in Chapter 7, and Chapter 8 builds its security guarantees on the same content hash).

apm lock — write the contract without deploying

Sometimes you want the lockfile refreshed but you do not want to touch your working tree — a PR check that only asks “does this manifest resolve cleanly?”, or a headless job that regenerates the lock, or an SBOM export. apm lock is that tool: it “resolves dependencies and writes apm.lock.yaml without deploying files” (apm lock). We proved the no-deploy guarantee by deleting the lockfile and one already-deployed file as a canary, then running apm lock:

apm lock resolves the graph and writes the lockfile but deploys nothing — a deleted deployed file (the canary) is not recreated, and the lock it writes omits the deployment record. needs network apm v0.23.1
$ del apm.lock.yaml
$ del .github/instructions/design-standards.instructions.md    # canary: an already-deployed file
$ apm lock
  [+] microsoft/apm-sample-package #v1.0.0 @fb285168
  [+] github.com/github/awesome-copilot/skills/review-and-refactor #default @3169734b
  [+] Lockfile written to apm.lock.yaml
# lockfile regenerated? True     canary recreated? False        # <- resolved + locked, but NOT deployed
# the new lock has NO deployed_files / local_deployed_files     # <- resolution-only

That last comment is the catch: an apm lock file is resolution-only. It records resolved_commit / resolved_ref / content_hash per dependency but omits deployed_files, deployed_file_hashes, and the local_deployed_* block — because it never deployed. It is perfect for validation and apm lock export (SBOM/inventory), but it is not the same artifact apm install commits: a later install will add the deployment record and change the file. Use apm lock to compute the contract; use apm install to compute and materialize it.

apm find — trace any file to its origin

Reproducibility is worthless if you cannot answer “where did this file come from?” apm find closes that loop. Reading straight from apm.lock.yaml, it prints the package that owns a deployed file; --source appends the resolved coordinate, and --path prints the full root-to-target chain, including the transitive edge:

apm find traces a deployed file to the package that produced it, straight from the lockfile. --source adds the resolved coordinate; --path prints the full provenance chain, including the transitive edge. The awesome-copilot@3169734b coordinate is unpinned — the tip at capture time; #main floats, so your run may differ. Offline. 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

Notice how the coordinate mirrors the pin: a pinned dependency resolves to pkg@v1.0.0 (the tag), an unpinned transitive one to pkg@<commit>, and a local file to . (workspace). Because apm find reads the lockfile, it is only ever as honest as the committed lock — one more reason the lockfile is the artifact this whole chapter is about.

When to use / pitfalls

Treat the lockfile as a contract

Four moves read or write that contract; reach for them by intent. Bare restore reproduces; the other three verify, regenerate, or trace.

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) restore replays the locked graph byte-for-byte (Chapter 5)
gate CI on reproducibility apm install --frozen fails closed if apm.yml and apm.lock.yaml disagree
regenerate/review the lock without touching your tree apm lock resolves and writes the lock, deploys nothing (PR checks, SBOM)
find where a deployed file came from apm find <file> traces it to the owning package and its provenance chain
move to newer versions apm update (Chapter 7) reproducibility is not staleness; version moves are deliberate

Worked example

The CI break, mechanically

Meridian shipped v0.2.0 in Chapter 5 with one pinned dependency and one review script. Now Priya adds a second, branch-pinned dependency. On her laptop apm install resolves it and updates apm.lock.yaml — but she commits only the manifest edit:

The apm.yml change Priya committed — a new branch-pinned dependency (acme/checkout-guardrails is a throwaway illustration for this CI-break scenario, not a dependency Meridian adds to its canonical manifest). The regenerated lockfile was not committed alongside it. apm v0.23.1
dependencies:
  apm:
    - microsoft/apm-sample-package#v1.0.0
    - acme/checkout-guardrails#main          # <- added, branch-pinned; lock re-generated locally but NOT committed
  mcp: []

CI checks out that commit — new manifest, old lockfile — and runs the reproducibility gate. It fails closed:

CI runs apm install --frozen and fails: the manifest names a package the committed lockfile does not pin. Exit 1 in 0.1s, with no network access — the structural gate fires before any clone is attempted. apm v0.23.1
$ apm install --frozen
  [>] Installing dependencies from apm.yml...
  --frozen: apm.lock.yaml is out of sync with apm.yml.
    - acme/checkout-guardrails is declared in apm.yml but missing from apm.lock.yaml
  [i] Tip: run 'apm outdated' to see what changed, then 'apm update'.
  [!] Install interrupted after 0.1s.                  # <- exit 1, no network, nothing written

Read the message literally: the break is a manifest ↔ lockfile mismatch, not an upstream ref that moved. The gate never even tried to clone acme/checkout-guardrails — it fired in a tenth of a second, before resolution, precisely because the package is declared but not locked. (That the illustrative repo does not exist is irrelevant: a real forgotten re-lock produces the identical error, because --frozen is checking presence, not fetching bytes.)

The fix is the discipline the whole chapter argues for. Priya does not hand-edit the lockfile. She reconciles by running plain apm install, which resolves the new dependency, regenerates apm.lock.yaml with its pin and fingerprint, and deploys it. The regenerated lockfile's diff is what the pull request reviews — a dependency change, read like any other:

The lockfile diff the reconcile produced — the new dependency arrives with its resolved_commit (the pin) and content_hash (the fingerprint). Illustrative: the exact SHA and hash depend on the package. apm v0.23.1
  dependencies:
   - repo_url: microsoft/apm-sample-package
     resolved_ref: v1.0.0
     resolved_commit: fb2851683be0e0e7711421d518bd8dba23b0b1f6
     content_hash: sha256:744cca54...
+  - repo_url: acme/checkout-guardrails               # the new dependency, now pinned
+    resolved_ref: main
+    resolved_commit: <40-char SHA that #main resolved to>
+    content_hash: sha256:<fingerprint of the deployed bytes>

She commits the lockfile, and CI re-runs the gate. With manifest and lockfile in agreement again, --frozen passes and prints its tell:

With the regenerated lockfile committed, the reproducibility gate is green again. Offline, exit 0. apm v0.23.1
$ apm install --frozen
  ...
  [i] Lockfile presence verified. Run 'apm audit' for on-disk content integrity.   # exit 0

Nothing exotic happened: a manifest edit outran its own committed lockfile, the gate caught it before touching the network, and a regenerate-review-commit brought the two back into agreement. The team leaves with a rule they now treat as non-negotiable — the lockfile is the reproducibility contract: generated, reviewed in the diff, and committed alongside the manifest that produced it. That is Reproducibility, enforced.

Recap & next

Recap

  • The manifest names intent; the lockfile freezes the fact. apm.lock.yaml records the exact resolved graph — direct and transitive — so restore is byte-for-byte. This is where Reproducibility becomes a mechanism, not a promise.
  • Three levels of identity: resolved_ref (what you asked for) → resolved_commit (the pin) → content_hash (the exact bytes). The hash follows the bytes, not the ref — a floated commit with identical content keeps an identical fingerprint.
  • apm install --frozen is a structural presence gate, not a content check and not a ref comparator. It fails when the lockfile is missing or when a package is declared in apm.yml but absent from the lock — the real CI break. On-disk byte integrity is apm audit's job, kept deliberately distinct.
  • apm lock writes the contract without deploying (resolution-only; omits the deployment record) — ideal for PR checks and SBOMs. apm find traces any deployed file to its owning package and provenance chain.
  • Never hand-edit the lockfile. Fix the manifest, regenerate, review the diff like a dependency change, and commit the re-locked file. Restore is byte-stable — a no-op install even leaves generated_at unchanged.
  • Meridian's CI break was a manifest↔lockfile mismatch — a declared package missing from the committed lock — caught offline in 0.1s and fixed by 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 see what drifted, apm update to move pins forward on purpose, and apm audit — the content-integrity companion to --frozen — to verify that the bytes on disk still match the bytes the lockfile promised.