An AI coding agent will happily hand you a refactor that is smaller, cleaner, better named — and quietly missing a feature.It is not being careless. It genuinely cannot tell which parts of your code are load-bearing. The append-only trigger on a ledger table looks like clutter. The NULL in a foreign key column looks like a bug. The weird cap on one code path looks like dead code left over from something. So on this project I keep a list called preservation anchors: things that must not change, written down where the agent will actually read them. This post is about how that list works, the three levels of enforcement behind it, and the one time the whole system failed anyway — which turned out to be the most useful thing that happened. Last time I said the next post would be about building a paywall for an app whose most valuable feature is not for sale. This is it. The paywall turned out to be the boring part. The interesting part was discovering that the flag marking that feature "not for sale" had never been read by anything at all. 1. What an anchor looks like CLAUDE.md is the instruction file my agent reads automatically. Section 3 of it is 56 lines that do nothing but enumerate what is untouchable. Each entry is one line of what, one clause of why, and a pointer to where the detail lives: Ledger entries for consumption and adjustment (unlock, token→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. That "because" clause is the entire point. An anchor without a reason is a rule the agent will route around the moment the rule becomes inconvenient. An anchor with a reason is a constraint it can reason with — when it needs to add a new kind of ledger entry, it now knows which property to preserve. The other thing that matters: anchors say 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. 2. Level 1 — prose (necessary, insufficient) Writing it down gets you a long way, and I want to be honest that it gets you only that far. Prose in an instruction file is advisory. The agent reads it, mostly respects it, and then one day is deep in a refactor at 2am 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. 3. Level 2 — a test that fails The next level is a test whose failure message names the anchor. As I write this, 26 of this 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. Compare: AssertionError: assert 3 == 2 with the message one of these tests actually prints. (This project's instruction files and test messages are written in Japanese; this is my translation, and the original is in tests/test_paid_boostable.py.) 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. The second one tells the agent — and me at 2am — what invariant broke and what the two legitimate responses are. An agent handed the first message will make the number 2. An agent handed the second one has enough to make the right call. And then, the single most important line in the whole instruction file: 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 the instinct of a coding agent facing a red test is to make it green, and it is extremely good at making tests green. You have to explicitly remove that as an option. 4. 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 this database physically refuse to be mutated. 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. An agent that decides the cleanest way to handle a refund is UPDATE token_ledger SET delta = 0 does not get a subtle bug. It gets an exception, immediately, with the invariant in the error string. The same idea in a different shape: 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 — you update the manifest in the same commit, which forces the change to be deliberate and visible in the diff rather than a side effect of "cleaning up assets". The pattern generalises: push each anchor down to the lowest layer that can enforce it. Prose < test < database constraint. Every step down removes a way for a well-meaning agent to be persuasive. 5. The anchor that was not enforced Now the part where this failed. On 11 July I built the groundwork for in-app subscriptions. Part of that 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 our case direct messages, for reasons that are half legal and half about what kind of product this is. The column shipped. It was displayed in the admin console with a "not for sale" badge. It was in the migration comments. It was in the instruction file. And 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, and 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 until 4 August: twenty-four days. Nothing was broken, no test failed, the admin console cheerfully displayed the badge. I found it while auditing the billing groundwork — I was about to switch payments on for a hackathon and wanted to know what was actually finished. The audit was supposed to be a formality. The lesson is uncomfortable and I think it is the real one: an anchor that is only data is not an anchor. I had written the policy into a database column and felt like I had implemented it. A column is a note to yourself. Until something reads it and refuses, you have documentation wearing a schema. 6. The guard that had the same hole as the bug The fix was to route every gate decision through one function, and — because a rule you cannot enforce is a wish — to add a test that detects any new code path bypassing it. I wrote an AST-based check that walks every module looking for calls to the low-level tier functions outside an explicit allow-list. It passed. I had also, separately and by hand, found one real bypass in a tide-data module and fixed it. Good day. Then I ran an adversarial review over my own change, and it came back with this: The detector only matches call nodes whose function is a name. The bypass you found was written getattr(db, "get_user_tier", None) — the function name is a string literal and the call goes through a local variable. Your detector never saw it. Verified by running your own detector against the pre-fix file: it returns empty. My guard could not detect the one bypass that had actually existed. And the comment I had written above it claimed the opposite — that this check is what stops the regression recurring. The detector now matches both shapes, the direct call and the getattr indirection, and the comment above it records that it originally did not and why. That correction is deliberately left in the source rather than tidied away: the next person to widen this check needs to know which failure mode it was blind to. That is a better lesson than the bug. When you write a detector, run it against the broken code. Not against a synthetic example you invent afterwards — against the actual historical failure, from git history. If it does not go red, you have written a test that agrees with you rather than one that checks you. 7. What I would steal from this Keep an explicit list of what must not change, with a reason for each. The reason is what makes it usable rather than merely obeyed. Write failure messages for a reader who does not have your context — because increasingly, the reader is not you. 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 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. I am shipping this app during RevenueCat Shipaton 2026 and writing up what breaks as it breaks. Next: what I let the AI write for a 2,940-species encyclopedia, and everything it confidently got wrong. Building in public as @WorldFishingMap on X.
How I Stop AI Coding Agents From “Improving” Away Important Features
Full Article
Original Source
Read the full article at Hackernoon →KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.