Most migration guidance stops at the diagram

A migration plan usually has a good architecture diagram: here is the target, here are the phases, here is roughly when each component moves. What it rarely has is anything an AI coding agent can be checked against directly. The diagram lives in a slide deck. The agent lives in a terminal. Between them is a translation step most teams never do.

This is a practical, developer-facing pass at that translation: five guardrail shapes that cover most of what a migration actually needs enforced, written the way a decision needs to be written to be evaluable rather than just readable.

1. No new imports from the legacy tree

The most common boundary in any migration is the line between old code and new code. The rule is simple to state and easy to get wrong in practice, because "avoid the legacy code" is not specific enough for an agent to act on.

## Constraints

- FORBID_LITERAL:
    value: from legacy.
    include_paths:
      - "modern/**"
    exclude_paths:
      - "modern/adapters/**"

The exclusion matters as much as the rule. Almost every migration needs exactly one sanctioned crossing point, and naming it explicitly is what keeps the rule from either blocking legitimate adapter code or silently permitting everything.

2. New persistence goes through the target repository

Persistence is usually the boundary agents cross most often, because nearly every feature touches data. A rule scoped to the migrated tree, forbidding the literal call an agent would otherwise reach for, is usually the single highest-leverage guardrail in a migration.

## Constraints

- FORBID_LITERAL:
    value: db.query(
    include_paths:
      - "modern/**"
    exclude_paths:
      - "legacy/**"

This is the exact decision the migration demo traces end to end: denied in the migrated tree, silently skipped in the legacy tree where the old pattern is still correct.

3. A service is read-only during its migration window

Some components need a hard freeze while they move, particularly when a migration involves a cutover with a defined window rather than a gradual strangler boundary. Write access resuming a day early, because an agent had no way to know the freeze was in effect, is a common and avoidable failure.

## Constraints

- FORBID_LITERAL:
    value: def update_
    include_paths:
      - "services/billing/**"

This is a transitional decision in the sense from the pillar article: it has a start and an expected end, and it should be removed, not just ignored, once the freeze lifts. A freeze rule with no corresponding removal step is how a two-week cutover becomes permanently frozen code nobody remembers why.

4. Only the named adapter may call the legacy interface

Covered in depth in the strangler-fig migration article: an agent is allowed to touch legacy code without creating a new dependency back into it. Encoding this as a scoped exception, rather than a general convention, is what makes it enforceable rather than aspirational.

## Constraints

- FORBID_LITERAL:
    value: import legacy_billing
    include_paths:
      - "services/**"
    exclude_paths:
      - "services/adapter.py"

5. Deprecated APIs do not appear in newly generated code

A deprecated library or API often keeps working long after it stops being the intended path, which is exactly why agents keep reaching for it: it is present, it compiles, and nothing signals that it is going away. This rule targets the surface an agent actually sees, the call itself, rather than trying to remove every trace of the old dependency at once.

## Constraints

- FORBID_LITERAL:
    value: LegacyHttpClient(
    include_paths:
      - "**/*.py"
    exclude_paths:
      - "legacy/**"

Scoping by file extension rather than a specific tree works well here, because the goal is that no new code anywhere reaches for the deprecated client, regardless of which package it happens to sit in.

Compiling and checking these

Each of the five patterns above is a ## Constraints block inside an ordinary ADR: identity and status in the frontmatter, the reasoning in prose, the enforceable rule in the constraints section. mneme adr import compiles the block into a typed rule; mneme check --mode strict evaluates a proposed change against the whole corpus and reports pass, fail, or skip with the reason for each.

Terminal

$ mneme adr import docs/adr --memory .mneme/project_memory.json --apply
Wrote 5 decisions to .mneme/project_memory.json

$ mneme check \
    --memory .mneme/project_memory.json \
    --input proposed.diff \
    --target-path modern/orders/customer_lookup.py \
    --query "migrated module data access" --mode strict
FAIL  [ADR-042] FORBID_LITERAL "db.query(" -- trigger: db.query(
PATH SKIP   [ADR-041] legacy/** excluded by selector
Result: FAIL

Where to start

Five rules is a ceiling worth resisting the urge to front-load. Encoding all five before writing any migration code is usually slower than encoding the one or two that actually matter for the first component being moved, watching what an agent tries to do against them, and adding the next rule when a real bypass attempt shows what is actually needed.

In practice the persistence rule and the adapter-boundary rule cover the majority of incorrect pattern copying on their own. The other three are worth having in reserve, not worth blocking the first migrated component to write in advance.

Conclusion

None of these five patterns require new tooling concepts. They are ordinary typed rules with path selectors, the same mechanism used everywhere else in the corpus. What makes them migration guardrails specifically is that they are scoped to a tree that moves over time, and written with an explicit exception for the one crossing point a migration usually needs. Writing five short rules is a fraction of the cost of discovering, after the fact, how many agents quietly copied the pattern the migration existed to remove.