Workflow · last verified 2026-09-14

Declared is not enforced: what an agent config lockfile can and can't tell you

What this workflow is

Declare your Claude API agent resources — agents, skills, environments, memory stores, scheduled deployments — in files the way you declare infrastructure: reconciled by a plan/apply tool, tracked in a committed lockfile. That closes real drift: a file edited without the resource following, or someone hand-editing an agent in the Console behind the repo's back. It does not close a different kind of drift, and the two get confused because both leave a clean-looking repo: a control can be declared, committed, and applied — and never once fire, because the mechanism that was supposed to activate it was itself misconfigured. A lockfile proves a file matches a resource. It has no way to prove the resource did anything.

This page is the plan/apply practice for agent configuration — what ant apply's lockfile catches, what a CI gate around it still needs, and a fixture-based check for the class of drift no lockfile can see, illustrated with an incident from this platform's own .claude/settings.json. It is part of the Engineering Workflow Library.

The job this workflow does

On 2026-09-03 Anthropic's ant CLI (v1.30.0) gained ant apply. The artifact at its centre is claude-lock.json: it ties each declared file — agent, skill, environment, memory store or scheduled deployment — to a live resource ID and keeps a fingerprint of both sides (the doc's own wording is quoted below). You review a plan before anything changes, and every later run reconciles against that record. That is the shape every infrastructure-as-code tool uses, and it earns the same trust for the same reason — a committed plan is reviewable, a lockfile makes reruns idempotent, and "what's declared" and "what's live" stop being two separate stories someone has to keep in sync by hand.

The trap is treating "the lockfile is clean" as "the config is working." A lockfile is built to answer one question: does the file on disk match the resource on the server. It is not built to answer a different, more important one: does the thing that resource describes actually run when it's supposed to. Those come apart in three specific, mechanical ways, and the vendor's own docs name two of them without softening.

What the lockfile actually catches

Two kinds of drift, both real, both worth having caught automatically:

  • A file edited out of step with what's live. Every entry in claude-lock.json carries two hashes — hash and remote_hash. Per the vendor doc, "The two hashes fingerprint what was last sent and what the API returned. That's how a later run notices an edited file, or a resource changed outside these files."
  • A Console edit that bypassed the files entirely. The doc credits the pair of hashes and doesn't say which one flags which case; our reading is that the API-side remote_hash is what exposes a hand edit made outside the repo. When that happens, "the plan ends with This plan cannot be applied: and the reason", the run stops with refusing to apply, and only --force overrides it.

Both are the drift a lockfile is designed for: the declared state and the actual state disagreed, and reconciliation caught it before someone acted on the wrong one.

What it structurally cannot catch

Three gaps, each with a concrete check, because naming the gap without the check is the failure mode this platform's own validating the validator page is about — a control nobody has fixture-tested is a control nobody has actually verified.

1. A declared control that never fires

A lockfile validates the mapping from file to resource. It says nothing about the resource's own activation logic — a permission rule's path matcher, a hook's gate condition, a deployment's cron expression. All of those can be syntactically valid, committed, applied, and present in a clean claude-lock.json, while never once triggering, because the condition that was supposed to turn them on was itself wrong. The lockfile has no opinion on that; it was never asked the question.

As-built, from this repo

Two PreToolUse hooks in this platform's own .claude/settings.json were declared to inject a knowledge-graph context note before Bash commands and file reads. Both were gated on a shell check for the graph index file. The gate checked .graphify/graph.json; the graph does not live at that path — the real build output is graphify-out/graph.json — and from the day those hooks were written until the mismatch was found and repointed, neither hook had ever fired once. Nothing about the file was malformed: valid JSON, a real matcher, a real command. The defect was one path string inside it. Full incident and the fix: skills vs subagents vs hooks, "two hooks that never fired once"; the decision record is docs/tracking/DECISIONS.md, 2026-08-11.

The check: don't read the config file to confirm a control is active — exercise it and assert the observable side effect. For a hook, that means running the exact matcher condition in the exact environment it fires in and asserting the injected context or the block actually appears, not asserting the JSON parses. A lockfile that says the file matches the server is a different, weaker claim than a fixture that says the control fired.

2. Two applies racing

The vendor's own CI guidance is direct about this one: "Run one apply at a time, because nothing locks the lockfile." Two concurrent ant apply runs against the same claude-lock.json — two branches merging close together, a scheduled job overlapping a manual run — can each start from the same lockfile. The doc doesn't say what an overlap produces, so what follows is our inference from its own mechanics, not documented behaviour: a run finds the resources the lockfile records and creates the rest, so two runs that both begin before either has written a new file's entry can each create that resource — and whichever lockfile is written last is the one that survives. A lockfile that keeps a single run consistent says nothing about two runs that overlap.

The check: serialize. Give the CI job that runs ant apply a concurrency group keyed to the lockfile's path, so a second run queues behind the first instead of racing it — this is the failure this platform's parallel agents, one working tree page describes for a file with more than one writer: nothing in the write path compares against a base, so an interleave raises no error. That page partitions agents by the operation each one performs; a lockfile has one kind of writer, so the equivalent here is simply one writer at a time.

