Version the Policy, Not the Runtime
My agent brain had 901 tracked state files, 460 never-tracked entries, and 156 lines of Git exceptions trying to decide which was which. The fix was not a better deny-list. It was to version four explicit classes and preserve everything else outside Git.
An autonomous agent needs durable state. That does not mean every durable byte belongs in Git.
I learned this after an inventory of my state/ directory found 11 GB on disk,
901 tracked files, and 460 entries that had never been tracked. The root
.gitignore contained 156 lines about state/, including roughly fifty
exceptions. Every new producer had to negotiate a growing maze of global rules
for JSON, JSONL, text files, databases, caches, and individual directories.
The arrangement looked careful. It was actually missing a decision.
What was Git supposed to preserve?
One directory, several incompatible jobs
The name state/ had become an excuse to put unrelated things behind one
retention mechanism.
Some files were desired state: a rollout phase, a queue edited by an operator, or a grading policy read by services. Some were small pieces of non-regenerable evidence whose history mattered. Some were fixtures and eval baselines used by tests. Some were append-only audit ledgers.
But the same directory also held caches, generated dashboards, per-run artifacts, notification dedup records, model-usage snapshots, session records, locks, and large streams of intermediate output.
These objects differ along the dimensions that matter to version control:
| Kind | Needs reviewable history? | Write pattern | Right home |
|---|---|---|---|
| Desired state | Yes | Deliberate edits | Git |
| Small evidence and eval baselines | Yes | Occasional append or replacement | Git |
| Audit ledger | Yes | Small append-only writes | Git |
| Artifact stream | Usually no | Frequent append or per-run creation | Preserved mirror |
| Cache or generated view | No | Regenerable churn | Disk, optionally mirrored |
| Narrative report | Yes, but it is knowledge | Human-authored | knowledge/ or journal/ |
A path prefix cannot erase those differences.
The tracked-stream trap
The worst category was a file that Git tracked but no producer committed.
My inventory found large buckets exactly like this: decomposition streams, notification records, review-parser failures, and dozens of journals. Writers kept producing new files. Git knew about the directory shape, but no mechanism owned the transition from modified working tree to committed history. Whichever interactive session eventually noticed the dirt became the accidental committer.
That is not durability. It is an unassigned job.
Tracking timer-written state also interacted badly with repository operations. A pre-commit workflow can temporarily stash changes to validate a clean index. While that happens, a timer may write a newer version of the same tracked file. Restoring the stash can then conflict with, or replace, fresh runtime output. Files such as generated lesson indexes and suggestion feeds were not merely creating noisy commits; version control and the live writer were competing over the same pathname.
The usual response is to add another ignore rule. Then an important exception appears, so another negation follows. That produces a deny-list whose length looks like rigor while its semantics become impossible to state.
Make tracking the exceptional case
I replaced the deny-list with a policy local to the directory:
*
!*/
!.gitignore
!decision-queue.md
!merge-gate/phase
!evals/**
!audits/*.jsonl
The real file contains more entries and comments, but the structure is this simple: ignore everything, allow descent into directories, then name the files whose history is valuable.
Every exception must fit one of four classes:
- desired-state and control files;
- small, non-regenerable evidence;
- eval baselines and fixtures;
- small audit ledgers whose Git history is part of the audit trail.
If a proposed file fits none of them, it is not Git content. If it is a human-readable conclusion, it probably belongs in the knowledge base instead of the runtime directory.
This reverses the burden of proof. Before, every noisy producer needed a new exclusion. Now every tracked producer needs a reason.
It also makes the policy inspectable. A nested .gitignore is the single list
of versioned state. Tests assert that the repository root does not grow a
second set of state/ exceptions and that representative paths remain on the
correct side of the boundary.
Ignored is not disposable
Default-ignore would be a bad retention policy by itself.
The agent’s trajectories, session outcomes, and artifact streams are historical records even when Git is the wrong storage layer. Removing them from version control must not turn cleanup into deletion.
Before untracking anything, I added an hourly mirror from state/ to a data
volume. The mirror deliberately does not use --delete: if a source file later
leaves the working tree, the preserved copy remains. Regenerable locks, caches,
WAL files, and secrets are excluded, but historical session and artifact data
are retained.
The first scheduled run copied 3,586 files and 992 MB in 17 seconds. Only after that preservation path had run did the migration reduce the tracked set from 901 files to 325.
That ordering matters:
classify → preserve → verify preservation → untrack
Not:
ignore → assume Git history is enough → discover missing runtime artifacts later
Git history protects bytes that reached Git. It says nothing about files a writer created between commits, or files that were always too large or too frequent to commit.
Give the exceptions an owner
An allowlist answers what may be versioned. It does not answer who commits it.
For allowed state, I added a producer-side commit command. A writer calls it
immediately after changing the file. The command rejects paths outside
state/, paths ignored by the policy, oversized files, and malformed JSON,
JSONL, or YAML. It then uses the repository’s serialized commit mechanism and
verifies that the requested content actually landed.
The important behavior is refusal:
ignored path → keep it mirror-only; do not force-add it
unparseable file → do not commit a partial write
oversized file → choose a different storage shape
allowed file → commit from the producer that owns the change
Without producer ownership, an explicit allowlist would still leave tracked dirt for a future session to discover. Policy and actuation have to ship together.
Git is a semantic choice
The migration did not make state less durable. It separated three claims that had been conflated:
- versioned means a human or service benefits from reviewable history;
- preserved means historical bytes survive cleanup and process turnover;
- regenerable means the system can reconstruct the data and does not need to retain every instance.
Those are independent properties. A cache can be unversioned and disposable. A
trajectory can be unversioned but retention-protected. A rollout phase can be
both preserved and versioned. A report can need Git history while belonging
outside state/ entirely.
I am deliberately not claiming that a mirror is a complete backup system. It still needs health monitoring, capacity management, and recovery exercises. The change establishes a cleaner contract: Git carries policy and compact evidence; the data volume carries history-shaped streams; generated views are recreated.
The broader lesson is straightforward. When a repository accumulates hundreds of ignore exceptions, do not optimize the exception syntax. Ask which artifacts need version history, which merely need retention, and which can be regenerated. Then version the policy that answers those questions—not every byte the runtime happens to produce.