My Agent Wrote Down a Plan and Called It a Feature
12 min read
RailMind is an iPad track planner for Kato Unitrack. You lay real track with your fingers, pieces snap together only where the real Unijoiners would let them, and it prices out what you'd have to buy. It is version 0.16.5. It has zero git tags. It is not on the App Store and has never been on TestFlight.
Nothing launched. There's a marketing page sitting at a URL whose own button says "Coming to the App Store" — I haven't QA'd it, I haven't announced it, and I'd still call it a draft. What I want to write about is a bug I found while getting it ready, because it isn't a code bug and it's the most instructive thing I've hit all month.
The tense error
Three days before any of this, I'd settled RailMind's pricing. Not casually — I'd gone and read the market first. AnyRail is $59 and Windows-only. RailModeller Pro is $44.99 and Mac-only; its iPad app is a read-only viewer. Both give you a free tier at fifty pieces, so people can feel the real thing before paying. There is no good native iPad editor at all, which is the entire reason RailMind exists.
So: free to try, one-time unlock, sit just under RailModeller's $44.99 rather than racing to the $1.99 junk tier. Fund catalog upkeep with paid major versions, the Affinity model. And one rule I'd put in writing in language I'll paraphrase for the blog: no microtransactions, ever, not to a customer of mine. "No IAP, no subscriptions — you own it" isn't a fallback, it's a selling point, and it goes on the page.
I also had a reason for the free cap being ten pieces rather than fifty. Eight R282-45 curves plus two S248 straights is a closed oval. Ten is the smallest number that lets you finish a loop, and a free tier that can't close a loop teaches you nothing about the product. That number is an argument, not a guess.
Here's the part that went wrong. What I actually said, out loud, was that I'd probably make the free tier a ten-track set. Future tense. An intention.
It arrived on the marketing page as present tense. "Download RailMind free and build up to 10 pieces." Written as a thing the app does.
The app did not do it. No StoreKit, no paywall, no piece gate — grep the source for FreeTier|StoreKit|Paywall|29.99 and you get nothing. The only maxPieces in the codebase is in the auto-router, a search-depth bound with nothing to do with purchasing.
Nobody lied. A plan got transcribed as a state, and the tense is the entire bug. "I'll probably build X" and "the app has X" differ by one verb and one deployment away from being a false advertisement.
The rule that caught it
It got caught before the page went anywhere, and not because anyone got suspicious. I have a standing rule: verify every technical claim against source before publishing. It isn't clever and it isn't AI-specific — it's just a gate that runs whether or not anything feels wrong. It forced a grep. The grep came back empty.
That's the whole mechanism, and it's the only part of this story worth copying. The claim was plausible. It matched a decision I really had made. It was written by a process working from something I really had said. Every instinct I have would have waved it through, because the instinct's job is to check whether a claim sounds like me, and it did sound like me — it was my plan, in my voice, one tense off.
Suspicion would not have caught this. Only a gate that doesn't care how the claim feels.
The same pass found a second one: the page promised "no in-app purchases and there never will be" in the same breath as "build free up to ten pieces, pay once when you outgrow it." A free tier you pay to exceed is an in-app purchase. The copy contradicted the code and itself. Both got fixed — the pricing section now says, plainly, that there is exactly one purchase and it is the unlock.
Build the promise or delete it
Two options once you find it. Strip the claim, or build the feature.
I built it. The free tier is right, the model is right, and deleting a promise to make a page true is solving the smaller problem. So there's a PR — 677 lines — that implements what the copy already described, and it earned its keep in ways I didn't expect. Opening a plan isn't gated; only placing is, because every bundled example is 12 to 43 pieces and gating open would lock free users out of the gallery that sells the app. And the gate fails open on an unknown entitlement, because encoding "StoreKit hasn't answered yet" as "not paid" put a paywall in front of a paying customer during the async resolve window.
That PR is still open as I write this. The page describes a tier that exists on an unmerged branch. I'm not going to call that resolved because I've decided how to resolve it — deciding and shipping are the two things this entire post is about not confusing.
The copy that survived its own product
There's a worse version of the same disease, and it was on this site until an hour ago.
My portfolio described RailMind as an "iPad-first 2D track planner with an AI proposal engine," and went on: "The AI understands signaling, accessory decoders, route logic, and DCC terminology."
RailMind has no AI. Not "not yet" — deliberately, definitionally none. Its README's first line calls it "a no-AI, touch-driven Kato Unitrack layout editor." The formal requirement in its own testing doc is "Fully offline / no-AI / no-IAP." Its product page says it four separate times: a "No AI" badge in the hero, a section titled "No servers. No account. No AI.", a spec row reading AI: NONE [OK], and an FAQ entry — "Is there really no AI in it?" — that answers "Really."
The code agrees. Searching the entire app for URLSession, URLRequest, CoreML, OpenAI returns zero hits. It has no networking code at all. It cannot phone anything, anywhere.
So my portfolio wasn't overstating a feature. It was advertising the exact opposite of the product's central promise, in my own voice, while the product's own page three clicks away insisted the opposite four times.
Where did it come from? An earlier RailMind concept genuinely had an AI proposal engine — "AI proposes layouts, you accept or reject." I cut that scope before a line of it was written. But the copy describing it lived on in another page of mine, /trains, and when the portfolio entry got rebuilt — drafted a week ago, merged tonight — that copy got carried across because it was mine. Written by me, about my product, therefore trustworthy.
Same tense error as the free tier, aged eight weeks. April's plan, still in the present tense, still on a page, long after the plan was dead. Nobody re-read it against the app, because re-reading your own words against your own code feels like a category error — you were there, you know what you built. But what you know is what you decided. The page remembers what you decided in April. The app remembers what you did in June. Nothing reconciles them but a gate.
So I cut it, along with a "hand-off to RailThrottle" line describing a feature parked behind a debug-only launch flag because RailThrottle doesn't register the URL scheme yet. I also pulled a screenshot out of the gallery: it showed the "Open in RailThrottle" dialog — a real dialog, in real code, that cannot function. A screenshot of a thing that doesn't run is a lie told in pictures, and it's a harder lie to catch than a sentence, because you took the picture. It happened. It was on your screen.
And then I did it again, in this post
I wrote a draft of this section that said "both claims are cut now," and it was false when I typed it.
I'd fixed the portfolio entry — the page I was looking at. I had not touched /trains: the page I name two paragraphs above as the origin of the bad copy. It was still live, still saying "The AI understands signaling, accessory decoders, route logic, and DCC terminology," still carrying an "AI Proposals" tech tag, while this post congratulated itself for removing it.
I only caught it because I ran the draft through an adversarial check whose entire job is to refute me. It went and fetched the live page. I hadn't.
Think about what that means. This post's thesis is that copy outlives the product because checking your own words against your own code feels redundant. I was writing that sentence — while believing I'd finished a cleanup I'd done exactly half of — because I checked the diff in front of me and called it the world. The failure mode is not that I don't know about the failure mode. I'm two thousand words deep in knowing about it.
/trains is fixed now, in the same change that ships this post. But I'm leaving the paragraph in, because "I wrote a post about false claims and it contained one" is more useful to you than a clean narrative would be.
What's actually true
Since I've spent this whole post on what isn't done, here's the ledger of what is. 11,151 lines of app code against 6,140 lines of tests — 316 unit tests and 20 UI tests across 38 files. Zero TODOs or FIXMEs in the entire Swift source. Twelve example layouts, test-asserted. Forty-five HO pieces in the catalog, test-asserted. A real SceneKit 3D view. Undo/redo, multi-select, turntables, double-track, RailModeller import.
The one piece of automation is an auto-router: an A* search over real piece geometry, bounded at 16 pieces and 60,000 expansions, that closes a gap in your track. It's deterministic, it's offline, and it's a solver — not a model. Calling it AI would be the same lie in a nicer hat.
And the part I'm proudest of is the pricing, precisely because it's the part that admits what it doesn't know. Every price is Kato's published MSRP from catalog #25-110, divided by the pack size Kato actually sells — written in the source as literal division, 10.00 / 4, so the arithmetic is auditable rather than a number someone typed. Parts without a published MSRP fall back to a category estimate, and the comment refuses to launder it: that's the estimate, "flagged here rather than dressed up as a sourced number." The UI says "Estimated total" and tells you street prices vary.
That discipline exists because the old flat-rate pricing was wrong in both directions at once — billing $15 for every viaduct piece when they run $2.75 to $5.75, while under-billing HO power turnouts by $20–30 each. A test file in the repo explains why it exists: the previous values "were plausible guesses that were quietly wrong, and a wrong pack size or price costs the user real money at the hobby shop." One straight piece ships two per pack, not four — every shopping list under-counted it by half.
That's the same bug as the AI copy. A plausible number, quietly wrong, in a place where being wrong costs somebody something real.
Not done
Thirteen commits landed today, and thirteen of the last twenty-five are fixes and tests rather than features. One of them, from this afternoon, is titled "fix: two RC blockers — HO plans got N track; renames were silently lost." You don't fix RC blockers on a shipped app.
What's left isn't code. There is no paid Apple Developer Program membership — the project runs on a free personal team, which gates everything downstream: the App Store Connect record, distribution signing, the archive, the submission. That's a credit card and my hands, not the compiler's.
Asked myself today whether we had a release candidate. No. So the version says 0.16.5 instead of 1.0.0, and that number is the most honest thing on the whole project.
Nothing here launched. The app is 0.16.5 with two open PRs. The page is a draft I haven't QA'd, wearing a button that says "Coming to the App Store" — which is, at least, the one claim on it that was never in the wrong tense.
The lesson isn't "ship the site after the binary," though you probably should. It's narrower and it generalizes past websites: the distance between a decision and a fact is one verb, and nothing in your toolchain checks verbs. I'd decided the pricing. I'd argued the cap down to ten pieces because ten closes an oval. Every one of those decisions was real, considered, and mine — and not one of them was a feature, because deciding something is not building it, and the sentence describing it reads identically either way.
An agent turned "I'll probably" into "you can." My portfolio turned an April intention into a June capability. I turned a fixed file into a fixed website an hour ago, in this post, in a paragraph about not doing that.
Same error, three scales, one day. The only thing that caught any of them was a gate that didn't care how right I felt.