Product10/12/2026·7 min read

Four Copies of a Promise, Thirty-One Days Stale

We removed a feature and forgot the App Review notes, which still told reviewers to press a button that no longer existed. The same copy lived in four independent files.

The button that no longer existed

On the eleventh of September we removed the free trial from Wavecraft. It was a decision we made deliberately, and for a reason that had nothing to do with engineering: the trial was not earning its place. We deleted the introductory-offer configuration, removed the question from the paywall's FAQ, and shipped a test that fails if that question ever comes back, because the paywall FAQ is static copy that renders identically for every user, so a static answer about a trial is a claim that cannot be true for someone who never had one.

Then we stopped.

The rest of our copy kept describing the trial. The App Review notes for both subscription products still told a reviewer to tap a button whose label reads "Start 7-Day Free Trial" for accounts eligible for the introductory offer. There was no such label any more, and there had not been for thirty-one days. It was not a vague promise that had drifted out of date. It was an instruction, with a quoted button label, to press something that had been deleted.

Our public FAQ had the same problem in the other direction. A Pro answer promised lossless export, all the mastering presets, unlimited AI separation, and, at the end, "and removes ads". There is no advertising in the application. There is no ad code anywhere in the product. The line had been true once, probably during an early design pass, and it had outlived the feature.

That second one deserves a pause, because it is the more embarrassing failure. Removing a trial is a business decision that leaves visible residue everywhere; a claim about a feature you never shipped leaves nothing behind to contradict it. Nobody was going to notice "and removes ads" by looking at the build.

What the store page is for

This happened in a specific context, and the context matters for judging how much of this is negligence.

We had just turned on paid acquisition for the product. Search ads, one market, a small daily budget. On a listing page that is the first thing anyone sees, and on a page a reviewer reads with a checklist, four separate places made claims about what the product does, and one of them was not merely out of date but impossible to follow.

It is easy to say this should have been caught. The harder question, and the one worth writing down, is why nothing caught it. A build that fails is found. A copy string that lies is found only by a person reading it, and the population of people who read App Review notes for a product they did not write is very small, and none of them had a reason to look on that particular day.

Four files, one fact

The audit that eventually found it was not clever. It went through every place the product makes a claim about itself and asked, of each claim, whether it was still true. That produced a list, and the list was longer than two items: the trial language, the ad promise, an effect count that did not match the enum, and a phrase about unlimited tracks that was not literally false but described the arrangement you end up with rather than the recording process you go through. You can stack one take at a time. The track count really is unlimited. "Unlimited tracks" describes the result.

That list was not the interesting part. The interesting part came next.

The claims lived in four kinds of file, and each kind had its own reason to exist:

  • the store listing metadata, in nineteen locales,
  • the App Review notes for each subscription product,
  • the App Review notes for each in-app purchase,
  • the public FAQ, in the app's own site.

None of these is a copy of another. Each is authored where it is consumed, because a review note for a subscription is a different document from a review note for a lifetime purchase, and because the same sentence cannot serve a reviewer and a prospective user at the same time. That reasoning is sound. It is also exactly the shape that lets a single fact drift four ways.

Because they are four documents, changing the fact meant four edits, and four edits meant someone had to remember four edits, and remembering is the only mechanism in the system. Remembering is not a mechanism. It is a habit, and habits fail silently, which is why the review notes drifted for a month before anyone read them.

A copy surface is a write obligation. An obligation with no owner does not decay slowly. It stays exactly as stale as the day you forgot.

This is not specific to store copy. It is the same failure as a README that describes a flag you renamed, a help article that describes a menu you moved, a marketing site that describes a pricing tier you retired. The mechanism is always the same: one fact, several places that state it, and a human as the only thing connecting them.

The gate knew, and nobody asked it

Here is the part that made the fix possible.

The product already had the answer, in code, in a single place. A five-case enum listing exactly what purchasing unlocks: vocal separation, auto-mix, the mastering chain, stem export, batch export. Everything gated in the product derives from it. The paywall's capability blocks derive from it. The PRO badges next to tools derive from it. Its own documentation says that being in the enum means there is a gate and not being in it means the feature is free, and that the two ways this can go wrong, promising a gated feature that does not exist and having a gate you never sold, "can never arise from forgetting".

That claim is true, and it was true on the day we removed the trial, and it did not help at all. The enum knew what Pro was. Nothing connected the enum to the four files.

The product code owns the truth. The repository tooling that lints store metadata deliberately has no build dependency on any product's client code, because tooling that is coupled to one product's source tree stops being repository tooling and becomes that product's build. So the enum and the linter are in different build graphs, and the two of them cannot see each other by construction.

The gap, restated: the enum is the single owner of the fact. The copy is in four places with no single source of truth pointing at it. Between them there is nothing.

Two guards, one seam

The fix is two guards, and they are deliberately not one guard, because one guard cannot be in both build graphs at once.

The first is a new lint category in the repository's store-metadata tool. It reads a machine-readable list of Pro capabilities that each review note now carries, and it compares those lists between the notes of the same product. It cannot compare them to the enum, because it has no access to the enum. So it does the half it can do: the three notes must declare the same set of capabilities. And the comments in it say so, in plain words, and name the other half as the product's own test.

The order of the list does not matter, because the same capabilities in a different order is not a divergence. That is deliberate: a check that fires on reordering punishes the person doing the mechanical work of keeping things in sync, which is precisely the person you want to keep doing it.

The second is a test in the product. It compares the declaration in one review note against the enum's cases, and it fails with a message that says what to do about it: update the note, or drop the capability from the enum if it should be free. The note it reads is bundled as a test resource, and the reason only one is needed is written down in the build file: the first guard already proves the other two declare the identical set, and three files with the same basename would collide in the test bundle.

Two guards, one of which cannot see the enum and one of which cannot see the other notes. Either one alone would have left a hole the size of this bug.

The two rules that keep the guards honest

A guard is a promise that something bad will be loud. A guard that is right most of the time is not a guard, it is a suggestion. Two rules keep them from becoming suggestions.

The guard must not fire on things that are not its business. A product whose notes make no Pro claim at all is not obliged to invent one to satisfy a lint rule. Silencing that error is done by declaring the scope, not by deleting the copy. And the test in the product fails if a note stops declaring its scope at all, since an empty declaration would let the prose drift back to whatever it liked.

The guard must be able to do its job. A lint rule cannot import a Swift enum, so it cannot compare against the enum. It can only compare declarations against each other. That limit is what the product test is for. A guard that tries to do both halves in one place fails at one of them, and fails silently at the other.

Takeaways

A copy surface is a write obligation, and an obligation with no owner does not decay slowly; it stays exactly as stale as the day you forgot. We removed a free trial on the eleventh, and the App Review notes still told reviewers to press a button labelled "Start 7-Day Free Trial" on the twelfth of the next month. Nobody noticed for thirty-one days.

Remove a feature and every copy surface that mentions it is now wrong, and if those surfaces are four independent documents, changing the fact meant remembering four edits. The review note for a subscription is genuinely a different document from the one for a lifetime purchase, and that is why the four documents cannot collapse into one. It is also why nothing connected them.

Give every copy surface one machine-checkable declaration and check the declarations against each other. The prose can stay hand-written; what stops drifting is the set of things the prose promises. A declaration in every note, compared between notes, means the four copies can only drift from the declaration and never from each other.

And put the check next to the owner. The lint that compares declarations has no build dependency on product code and never will, because tooling coupled to one product's source tree stops being tooling. The test that compares the declaration to the enum can only exist where the enum is importable. The seam between them is the design, not a gap in it.

Products in this post

Keep reading