Stop AI coding agents from deleting features that must survive

lines of HTML codes

An AI coding agent is not trying to break your app. It just cannot tell which parts of your code are load-bearing. An append-only trigger on a ledger table looks like clutter. A NULL in a foreign key column looks like a bug someone forgot to fix. A weird cap on one code path looks like dead code.

Naoto Yamabe, building a live app during RevenueCat Shipaton 2026, ran into this problem and documented the system he built to solve it. He calls the core concept preservation anchors: an explicit list of things that must not change, placed where the agent will actually read them. The system has three enforcement layers, and it failed once in a way that turned out to be the most useful thing that happened.

What a preservation anchor looks like

Yamabe keeps a file called CLAUDE.md that his agent reads automatically. Section 3 is 56 lines listing only what is untouchable. Each entry is one line of what, one clause of why, and a pointer to where the detail lives.

Here is a real example from his project:

Ledger entries for consumption and adjustment (unlock, token to point exchange, admin adjustment) must always carry a NULL catch id — because the fraud clawback reconstructs the awarded amount by summing deltas per catch. Break this and a clawback silently under-refunds.

The because clause is the entire point. An anchor without a reason is a rule the agent will route around the moment following it becomes inconvenient. An anchor with a reason is a constraint the agent can reason with. When it needs to add a new kind of ledger entry, it now knows which property to preserve.

Anchors also describe what to keep, not what to do. The instruction “don’t break the ledger” is useless. “This table is append-only; refunds are expressed by writing a new row, never by editing an old one” is actionable.

Level 1: Prose (necessary, insufficient)

Writing it down gets you a long way, and only that far. Prose in an instruction file is advisory. The agent reads it, mostly respects it, and then one day it’s deep in a refactor and the constraint is four thousand tokens behind it in the context window.

Prose is where anchors start. It is not where they should end.

a broken umbrella sitting on top of a sandy beach

Level 2: A test that fails with a useful message

The next level is a test whose failure message names the anchor. As of this writing, 26 of the project’s 86 test files mention a preservation anchor by name in their assertions, docstrings, or comments.

The failure message matters more than the assertion itself. Compare this:

AssertionError: assert 3 == 2

With the message one of Yamabe’s tests actually prints (translated from Japanese):

Tier gate decisions appear to be bypassing db.load_feature_access (the choke point). paid_boostable — the flag that marks a feature as NOT purchasable — has no effect on that path, so buying a subscription would unlock a feature that must never be for sale. If this is a feature gate, use FeatureAccess. If it is not a gate, add it to _ALLOWED_DIRECT_CALLERS with a reason.

An agent handed the first message will make the number 2. An agent handed the second one has enough context to make the right call.

There’s also one critical line in Yamabe’s instruction file that handles the most dangerous instinct a coding agent has:

If a change touches a preservation anchor, the tests will fail. When they fail, do not fix the test — first suspect that the change broke the anchor.

This exists because a coding agent facing a failing test will try to make it green, and it’s extremely good at doing exactly that. You have to explicitly remove that as an option.

Level 3: Make it structurally impossible

The strongest anchors are not enforced by tests at all, because tests are code and code can be edited.

Four tables in Yamabe’s database physically refuse mutation. Not by convention. By a Postgres trigger:

CREATE OR REPLACE FUNCTION forbid_ledger_mutation() RETURNS trigger AS $$
BEGIN
    RAISE EXCEPTION 'token_ledger is append-only';
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_ledger_no_update
    BEFORE UPDATE OR DELETE ON token_ledger
    FOR EACH ROW EXECUTE FUNCTION forbid_ledger_mutation();

The two points ledgers, the admin audit log, and the consent log all have this trigger. An agent that decides the cleanest way to handle a refund is UPDATE token_ledger SET delta = 0 doesn’t get a subtle bug. It gets an exception immediately, with the invariant in the error string.

A different application of the same idea: the app’s opening animation is design-locked. 26 files are pinned by SHA-256 in a test manifest. Changing any byte of any of them fails CI. You can change them, but you have to update the manifest in the same commit, which makes the change deliberate and visible in the diff rather than a side effect of an asset cleanup.

The generalizable pattern: push each anchor down to the lowest layer that can enforce it. Prose is weaker than a test. A test is weaker than a database constraint. Every step down removes a way for a well-meaning agent to be persuasive.

scrabble tiles spelling out the word data on a wooden surface

⚠️ The anchor that was never enforced

On 11 July, Yamabe built the groundwork for in-app subscriptions. Part of that work was a column, tier_features.paid_boostable, whose entire purpose was to encode a policy decision: some features must never be unlockable by paying. In this case, direct messages, for reasons that were half legal and half about product identity.

The column shipped. It showed up in the admin console with a “not for sale” badge. It was in the migration comments and in the instruction file.

Nothing ever read it. Not one decision path. Every tier gate in the codebase resolved a single scalar “effective tier” and compared it to a threshold. None of them asked whether the tier had been bought. The moment a real subscription wrote its first row, every gate at or below that tier would have opened, including the one feature the column existed to protect.

It sat like that from 11 July to 4 August. Twenty-four days. No test failed. The admin console displayed the badge without complaint. Yamabe found it while auditing the billing groundwork before turning payments on for a hackathon. The audit was supposed to be a formality.

The lesson: a policy stored as data is not enforced. Writing a rule into a database column and feeling like you’ve implemented it is a trap. A column is a note to yourself. Until something reads it and refuses, you have documentation wearing a schema.

Pro Tip: Test your detector against the real historical bug

The fix was to route every gate decision through one function and to add an AST-based check that walks every module looking for calls to the low-level tier functions outside an explicit allow-list.

The check passed. Yamabe had also separately found one real bypass in a tide-data module and fixed it by hand. Good day, it seemed.

Then he ran an adversarial review over his own change, and it came back with this finding: the detector only matched call nodes whose function was a name. The bypass that had actually existed was written as getattr(db, "get_user_tier", None), where the function name is a string literal and the call goes through a local variable. The detector never saw it. Confirmed by running the detector against the pre-fix file: it returned empty.

The guard had the same hole as the bug it was supposed to catch.

The detector now matches both shapes, the direct call and the getattr indirection. The comment above it records that it originally did not, and why. That correction is left in the source deliberately rather than cleaned up: the next person widening this check needs to know which failure mode it was blind to.

The takeaway is worth repeating: when you write a detector, run it against the broken code from git history. Not against a synthetic example you invent afterward. Against the actual historical failure. If it doesn’t go red, you’ve written a test that agrees with you rather than one that checks you.

The six-line summary

  • Keep an explicit list of what must not change, with a reason for each. The reason is what makes an anchor usable rather than merely obeyed.
  • Write failure messages for a reader who doesn’t have your context, because that reader is increasingly an AI agent.
  • Say “do not fix the test” out loud in your agent’s instruction file. Otherwise it will, and it will do it well.
  • Push each anchor to the lowest enforceable layer. A database trigger cannot be talked out of its position.
  • A policy stored only as data is not enforced. Something has to read it and say no.
  • Test your detectors against the real historical bug. A green detector proves nothing until it has been red for the right reason.
Stay on top of AI & Automation with BizStack Newsletter
BizStack  —  Entrepreneur’s Business Stack
Logo