3. A CI dry-run that exits 0 on a blocked plan

The obvious CI shape is: run ant apply --dry-run on a pull request, gate merge on its exit code. That gate passes a plan it should have failed. Per the flags reference, --dry-run will "Print the plan and exit without applying or writing the lockfile. Exits 0 even when the plan is blocked." — and the CI section repeats it for the exact case that matters: a dry run on a PR is "informational only and exits 0 even when the plan is blocked." A blocked plan is the case you most want a red build for — a resource edited, archived or deleted outside the files — and the exit code is indistinguishable from a clean, applyable plan.

The check: never gate CI on the dry-run's exit code. Grep its printed output for the blocking phrases the CLI itself uses — This plan cannot be applied:, refusing to apply — and fail the build explicitly when either appears. Keep the exit code as a second condition, not a replacement: a non-zero exit should mean the CLI itself failed (bad credentials, an unparseable file, the network — our inference; the doc lists no exit codes beyond the dry-run's 0), and a check that only greps would read that empty plan as clean. The grep is also only as wide as the two phrases the doc documents: a refusal that the dry-run prints in other words, still with exit 0, would get past it. The exit code answers "did the command run"; the plan text answers "can this be applied," and those are different questions with the same green checkmark if you only check the first.

Adjacent evidence: the same shape, in a different Anthropic product

Claude Code's own permission rules gave the same lesson from the enforcement side rather than the observability side, a week later. Across 2.1.268 and 2.1.269 (2026-09-10 to 2026-09-11), at least four permission-rule bugs shipped fixes; these are the four this page leans on. In three, a deny or ask rule an operator would call configured and enforced silently did not apply: a rule set on a symlinked path (/etc, /tmp, /var on macOS, /bin on Linux) didn't catch a command that typed the real path instead; 2.1.268 also fixed "a Read or Edit deny rule did not apply when an env -C, eval or similar command the permission checker cannot analyze was on the same line"; and, in 2.1.269's words, the fix covered "Edit() deny rules and the write-path check not applying to the file a Bash tee command writes". The fourth was the mirror image — a ! negation rule written in one settings source was taking effect in others, wider than configured. 2.1.270 then repaired a regression 2.1.269 had introduced: read-only git commands in Bash "unexpectedly asking for permission after a session had been running for a while".

None of those four bugs is about a lockfile — Claude Code's permission rules aren't managed through ant apply. The generalization holds anyway: "the rule is in the settings file, therefore it applies" and "the resource is in the lockfile, therefore the file drives it" are the same unverified assumption in two different products. A rule or a hook that was configured and silently did something else is not a hypothetical — it shipped fixes in a real changelog a week after the lockfile feature shipped.

The workflow

1. Declare the resource as a file, and commit the lockfile on first apply

Put each agent, environment, memory store or deployment file in the directory ant apply reads its kind from (agents/, environments/, memory_stores/, deployments/); skills are the odd one out, each one being, in the doc's words, "a directory with a SKILL.md at its root, conventionally under skills/, uploaded as one bundle." Commit claude-lock.json in the same change as the files it maps. Pick the repository root as the working directory for your first ant apply ., because that is where the lockfile lands: the doc says it is written "in the directory you run it from".

2. Before the first apply in any new environment, read the plan, don't just approve it

The vendor doc is blunt about adoption: "ant apply can't adopt a resource you created in the Console or with ant beta:agents create. Only what's in the lockfile is managed, and applying a file that describes an existing agent creates a second one." If a teammate hand-created the agent your file is meant to describe, and you apply without checking, you now have two. The exception is the Console's Export as code: per the doc, "the download includes its own claude-lock.json, so applying it updates the resources you built there" — an update, not a clone. Confirm you're running from the lockfile that already knows about the resource, not a fresh one.

3. Serialize CI, and gate on plan text, not exit code

One ant apply job at a time per lockfile (a concurrency group is enough). On pull requests, run ant apply --dry-run . and fail the build on a non-zero exit or on This plan cannot be applied: or refusing to apply in its output — the exit code alone will not do it (§3 above). The real apply happens only after merge, on the default branch, and it has to name the directory: ant apply --yes .. Leave off the . and, per the doc, the run "reconciles only files the lockfile already tracks and skips a newly added one" — every resource the PR introduced. That same job then commits claude-lock.json back, failed apply or not — per the doc, "a partial apply still records what it created." The dry-run on the PR never wrote the lockfile, so in this workflow that commit is how a new resource's ID reaches the repo; skip it and the next run cannot find what this one made, and makes it again.

4. For every declared control, write the fixture that proves it fires — separately from the lockfile that proves the file matches

A permission rule, a hook gate, a scheduled deployment's cron trigger: each has an activation condition the lockfile never inspects. For each one, write the smallest test that exercises the real condition and asserts the real side effect — the injected context, the blocked call, the run that actually started at 3am — the same fixture-first discipline validating the validator prescribes for any check whose green you plan to trust. A control with no fixture is unverified, whatever its lockfile status says.

