Answering the age-rating questionnaire from a file in the repo, not from memory
The App Store age-rating questionnaire became mandatory in September 2026. Here is how I moved the answers into a declaration file in the repo and had a stdlib-only script argue back, with the real output.
In early September I was submitting a wallpaper app update and stopped partway through App Store Connect. There was a questionnaire I had never seen. Since September 2026, answering it is required when you submit a new app or an update, and when you file for notarization for alternative distribution. It drives the age rating and the Time Allowances behaviour on iOS 27, iPadOS 27 and macOS 27.
I tried to answer from memory. I stopped on the second question. Asked whether the app opens web content inside itself, I could not honestly say yes or no. Somewhere in the settings screen there was a link that opened a help page. I had put it there three years ago, and I no longer notice it when I look at the screen.
What I decided in that moment became the rule.
If a question makes you dig through your memory at submission time, the answer belongs in the repository.
As an indie developer shipping several apps, I fill in the same questionnaire many times a year, once per app. Reconstructing the answers each time guarantees they will eventually drift, and when they do, I will not know whether the declaration or the implementation is the one that moved. What follows is how I put the answers into a single declared file and made a check fail when the code disagrees.
The questionnaire asks what you are capable of, not what you usually do
This is where I was stuck. Questions of this kind are not asking what the app does day to day. They ask whether a user can reach a capability.
Advertising is the clearest example. Ads are a rating question in their own right, but separately, ad landing pages open in an in-app browser. Ship an ad SDK and your users can reach an arbitrary web page from inside the app, even if the string WKWebView never appears in your source.
The other case is leftovers. You can stop asking for tracking consent and delete the ATTrackingManager call, and still leave NSUserTrackingUsageDescription sitting in Info.plist. From the outside, an app that ships that key is an app that may ask.
Kind of entry point
Where the evidence lives
Findable by reading your own source?
A feature you wrote
Swift / Kotlin source
Yes
A declaration you forgot to remove
Info.plist
Rarely
A path a dependency brings with it
Podfile.lock / Package.resolved
No
The answers worth protecting are the ones where you said "none." Nobody notices when those quietly stop being true.
Put the answers in one declared file
Start by writing the answers out per app. I use JSON so that the checker needs nothing but the standard library.
questionnaire_version earns its place because the questionnaire itself gets revised. Without it, the next revision leaves you unable to say which set of questions these answers were given to, and you are back to digging through memory a year later.
The answers above do not have to be correct yet. They are exactly what I typed from memory. The machine is about to argue with them.
✦
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 can stop reconstructing questionnaire answers from memory and pull the same answer out of one declared file in the repo
✦You can find the questions where your declaration disagrees with your implementation on your own machine, before a reviewer finds them for you
✦You can account for the entry points that never appear anywhere in your own source, and explain what your app is capable of rather than what it usually does
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.
Make the declaration and the implementation disagree out loud
The checker fits in the standard library. plistlib ships with Python, so Info.plist is readable even in its binary form. Evidence is gathered from three places.
One: the source you wrote
Walk .swift / .kt and friends line by line, and record every line where a sign such as SFSafariViewController or StoreKit appears, with the file name and line number. The line number matters later, when you check with your own eyes whether that screen is still reachable.
Two: keys in Info.plist
plistlib.loads() takes bytes and handles XML and binary alike. Keys such as NSUserTrackingUsageDescription and the location usage descriptions outlive the implementation. Strip the feature from the code and leave the key, and the app is still one that may ask.
Three: the dependency lock files
Read Podfile.lock and Package.resolved as text. If AdMob (Google-Mobile-Ads-SDK) or AppLovin is listed, raise evidence not only for advertising but for the in-app browser as well. This is the part no amount of reading your own source will produce.
#!/usr/bin/env python3"""Compare declared age-rating answers against what the implementation shows.Standard library only. exit 1 = the declaration and the code disagree."""import json, plistlib, re, sysfrom pathlib import PathSOURCE_SUFFIXES = {".swift", ".m", ".mm", ".h", ".kt", ".java"}# Seen in your own source -> the capability is presentSOURCE_SIGNS = { "in_app_browser": [r"\bSFSafariViewController\b", r"\bWKWebView\b", r"\bASWebAuthenticationSession\b", r"\bUIWebView\b"], "advertising": [r"\bGoogleMobileAds\b", r"\bBannerView\b", r"\bAppLovin", r"\bGADMobileAds\b"], "user_generated_content": [r"\bUITextView\b", r"\bTextEditor\b", r"uploadComment", r"submitPost"], "in_app_purchase": [r"\bStoreKit\b", r"\bTransaction\.currentEntitlements\b", r"\bSKPayment"], "location_sharing": [r"\bCLLocationManager\b", r"\brequestWhenInUseAuthorization\b"], "tracking": [r"\bATTrackingManager\b", r"\brequestTrackingAuthorization\b"],}# Present in Info.plist -> there is a path to the capabilityPLIST_SIGNS = { "location_sharing": ["NSLocationWhenInUseUsageDescription", "NSLocationAlwaysAndWhenInUseUsageDescription"], "tracking": ["NSUserTrackingUsageDescription"],}# What a dependency brings in (never appears in your own source)DEPENDENCY_IMPLIES = { r"Google-Mobile-Ads-SDK|GoogleMobileAds": [ ("advertising", "an ad SDK is bundled"), ("in_app_browser", "ad landing pages open in an in-app browser"), ], r"AppLovin|InMobi|UnityAds": [ ("advertising", "an ad mediation SDK is bundled"), ("in_app_browser", "ad landing pages open in an in-app browser"), ],}# You may declare absence only when no evidence turned up at allDECLARED_ABSENT = { "in_app_browser": lambda v: v == "none", "advertising": lambda v: v == "none", "user_generated_content": lambda v: v == "none", "in_app_purchase": lambda v: v is False, "location_sharing": lambda v: v is False, "tracking": lambda v: v is False,}def collect_evidence(root: Path) -> dict[str, list[str]]: found: dict[str, list[str]] = {} for path in sorted(root.rglob("*")): if not path.is_file(): continue if path.suffix in SOURCE_SUFFIXES: text = path.read_text(encoding="utf-8", errors="ignore") for line_no, line in enumerate(text.splitlines(), 1): for key, patterns in SOURCE_SIGNS.items(): for pattern in patterns: if re.search(pattern, line): found.setdefault(key, []).append( f"{path.relative_to(root)}:{line_no} {line.strip()[:60]}") elif path.name == "Info.plist": try: plist = plistlib.loads(path.read_bytes()) except Exception: continue for key, plist_keys in PLIST_SIGNS.items(): for plist_key in plist_keys: if plist_key in plist: found.setdefault(key, []).append( f"{path.relative_to(root)} {plist_key}") elif path.name in {"Podfile.lock", "Package.resolved"}: text = path.read_text(encoding="utf-8", errors="ignore") for pattern, implications in DEPENDENCY_IMPLIES.items(): if re.search(pattern, text): for key, why in implications: found.setdefault(key, []).append( f"{path.relative_to(root)} via dependency: {why}") return founddef main() -> int: manifest_path, app_root = Path(sys.argv[1]), Path(sys.argv[2]) answers = json.loads(manifest_path.read_text(encoding="utf-8"))["answers"] evidence = collect_evidence(app_root) conflicts = 0 print(f"{'question':<24}{'declared':<14}{'ev':>4} verdict") print("-" * 72) for key, declared in answers.items(): hits = evidence.get(key, []) says_absent = DECLARED_ABSENT[key](declared) if says_absent and hits: verdict, conflicts = "x the code says otherwise", conflicts + 1 elif not says_absent and not hits: verdict = "- no evidence found" else: verdict = "ok" print(f"{key:<24}{str(declared):<14}{len(hits):>4} {verdict}") if says_absent and hits: for hit in hits: print(f"{'':<24} {hit}") print("-" * 72) print(f"conflicts: {conflicts}") return 1 if conflicts else 0if __name__ == "__main__": raise SystemExit(main())
The design decision I went back and forth on was whether to make the verdict three-valued. In the end it only fails on one case: you declared absence and evidence turned up. The reverse — you declared a capability and no evidence appeared — prints a dash and passes. Usually that just means my patterns are incomplete, and a check that fails on incomplete patterns is a check nobody runs.
Three traps I hit while writing it
All three fail quietly, so it is worth heading them off first.
Info.plist is not just the app target. Ship a widget or a share extension and you have several. Picking all of them up with rglob is correct, but keep in mind that keys on the extension side feed into the answer for the app as a whole.
Package.resolved is versioned. Matching on strings is unaffected, but if you grow this toward structured parsing, read version before anything else.
Leave errors="ignore" in place. Without it, a single non-UTF-8 file in some corner of the repository takes the whole run down. Skipping quietly is the practical choice here.
Running it produced two disagreements
To keep this reproducible I ran it against a minimal tree: a screen and an ad banner under Sources/, a help-link screen under Settings/, plus Info.plist and Podfile.lock.
$ python3 check_rating_answers.py rating-answers.json WallpaperAppquestion declared ev verdict------------------------------------------------------------------------in_app_browser none 2 x the code says otherwise Podfile.lock via dependency: ad landing pages open in an in-app browser Settings/SupportLinkView.swift:7 vc.present(SFSafariViewController(url: url), aadvertising contextual 3 okuser_generated_content none 0 okin_app_purchase True 2 oklocation_sharing False 0 oktracking False 1 x the code says otherwise Info.plist NSUserTrackingUsageDescription------------------------------------------------------------------------conflicts: 2$ echo $?1
in_app_browser is the exact question I stalled on. The help link I added three years ago came back with a line number. The second one, tracking, I had forgotten completely: the consent code is gone, but the Info.plist key stayed behind.
A third of the evidence was outside my own source
This is the part that came out the opposite of what I expected. I assumed the check would surface leftover code. Counting the evidence it actually collected told a different story.
Where the evidence came from
Count
Source I wrote (.swift)
5
Info.plist and Podfile.lock
3
Total
8
That is 37.5% of the evidence coming from places where I never wrote a line. More to the point: of the two conflicts, one had no basis in my source at all. The only evidence for tracking was a single line in Info.plist. in_app_browser had a dependency-derived hit on top of its source hit.
So a check that greps Sources/ and stops there would have waved through one of the two things worth stopping for. What an app is capable of sits partly outside the code you wrote. That matches how the apps I actually maintain have grown — when I added another adapter to AdMob mediation, what I added was a dependency declaration, and not one line of source.
Fix the declaration, then hold it green
When a conflict shows up you fix either the declaration or the implementation. I was keeping both the help link and the ads, so the declaration moved: in_app_browser to restricted, tracking to true.
Setting tracking back to true moved a real decision — keep the Info.plist key or remove it — from submission day to today. That, to me, is the whole return on declaring the answers first.
I also measured the cost. On a tree padded out to 2,000 source files (2,002 files total), the run took 0.21 seconds. It only reads text files filtered by extension, so the number stays in that range as a repository grows. Cheap enough for CI, and cheap enough for a pre-commit hook.
Put it in the everyday, not in the submission checklist
There are two places this can live: a pre-submission checklist, or CI. I went with CI.
Run it right before submitting and the conflict surfaces at the moment you have the least time. Few people decide calmly which side is wrong under that pressure. In CI, it goes red on the day you add a dependency or touch Info.plist — which hands you the time to decide weeks before submission.
This script can say that a declaration and an implementation disagree. It cannot say which one should move. That is a question about intent, and intent does not follow from evidence.
I hold the same line when I hand work to an agent. Gathering evidence, attaching line numbers, presenting a diff — all delegated. My hand is the one that edits rating-answers.json. Evidence gathering to the machine, the wording of the declaration to me. Anything submitted under my name keeps its last move with me.
Drop a rating-answers.json at the root of whichever app repository is closest to hand, and fill it in from memory. The checking can come later. The moment the answers exist as text, that is one thing you will not have to reconstruct at the next submission.
I have not moved all of my own apps across yet. If you are facing the same questionnaire this season, I hope this saves you an evening.
Share
Thank You for Reading
Antigravity 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.