My Website Promised a Feature My App Didn't Have
8 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.
Its launch website went live today.
The site shipped. The app didn't. I want to write about that gap honestly, because it produced the most instructive bug I've had all month, and it isn't a code bug.
The site became a spec
The launch site advertises a 10-piece free tier and a one-time $29.99 unlock. Reasonable business model. It's on the page right now.
None of it existed in the app. No StoreKit. No paywall. No piece gate anywhere. Grepping the app source for FreeTier|StoreKit|Paywall|29.99 returned nothing — the only hit anywhere in the repo is a requirements doc promising the exact opposite. The only maxPieces in the codebase is in the auto-router: a search-depth bound, having nothing whatsoever to do with purchasing.
I found this the boring way: verifying the site's claims against source before publishing it. Not from a bug report. Not from a user. From reading my own marketing copy next to my own code and noticing they described different products.
The decision at that point is the interesting one. You can strip the claim or build the feature. I chose to build it — the free tier is real work, it's the right model, and deleting the promise to make the page true would have been solving the wrong problem. So there's an open PR, 677 lines, that implements what the site already says.
Which means the site is still ahead of the binary as I write this. The page promises a tier that exists on an unmerged branch. I'm not going to pretend that's resolved because I've decided how to resolve it.
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 launch site 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 my own product site 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 writing a line of it. 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. Nobody checked it against the app, because checking your own words against your own code feels like a category error. You were there. You know what you built.
I didn't. I knew what I'd intended to build in April.
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.
Today, deciding what to call the launch version, I asked whether we had a release candidate. The answer was no. So it went out as 0.16.5 instead of 1.0.0, and that number is the most honest thing on the whole project.
The website is done. The app is 0.16.5 with two open PRs and an inactive developer account. Those are different sentences, and for a while I had them merged into one — a page describing a product that was partly built, partly cut, and partly still an intention I'd had in the spring.
Ship the site after the binary. Or, if you ship it first, at least go read it next to the code before you tell anyone it's true.