●PLAY — Google Play's target API level deadline passed on August 31. Whether the project your builder generates has kept pace is worth verifying yourself rather than assuming●POLICY — Play has sharpened its definition of a low-value app: rehashes of existing apps, or thin wrappers around a third-party model with no useful function of their own●DATA — User Data requirements apply to third-party AI integrations too. Disclosing what gets sent to an external model is the responsibility of whoever publishes the app●APPLE — Enforcement of Guideline 2.5.2 has tightened around apps that execute arbitrary code at runtime, putting the line between a tool and an app under closer scrutiny●BUILD — Compiling a signed native binary server-side through a reproducible pipeline sits outside 2.5.2, and the technical distinction is worth stating precisely●IOS — iOS 27 reaches general release in September, supporting iPhone 11 and later. Building the device check into an annual routine takes the surprise out of it●PLAY — Google Play's target API level deadline passed on August 31. Whether the project your builder generates has kept pace is worth verifying yourself rather than assuming●POLICY — Play has sharpened its definition of a low-value app: rehashes of existing apps, or thin wrappers around a third-party model with no useful function of their own●DATA — User Data requirements apply to third-party AI integrations too. Disclosing what gets sent to an external model is the responsibility of whoever publishes the app●APPLE — Enforcement of Guideline 2.5.2 has tightened around apps that execute arbitrary code at runtime, putting the line between a tool and an app under closer scrutiny●BUILD — Compiling a signed native binary server-side through a reproducible pipeline sits outside 2.5.2, and the technical distinction is worth stating precisely●IOS — iOS 27 reaches general release in September, supporting iPhone 11 and later. Building the device check into an annual routine takes the surprise out of it
My Second App From the Same Codebase Ships With a Distinct-Value Manifest
Google Play has drawn a clearer line around low-value apps. Here is how I decide where the distinct value of a sibling app lives, and the small script I run before every submission to check that the two apps have actually diverged.
The Question That Stopped Me on Submission Morning
I had a new wallpaper build ready for Play Console one morning, and my hand stopped over the button. Nothing was technically wrong. What stopped me was a question I could not answer in my own words: how is this app different from the other one?
The codebase is shared. The screen skeleton, the purchase flow, the settings page — all reused. The only difference was the images being served, and as I started to say that out loud, I realised it was not an answer at all.
If you run several apps as an indie developer, this question comes back on a schedule. It got a little warmer this month, when Google Play clarified what it considers a low-value app. What follows is the check I actually run before I submit.
Low Value Has Two Branches
The clarification splits into two cases. One is a rehash of an existing app. The other is wrapping a third-party model without adding any value of your own.
Those two branches catch very different people.
Branch
Who it catches
What is being asked
Rehash of an existing app
Anyone shipping siblings from one codebase
Is the second app a different experience?
Wrapping a model
Anyone who shaped an app quickly with an AI builder
What remains if you remove the model?
If you started with a tool like Rork, the second branch is the one you notice first. But once you are on your second or third app, a foot quietly lands in the first branch too. That crossing over is exactly where I got caught.
One more piece landed alongside it: user-data requirements now clearly extend to third-party AI integrations, and the duty to disclose what you send sits with the developer who publishes the app, not with the builder that generated it. I will come back to that.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to count, before you press submit, exactly which surfaces of two sibling apps still overlap
✦You will be able to answer a reviewer's question about distinct value from a record you already keep, instead of reconstructing it from memory
✦You will be able to catch the disclosure lines that a single third-party model call adds, on the day you write it rather than after the app is live
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
For a while I thought about differentiation in terms of screenshots. If the store listings look different side by side, surely that is enough. It did not work well. You can change how something looks, but a reviewer is asking whether there is anything here you cannot get elsewhere, and that is not a question about impressions.
Now I split the two apps into four surfaces and measure the overlap on each:
Catalog — the set of assets actually shipped
Copy — whether the same key holds the same string in both apps
Screens — the shape of the route tree
Features — what you can do in each app
Set overlap is enough to measure this. Siblings are supposed to share a catalog, so that threshold stays loose, while the surfaces closest to the experience get strict ones. Varying the threshold per surface is the whole point: a single flat threshold pushes you to change things that should not change.
The Sixty Lines I Run Before Submitting
Here is the script. Give it two manifests and it reports the overlap per surface, plus what exists only on the B side.
#!/usr/bin/env python3"""distinct_value.py - count how much two sibling apps overlap before submission.Usage: python3 distinct_value.py app_a.json app_b.jsonExit code 1 = a threshold was exceeded (fix it before you submit)."""import jsonimport sys# Catalogs are supposed to overlap, so keep that loose.# The closer a surface is to the experience, the stricter it gets.THRESHOLDS = {"catalog": 0.60, "copy": 0.35, "screens": 0.50, "features": 0.70}def load(path): with open(path, encoding="utf-8") as f: m = json.load(f) # A missing surface is an explicit failure, not an empty set. for key in ("catalog", "copy", "screens", "features"): if key not in m: raise KeyError(f"{path}: '{key}' is missing") return mdef jaccard(a, b): sa, sb = set(a), set(b) if not sa and not sb: return 0.0 return len(sa & sb) / len(sa | sb)def copy_overlap(a, b): """Copy is compared by value, not by key. Two apps can share a key and still read as different products if the strings differ.""" keys = set(a) & set(b) if not keys: return 0.0 same = sum(1 for k in keys if a[k].strip() == b[k].strip()) return same / len(set(a) | set(b))def main(path_a, path_b): a, b = load(path_a), load(path_b) scores = { "catalog": jaccard(a["catalog"], b["catalog"]), "copy": copy_overlap(a["copy"], b["copy"]), "screens": jaccard(a["screens"], b["screens"]), "features": jaccard(a["features"], b["features"]), } failed = [] print(f"{'surface':<10}{'overlap':>9}{'limit':>8} verdict") for key, value in scores.items(): limit = THRESHOLDS[key] ok = value <= limit if not ok: failed.append(key) print(f"{key:<10}{value:>9.2f}{limit:>8.2f} {'ok' if ok else 'OVER'}") only_a = sorted(set(a["features"]) - set(b["features"])) only_b = sorted(set(b["features"]) - set(a["features"])) print(f"\nOnly in A: {only_a or '(none)'}") print(f"Only in B: {only_b or '(none)'}") if not only_b: failed.append("features:B-side-empty") print("B has no feature of its own. You have nothing to hand a reviewer.") if failed: print(f"\nFAIL: {', '.join(failed)}") return 1 print("\nPASS: all four surfaces are within their limits.") return 0if __name__ == "__main__": if len(sys.argv) != 3: print(__doc__) sys.exit(2) sys.exit(main(sys.argv[1], sys.argv[2]))
The manifest can stay this coarse. Generate it from your build config so you are not hand-editing it on every release.
The deliberate failure when only_b is empty came later. Numbers can sit inside every threshold and still leave you with nothing to show if the second app owns no feature of its own. I wanted the tool that measures to double as the tool that answers.
The Result Went the Opposite Way From What I Expected
My assumption was that swapping the catalog would be enough. The first run turned that assumption over.
surface overlap limit verdict
catalog 0.17 0.60 ok
copy 0.67 0.35 OVER
screens 0.83 0.50 OVER
features 0.33 0.70 ok
Only in A: ['daily-pick', 'hdr-preview']
Only in B: ['artist-notes', 'restoration-before-after']
FAIL: copy, screens
Catalog overlap was down to 0.17, and yet copy came in at 0.67 and the route tree at 0.83. Replacing the assets does nothing for the words and the navigation, because those grew from the same trunk.
In hindsight it is obvious. Swapping images is visible work, so you do it first. home.title and the paywall headline live in the shared layer, where it never occurs to you that they are shared at all. When you fix what you can see, the parts that overlap most are the ones left standing. That sentence is the reason the script now sits in my submission flow.
So I reworked the second app's route tree and its copy — per-artwork notes, a before-and-after restoration view, and headlines rewritten instead of inherited. The same check afterwards:
surface overlap limit verdict
catalog 0.00 0.60 ok
copy 0.00 0.35 ok
screens 0.38 0.50 ok
features 0.14 0.70 ok
Only in A: ['category-browse', 'daily-pick', 'hdr-preview']
Only in B: ['artist-notes', 'print-provenance', 'restoration-before-after']
PASS: all four surfaces are within their limits.
Screens still overlap at 0.38, and that is fine. Settings and detail views do not need to be different products, and forcing them apart only makes both apps harder to use. That is what the per-surface limits are for.
One Model Call Adds More Than One Line
Back to the second branch for a moment.
The manifest doubles as a first draft of your disclosure. Adding a feature that calls an external model does not add one line to features. It adds three questions at once: what you send, where it goes, and whether it is retained. And the duty to answer them sits with the person publishing the app, not with the tool that wrote the call.
So each feature row that touches a model carries these fields alongside it.
Field
What goes in it
When to write it
Destination
The external service being called
The day you implement it
Payload
Whether user-originated data is in it (text, images, identifiers)
The day you implement it
Retention
Whether the vendor stores it, or trains on it
The day you implement it
Removability
Whether the app still stands with the feature removed
The day before you submit
That last row is the answer to the second branch. If the app still stands without the feature, you have not merely wrapped a model. If the row is blank, I take it as a signal to think again before submitting.
Pick two apps you currently run and write out the features array for each, by hand. You do not need the catalog or the copy yet. If you stall while trying to name something that exists in only one of them, that gap is what to fill before you submit.
Since I started doing this, submission mornings feel different. I am not doing anything clever — I simply have the answer ready before I ask for the review — and the waiting is far easier for it.
Thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.