5. Treat a pinned model id as a config pin, not a behaviour pin

Pinning between your own files is already solved: when one file points at another agent or skill, ant apply ties that link to whatever it applied in the same run, and a GitHub-hosted skill doesn't drift to a newer commit on its own — moving it is an explicit --upgrade. What no lockfile pins is behaviour. A model id sitting correctly in a committed file is a config pin, not a behaviour pin — the same distinction this platform's model pin and migration pattern makes for any pinned model id: the lockfile confirms the string in the file matches the string on the resource, never that the resource still behaves the way it did when you pinned it.

Failure modes

  • Reading lockfile cleanliness as control health. A clean claude-lock.json says the file matches the resource. It says nothing about whether the resource's own trigger condition — a path matcher, a gate script, a schedule — is correct. Confirm the two hashes and the fixture separately; neither substitutes for the other.
  • Gating CI on --dry-run's exit code. It exits 0 on a blocked plan by design, documented as such. Grep the plan text for the blocking phrases instead.
  • Applying into an environment without checking for an existing resource first. For an agent made in the Console or with the CLI and absent from the lockfile, the doc says an apply produces a second agent. It states that for agents; we would expect the same of the other kinds, but that part is our inference.
  • Treating a file rename as a move. A rename orphans the resource you already had, and nothing cleans it up short of a --prune run.
  • Concurrent applies against one lockfile. Nothing locks it. Serialize at the CI layer, not the tool layer.
  • A silently mis-gated control reads exactly like a control that correctly chose not to fire. No error, no warning, no gap in output that reads as a gap. The reliable way to tell the two apart is a fixture with a known-bad input that must trip the control.

Verification

You know the practice is holding when three things are each independently true, not when one substitutes for the others:

# 1. a reviewer reads the plan: nothing created or updated that the PR didn't intend
#    (its exit code is 0 whether or not the plan is clean — see §3)
ant apply --dry-run .

# 2. CI (bash): fail if the CLI itself errored, or if the plan text says it is blocked
status=0; out=$(ant apply --dry-run . 2>&1) || status=$?
printf '%s\n' "$out"
[ "$status" -eq 0 ] || exit "$status"
if grep -qE "This plan cannot be applied:|refusing to apply" <<<"$out"; then exit 1; fi

# 3. each declared control has its own fixture that exercises the real trigger condition
#    and asserts the real side effect — this is the check a lockfile cannot perform

The third line has no single command, on purpose: it is as many small fixtures as you have controls, and it is the one a lockfile-only setup skips. That's the gap the graphify hooks fell into here — a clean config, a valid file, and nearly two months of silence until a readiness audit, not a test, caught it. The repoint was then checked by hand once; no fixture guards those hooks today.

When not to use this workflow

Skip declarative apply for a single agent, edited by one person, in the Console. The overhead — files, a lockfile, a plan review step — buys reviewability and reproducibility across a team and across environments, and neither matters when there is no second editor and no second environment to reconcile against. The moment a second person, a second environment (staging vs production workspace), or CI needs to reproduce the same resource, the files and the lockfile start paying for themselves; before that point they are process for its own sake.

Changelog

  • 2026-09-14 — Initial version. ant apply claims verified live against the vendor's CLI reference page; the graphify hooks incident verified against this repo's own .claude/settings.json and docs/tracking/DECISIONS.md (2026-08-11); Claude Code 2.1.268-2.1.270 permission-rule fixes verified live against the vendor changelog.
Sources & provenance
  • ant apply mechanics, flags and CI guidance (the --dry-run exit-0-on-blocked behaviour, "nothing locks the lockfile", the two-hash fingerprint, the --yes . post-merge apply and the lockfile commit, and the existing-agent duplication on an unmanaged apply): Anthropic, "Manage resources as code with ant apply", read 2026-09-14. Shipped in ant CLI 1.30.0, 2026-09-03 (release notes).
  • The two hooks that never fired once, the wrong gate path, and the current fixed state: this repo's own skills vs subagents vs hooks guide and docs/tracking/DECISIONS.md, entry dated 2026-08-11 (Q-471).
  • Claude Code 2.1.268-2.1.270 permission-rule fixes (symlink path matching, the eval/env -C unparseable-neighbor case, the tee write-path detour, and the over-wide ! negation rule) and the 2.1.270 regression fix: Anthropic, Claude Code changelog, 2.1.268 (Sep 10) through 2.1.270 (Sep 12, 2026).
  • Third-party write-ups of ant apply exist (a search on 2026-09-14 found at least five, Goldie Agency's among them); every mechanic on this page is cited to the vendor doc directly, not to them.

The CLI flags and lockfile shape are current as of the CLI version cited above and may change in a later release. Re-read the source before relying on any flag or exit code. Corrections: hello@aiarch.dev.

Learn to build the systems that check the systems.

aiArch teaches evaluation, gating, and production agent architecture by building — on a platform that publishes its own config drift, caught and fixed.

Free sample — no signup · every claim cited · full curriculum with membership