Architecture Drift with Agents: A Playbook
Someone says the checkout service is getting harder to change. Everyone nods. The dependency graph feels tighter, small features keep reaching across the same boundary, and the file nobody wanted to touch last year is now in every pull request. Then somebody asks the question that stalls the room: harder than when?
Git can show what the code said six months ago. Tests can show whether it still behaves. A static analyzer can show the architecture today. None of those, by itself, tells you whether the structure has been improving, holding, or quietly coming apart. A snapshot tells you what the architecture is. A ledger tells you where it is going.
That difference matters because agents compress the timeline. The convenience import a human might add once a sprint can now appear twenty times in an afternoon. The code still compiles. The tests stay green. By the time the team can feel the damage, the commits that caused it are buried under a season of otherwise useful work.
Architecture drift needs a practice, not another dashboard. If you want the whole thing in one sentence: establish the record, read the direction, and gate the regressions that violate architectural intent. act drift is act101's implementation of that practice. It records a repository's structural signature in an append-only ledger, reconstructs the years before installation from git history, renders the direction of travel, and gives the record teeth at the merge boundary.
Why architecture drift with agents needs its own discipline
Architecture rarely collapses in one theatrical commit. It moves by inches, and almost every inch looks reasonable in isolation.
- A cycle begins as a convenient import. One module needs one type from its neighbor. The reciprocal edge is easy to miss in a busy diff and expensive to unwind after ten more changes depend on it.
- A layer boundary erodes one exception at a time. The controller talks directly to storage “just for this endpoint.” The next agent sees precedent, not intent, and repeats it.
- Cohesion falls while behavior stays correct. A module acquires billing, retry, and formatting responsibilities without breaking a test. The suite is green because the problem is shape, not output.
- Agents do not carry architectural memory between runs. A fresh session sees the files in context. It does not remember the meeting where the team decided that
domain/must never importinfra/. - Repository growth makes raw counts lie. Ten cycles in a hundred files and ten cycles in ten thousand files are not the same condition. Without a denominator, a growing codebase can look worse simply because it grew.
- A point-in-time score arrives without a story. “Coupling is 0.42” may be precise, but it cannot tell you whether 0.42 is a recovery, a decline, or the repository's steady state.
The old answer was a periodic architecture review. A senior engineer spent a week rebuilding the map, wrote a document, and hoped the conclusions survived until the next review. That cadence was already fragile when humans wrote every line. It cannot keep up with code generation that runs all day and starts each session with a clean memory.
The new discipline is commit-shaped. The structure gets measured often enough to reveal direction, the measurement carries its own scope and denominator, and the handful of invariants the team truly cares about can stop a merge. The ledger is the memory the next agent run does not have.
The architecture-ledger loop, end to end
agent writes ─► snapshot / backfill ─► architecture ledger ─► trends ─► check ─► merge
▲ │
└──────────────── remediate the named regression ────────────────┘
There are four commands in the diagram, but only three operating stages.
- Establish the record.
snapshotadds the working tree as it exists now.backfillsamples the first-parent git history and reconstructs the record the team was not keeping. - Read the direction.
trendsturns the ledger into per-metric movement and an overall Improving, Stable, or Degrading verdict, with the deciding measurements named. - Hold the line.
checkcompares the current tree with a prior record or merge-base, returns baseline → current evidence, and fails when a selected invariant regresses.
The back-edge is the point. A red check does not say “architecture bad” and leave a human with a chart. It names the metric that moved so the engineer or agent can investigate the corresponding commits, repair the boundary, and run the same check again. The record survives the session; the correction re-enters the loop.
This sits beside continuous quality rather than replacing it. A health score answers whether a commit made the codebase broadly healthier or sicker across security and architecture. The architecture ledger keeps the raw structural record: cycles, layers, cohesion, coupling, depth, chokepoints, conformance, and public-surface change over time. One is a broad quality gate. The other is an architectural instrument panel with memory.
Stage 1 · establish the record
The first stage is deliberately boring: make a file, put evidence in it, keep it. By default the ledger lives at .act/arch-history.jsonl, one versioned JSON record per line. It belongs to the repository, next to the architecture it describes.
Two commands build it from opposite directions.
snapshot moves forward. It analyzes the working tree, records the full HEAD commit when one exists, marks whether the tree is dirty, captures the include/exclude scope, and appends the structural signature. It appends unconditionally because two dirty-tree measurements during a long refactor can both be legitimate evidence. A snapshot is not a replacement for a commit; it is a record of the structure at a meaningful point in the work.
backfill reaches backward. It walks the repository's first-parent history, samples commits across the requested range, materializes each tree in a temporary directory with git archive, and runs the same structural analysis there. It never checks out an old commit, never stashes the working tree, and never asks the engineer to stop working while history is reconstructed. Records keep the original commit identity and committer date.
Historical trees are not always polite. If one sampled commit cannot be parsed or analyzed, backfill names the commit and reason, skips that point, and continues the walk. The final summary reports how many records were written and how many were skipped. One broken afternoon in 2021 should leave a visible gap, not erase the rest of the repository's history.
Start with the default shape:
act drift backfill --samples 30 --include crates
act drift snapshot --include crates
Thirty points is usually enough to reveal the broad curve without turning adoption into a source-code archaeology project. If the curve bends sharply in March, rerun a narrower rev range, filter with --since DATE, or choose --every N to add resolution around March. Backfill is idempotent by commit: records already present are removed from the candidate set before sampling, so a second pass can densify the ledger without duplicating the first.
Start with the shape, then spend resolution where the shape gets interesting. Every historical point requires a whole structural analysis. A dense record of a flat year teaches less than five extra points around the week a cycle appeared.
Scope is part of the measurement, not a convenience flag. If the first run includes crates and excludes vendor, every snapshot and check intended for that series needs the same scope:
act drift backfill --samples 30 --include crates --exclude vendor
act drift snapshot --include crates --exclude vendor
The ledger records that scope on every line. Trends will warn when it encounters incompatible records; check refuses the comparison. That refusal is healthy. Comparing crates/ last month with the whole monorepo today would produce a very exact lie.
Conformance needs one more decision. By default, a historical commit is judged against the [architecture] contract that existed in that commit's own .act/config.toml. If there was no contract then, conformance is absent rather than invented. --contract-from-head asks a different forensic question: when did the historical code first violate the architecture we intend today?
act drift backfill --samples 30 --contract-from-head --include crates
That switch is powerful in a design review and easy to misread. It applies present intent retroactively; it does not prove the team held that intent at the time. Keep the default ledger for point-in-time truth. Use the HEAD contract when the question is explicitly about today's boundary.
Finally, decide how the file persists. Commit .act/arch-history.jsonl when repository history is the desired source of truth, or publish it through a durable artifact workflow with clear ownership. A ledger that lives only on one laptop is a demo. A ledger an ephemeral runner deletes at the end of the job is no ledger at all.
Stage 2 · read the direction
A ledger becomes useful at two records and honest at many. act drift trends sorts the records by timestamp, compares the first and last endpoints for structural metrics, and keeps interval API breaks visible across the whole window. It renders both the individual movements and the overall verdict. If the ledger spans incompatible scopes, it warns instead of pretending the comparison is clean.
act drift trends
act drift trends --format markdown
Summary is for the terminal. Markdown is for a pull-request comment, an architecture review, or the weekly engineering note. JSON is the automation surface when another system needs the complete report.
The signature has eight measurement groups. The names matter less than the questions they let the team ask:
| Measurement | The operator's question |
|---|---|
| Dependency cycles | Did the import graph acquire a loop that makes change order ambiguous? |
| Layer violations | Are calls crossing the boundaries the team says should hold? |
| Cohesion | Are modules still about one thing, or accumulating unrelated responsibilities? |
| Coupling distance | Are units moving away from a balanced dependency shape? |
| Dependency depth | Are the chains required to understand or initialize the system getting longer? |
| Chokepoints | Is more of the repository routing through a shrinking set of load-bearing files? |
| Conformance | Does the code still match the explicit contract in .act/config.toml? |
| API diff | Did the public surface break, possibly break, or grow since the prior committed record? |
Counts travel with files_analyzed. When the repository grows, the report carries the absolute delta and the per-file movement; count-based verdicts use the rate when a denominator exists. Mean-valued measurements such as cohesion and coupling distance use their own bands. This is what keeps “we added 400 files” from being diagnosed as architectural decay by itself.
The report also preserves uncertainty. A truncated chokepoint analysis is marked unreliable. A missing architecture contract produces no conformance value. Records with different scopes produce a warning instead of a quiet comparison. The goal is not to fill every cell. It is to make every cell mean what it says.
Per-metric directions are Improving, Stable, or Degrading. The overall verdict is intentionally conservative:
- A hard structural metric—cycles, layer violations, or conformance—degrading beyond its band makes the overall verdict Degrading.
- If at least one judged metric improves beyond its band and none degrade, the verdict is Improving.
- Everything else is Stable, including mixed soft movement that deserves attention but not a categorical architectural alarm.
- With fewer than two records, the answer is No Trend Yet. One point is a baseline, not a trend.
API breakage is visible as its own degrading row but does not force the overall verdict. That is a policy choice, not a loophole. Removing a public symbol may be an error, or it may be the entire purpose of a major release. The ledger records the fact; the owner decides whether compatibility is an invariant worth gating.
Do not stop at the headline. A Stable verdict can contain a soft metric moving the wrong way, just as an Improving verdict can be driven by one strong recovery. Read the rows, then use git to investigate the inflection point. The ledger tells you when to look and what changed shape. It does not pretend an aggregate curve can prove why.
Stage 3 · hold the line
Charts create awareness. Gates change behavior.
act drift check computes the current working-tree signature and compares it with a baseline. With no arguments, the baseline is prev: the newest record in the ledger by timestamp. In a pull request, point it at the target branch. The command resolves the merge-base with HEAD and uses the ledger record for that commit; if the sparse ledger has no such record, it computes the baseline from that committed tree without appending it.
act drift check --against main --append
The default fail set is small on purpose:
cycles— fail when the cycle rate increases beyond the comparison band;layers— fail when the layer-violation rate increases beyond the band;conformance— fail on any increase, because the contract is explicit intent.
These are structural invariants: graph tangling, boundary erosion, and a declared contract no longer holding. Cohesion, coupling, chokepoints, dependency depth, and API breakage are still computed and reported, but they become blocking only when the team selects them.
--fail-on replaces the default set; it does not add to it. A team that wants the defaults plus API compatibility should name all four:
act drift check --against main \
--fail-on cycles,layers,conformance,api-breaking \
--append
That replacement behavior is worth writing into the CI file where reviewers can see it. --fail-on api-breaking alone would stop API breaks and allow new cycles. The command would be doing exactly what it was told, and almost certainly not what the team meant.
The exit contract is CI-shaped:
- 0 — the selected policy passed;
- 1 — a selected structural metric regressed;
- 2 — the check could not produce a trustworthy comparison: missing
prevbaseline, scope mismatch, analysis failure, invalid policy, tier denial, or another execution error.
Exit 2 must never be painted green. “We could not judge this change” and “this change preserved the architecture” are different statements.
A minimal pull-request step is small:
- name: Gate architecture drift
run: act drift check --against origin/main --append
--append writes the current signature as a check record on both pass and regression, but never after an execution error. It writes a file; it does not publish that file. If the runner is ephemeral, persist the updated ledger through the team's chosen commit or artifact workflow. Otherwise every job begins from the same old baseline and its new evidence vanishes at teardown.
Agents can run the same stage before CI. The MCP surface exposes drift_snapshot, drift_trends, and drift_check, so an agent finishing a module extraction can record the state, inspect whether the chokepoint or coupling row moved, and self-gate before claiming the refactor is done. drift_backfill stays CLI-only because a minutes-long history walk with progress output is batch work, not a sensible interactive tool call.
The agent does not get to certify the architecture from its own diff. It runs the same independent measurement CI will run and brings back the baseline → current evidence. “I cleaned this up” is a claim. “Cycles 2 → 0, scope unchanged, check passed” is a receipt.
The merge gate
An architecture check is one part of a merge decision, not a substitute for tests, review, or security gates. The minimum that makes this part trustworthy:
- The scope is identical. Include and exclude lists match the baseline record. A scope mismatch is an execution error, never a pass.
- The baseline is meaningful. Pull requests compare with the target branch's merge-base; local incremental work may compare with
prev. The choice is visible in CI. - The selected policy is explicit. Cycles, layers, and conformance are the default. Any soft metric promoted to blocking is named in version-controlled configuration or workflow code.
- Every selected metric is judged. If conformance is policy, the architecture contract exists at both endpoints. An absent value is honest evidence that the check could not judge that dimension, not proof that it passed.
- Every failure carries baseline → current evidence. A red build says which metric moved and by how much. JSON output is available for annotations that need the full delta table.
- Operational errors stay red. Missing history, failed analysis, and incompatible scope do not disappear behind
continue-on-error. - The record survives the job. If
--appendis policy, the resulting ledger reaches durable storage.
A pull request green on these conditions has preserved the selected architectural invariants. It may still be a bad feature, a security regression, or a behavior break. Keep the claim narrow. Narrow claims are the ones teams learn to trust.
Best practices, in plain English
- Backfill before you gate. A gate installed against one fresh snapshot has no context and no confidence. Reconstruct a sparse history, inspect it, then decide what deserves authority.
- Start sparse. Use the default 30 samples across the mainline. Add density only around a release, migration, reorganization, or visible bend in the curve.
- Put scope in one shared command. CI, scheduled snapshots, and local runbooks should copy the same include/exclude policy. If scope has to change, start a clearly named new series or document the boundary.
- Commit the ledger or own the artifact. “The CI system has it somewhere” is not an operating model. Name the durable location and who can update it.
- Gate intent, not every available number. A short list of load-bearing invariants earns trust. A gate that blocks normal, deliberate change becomes a gate people route around.
- Use conformance for rules the team can state. If
domainmust not importinfra, write the contract. The conformance row cannot enforce intent that exists only in someone's head. - Treat API compatibility as a release policy. Turn on
api-breakingwhere consumers require it. Leave it observational where breaking change is deliberate and versioned. - Let the agent check its own work before the push. CI should be the backstop, not the first place the author learns a new cycle appeared.
- Investigate with git, not storytelling. When a row bends, narrow the history and inspect the commits. The chart points to the scene; it is not the postmortem.
- Snapshot meaningful boundaries. After a structural refactor, a release, or a contract change is useful. Five identical snapshots because somebody reran a script are technically valid and operationally noisy.
- Read the rows, not only the verdict. Hard metrics control the overall Degrading label. Soft movement can still be the earliest warning in the report.
Failure modes & gotchas
These are the ways an architecture ledger turns into wallpaper—or worse, a confident false alarm.
- The scope shuffle. Last month's record covered
src/; today's covers the whole monorepo. The cycle count jumps and a meeting begins. Fix: keep scope stable; let check's hard mismatch protect you and treat trend warnings as a stop sign. - The raw-count panic. The repository doubles, cycle count rises from one to two, and somebody declares a 100% collapse. Fix: read the file-normalized rate and the absolute count together.
- The archaeology project. The first backfill uses
--every 1across eight years, takes all afternoon, and adoption dies in the terminal. Fix: 30 points first, density around an actual question. - The causal fairy tale. Cohesion falls in the same month a framework migration begins, so the framework gets blamed. Fix: the ledger identifies timing and direction; git evidence establishes cause.
- The replacement-list surprise. CI sets
--fail-on api-breakingand silently stops gating the defaults. Fix: remember that--fail-onreplaces; spell out the complete policy. - The disappearing append. The job runs with
--append, prints a good report, and deletes the new ledger with the runner. Fix: persist the file deliberately. - The one-point trend. A dashboard renders one snapshot with a dramatic arrow added elsewhere. Fix: under two records the honest verdict is No Trend Yet.
- Present intent mistaken for past intent.
--contract-from-headfinds a violation in 2022 and the team concludes a 2022 engineer broke a rule written in 2025. Fix: label retroactive conformance as forensics, not history. - Conformance without a contract. The default fail set includes conformance, but no
[architecture]contract exists, so there is nothing to judge. Fix: write the contract for rules that matter; absent evidence stays absent. - The green-headline trap. Overall Stable hides coupling or cohesion drifting beyond its row's band because no hard invariant degraded. Fix: reserve the headline for triage, then read every judged metric.
- The permanent red gate. A team enables every fail token before it understands normal variance, gets blocked by deliberate changes, and adds
continue-on-error. Fix: observe first, promote one policy at a time, and keep the reason beside the token.
The common thread is trust. A ledger earns authority only when its scope is stable, its comparisons are reproducible, and its red states distinguish regression from inability to judge. Once engineers learn that a check can lie, they stop reading the evidence even after the lie is fixed.
Cost, sampling density, and the agent budget
Backfill is the expensive stage because each sampled commit needs a repository tree and a structural graph. That cost is front-loaded and controllable. Thirty samples answer “what is the broad direction?” A narrowed rev range or --every 5 can answer “where inside this quarter did the curve turn?” --every 1 is available for the rare case where every mainline commit matters and the team has chosen the cost knowingly.
Use a funnel:
- Backfill the whole history sparsely.
- Run trends and identify an inflection.
- Backfill the relevant release range more densely.
- Inspect the commits around the first degrading record.
- Return to normal snapshots and checks going forward.
This keeps compute attached to a question. “Collect everything because we may want it later” is how observability bills grow and repositories fill with artifacts nobody can interpret.
The online path is smaller. A snapshot analyzes one working tree. A check analyzes the current tree and may materialize a missing committed baseline when the chosen merge-base is not already in the ledger. Scheduled work can keep common baselines warm; sparse ledgers remain valid because ref checks can compute the missing point on demand.
For the agent, the valuable output is not the whole codebase reread into context. It is a compact report: the verdict, the deciding metrics, and the baseline → current deltas. Backfill remains outside the interactive MCP path; the agent can request the already-computed trend without spending a tool call waiting for thirty repository analyses.
How act101 implements the loop
The practice is simple enough to describe without a product. The hard part is producing one comparable structural signature across real repositories, old commits, working trees, CI, and agent sessions.
act101 builds the signature from analyzers it already uses for architectural work:
- graph analysis provides cycles, coupling distance, dependency depth, cohesion, and chokepoints;
- layer analysis records violations, inversions, skips, and classification reliability;
- conformance compares the tree with the explicit
[architecture]contract when one exists; - API diff records public-surface movement against the previous committed point;
- the repository file count supplies the denominator for count-based comparisons.
act-drift coordinates those analyses, historical tree materialization, trend logic, and check policy. act-ledger owns the generic JSONL read/append mechanics and shared verdict primitives. The separation matters less to an operator than the consequence: the architecture ledger is not a second, watered-down analyzer. It is the existing structural instruments sampled consistently over time.
The command surface maps directly to the practice:
act drift snapshot— compute and append the working-tree record;act drift backfill— reconstruct sampled first-parent history from the CLI;act drift trends— render summary, Markdown, or JSON trends;act drift check— compare, report, and return 0/1/2 gate semantics.
The agent surface exposes drift_snapshot, drift_trends, and drift_check through MCP, all in Architecture Edition. The whole feature is Architecture because it is the Architecture promise made measurable: not just seeing structure, but proving whether a change improved it and whether the improvement held. The free act scan / act trends history remains a separate on-ramp for the broader health-ledger habit.
The ledger is evidence, not telemetry theater. Same tree, scope, and analyzer semantics in; same signature out. When those inputs are not comparable, the tool warns or refuses instead of smoothing the mismatch into a prettier line.
The maturity ladder
Tick what the team does today, not what the rollout document says it hopes to do.
- [ ] The repository has a committed or otherwise durable
.act/arch-history.jsonl - [ ] Include/exclude scope is documented and shared across every drift command
- [ ] A sparse first-parent backfill establishes history before adoption
- [ ] The team can explain what each of the eight metric groups means operationally
- [ ] Trend review happens on a named cadence, not only after a bad quarter
- [ ]
[architecture]contracts encode the boundaries important enough to enforce - [ ] Agents run
drift_checkbefore declaring structural work complete - [ ] Pull requests compare against the target branch's merge-base
- [ ] The blocking
--fail-onpolicy is explicit, minimal, and version-controlled - [ ] Appended CI records survive the runner and extend the durable ledger
Zero to three: architecture by memory. Four to six: the team can see direction but still reacts manually. Seven to nine: the feedback loop is operating. Ten: the codebase carries its own architectural memory, and every author—human or agent—meets the same evidence at the door.
A reasonable 30 / 60 / 90-day plan
- Days 1–30 — establish the record. Pick a stable scope, run the default 30-sample backfill, commit or publish the ledger, and review the curve with the engineers who know the repository's history. Do not block merges yet. Your job is to learn which movements reflect real architectural events and which reflect ordinary variance.
- Days 31–60 — make the record routine. Add snapshots after meaningful structural changes or on a scheduled cadence. Review trends alongside architecture work. Write the
[architecture]contract for boundaries the team is already willing to defend. Have agents rundrift_trendsanddrift_checkduring refactors, still without CI authority. - Days 61–90 — give selected invariants teeth. Add
act drift check --against origin/mainto pull requests, begin with the default structural set, and persist appended records. Add softer metrics orapi-breakingonly when an owner can state why they are blocking. Track overrides as policy bugs, not as normal operation.
At ninety days the win is not a perfect trend line. It is a team that can answer “are we winning or losing?” with a record, point to the commit where the direction changed, and stop the next violation before it becomes another quarter of cleanup.
What architecture fitness functions got right (and what the ledger adds)
Neal Ford, Rebecca Parsons, and Patrick Kua gave the industry a useful frame in Building Evolutionary Architectures: if an architectural characteristic matters, turn it into an objective fitness function and run it continuously. Layering should not live only in a diagram. Coupling should not depend on an architect remembering to object. The system should be able to test the property as it evolves.
That idea supplied the enforcement axis. The missing axis was time.
A fitness function can tell you that the boundary holds today. A ledger can tell you when it first failed, whether the repair lasted, and whether the rest of the structure improved while the rule stayed green. Backfill matters because teams adopting the practice are not blank slates. They arrive with eight years of commits and a hunch that something changed two years ago. The record can start where the repository started, not where the dashboard was installed.
The shortest version is still the useful one: establish the record. Read the direction. Hold the line. Do that on the cadence agents write code, and architecture stops being a feeling the team rediscovers during rewrite season. It becomes evidence the repository carries with it.