40 KiB
40 KiB
Pitfalls: lessons from real use
A changelog of mistakes found while shipping real apps and games with storebound, newest first. Each entry says what
went wrong, why, and what now guards it. The agent-facing short version is skills/store-pitfalls/SKILL.md; keep the
two in step.
Rule (CONTRIBUTING.md): every bug found in real use adds an entry here and, where possible, a check or a test that fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 52 | 2026-10 | An iOS app that is live on the App Store but whose source is not on this machine could not appear in the desktop: the machine's app list (~/.storebound/projects.json) took only folders. |
storebound apps add --store apple|play --app-id <id> --account <profile> writes a store-only entry (storeApps, no path; apps folders shows "no local project", apps remove apple:<id>); the desktop adds it to the app list of the account with that profile (registry::with_store_only, store_apps_cached / store_apps_refresh), shown read-only as "Store only"; writing the folder list keeps the entries. tests/onboard.test.ts, src-tauri/tests/machine.rs, the boot check's shared-profiles case (zero uncaught errors). |
| 51 | 2026-10 | With several credential profiles the only way to have a machine default was naming one default. |
storebound accounts default <profile> (and --clear) writes the pointer file <credentials>/default-profile; accounts list shows the default; order: env > a profile the project names > the project's own keys > the pointer > the only profile (or default) (defaultProfile, setDefaultPointer in cli/lib/credentials.ts; tests/machine.test.ts). |
| 50 | 2026-10 | Onboarding an app with sign-in left "account deletion URL" as an owner question although the app's own website had the page. | storebound onboard looks on the app's site before asking: /delete-account, /account/delete, /delete, /account-deletion on the website and the privacy policy's host, then links in the privacy policy that mention delete / deletion, then a deletion section of the privacy page; a page counts only when it answers 200 and speaks of deleting. Not found stays a finding ("a real gap, Play requires it"); never invented (findDeletionUrl in cli/lib/onboard.ts; tests/onboard.test.ts). |
| 49 | 2026-10 | "Sign-in required for review" defaulted to yes during onboarding, so every app got a demo-account question. | signInDecision: Apple's live review detail (demoAccountRequired) wins, else no auth code in the project (scan's auth signals, import lines ignored) means no; only when neither proves it is it an owner question. A demo account set on App Store Connect goes into .storebound/credentials.json reviewDemoAccount, never printed (cli/lib/onboard.ts, cli/onboard.ts; tests/onboard.test.ts). |
| 48 | 2026-10 | Onboarding left support@example.com and the template URLs in the config and asked the owner for the contact e-mail, website and App Review contact, all of which the stores have. |
storebound onboard fills app.contactEmail / website / supportUrl / privacyPolicyUrl / marketingUrl from Play's app details and App Store Connect where the config holds a template value (a real value that differs is reported, kept); the App Review contact goes into .storebound/credentials.json reviewContact, masked in every line (phone: last 2 digits) (contactProposal, maskContact; tests/onboard.test.ts). |
| 47 | 2026-10 | Setting storebound up on live apps left their products out of the config "because there were no USD prices": the import read only texts (catalog pull), never prices. |
storebound onboard --write imports the live one-time products and subscriptions of both stores: ids, type, listings per language, usd = the US price (else Play's own USD price for new regions), Apple's price schedule base territory as an override when it is not the USA, an item on one store only kept with platforms; catalog diff comes out clean; store prices are never changed, an item with no USD price anywhere is skipped with the reason (importCatalog; tests/onboard.test.ts runs catalog diff on the written config). |
| 46 | 2026-10 | apps list --store play and Play vitals failed outright when the Play Developer Reporting API was off in the service account's Cloud project, with a generic "enable it" hint. |
The finding names the project and the exact link (https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=<id>, the id from Google's 403 or the service account e-mail: reportingEnableUrl); apps list no longer fails: it lists the Play apps of this machine's app folders and store-only entries instead, marked as not read from Play (knownPlayPackages, localPlayApps); healthFindings uses the same link. tests/onboard.test.ts, tests/store-real-shapes.test.ts. |
| 45 | 2026-10 | validate reported 34 blockers for a Play app whose 17 extra languages have no icon or feature graphic of their own; Play shows the default language's graphics there. |
playRules: a non-default language with none of its own uses the default language's graphics (one note per image type); only the default language's missing graphics block (cli/lib/validate-rules.ts; tests/wave1.test.ts). |
| 44 | 2026-10 | "en-US: no APP_IPHONE_67 screenshots" blocked live apps that have the 6.5" set: App Store Connect requires the 6.9" set or, without it, the 6.5" set (and scales down from it). | ACCEPTED_INSTEAD in cli/lib/validate-rules.ts: the 6.5" set satisfies the 6.9" requirement (and either 13" iPad type the other); then a recommendation, not a blocker (tests/wave1.test.ts, a live-shape set list with only APP_IPHONE_65). |
| 43 | 2026-10 | The App Store keyword field is limited to 100 characters, not UTF-8 bytes (entry 4 was wrong): live versions READY_FOR_SALE read on 2026-10-13 hold Thai 95 characters / 265 bytes, Russian 91 / 169, Japanese 49 / 123, Turkish 100 / 105, Polish 99 / 105, and App Store Connect accepted them. | check (cli/lib/copy.ts), check --aso (auditAppleKeywords), listing check (checkFields), listing translate (normalizeKeywords) and aso suggest (fitKeywordField) count characters; tests/listing-translate.test.ts, tests/wave1.test.ts, tests/aso-plus.test.ts with Thai, Turkish and Polish fields over 100 bytes. |
| 42 | 2026-10 | apple testflight distribute --build 1 --group Team --yes failed with 400 on GET /v1/betaGroups?filter[builds]=: Apple's spec declares the filter, App Store Connect refuses it, and the spec has no GET of a build's groups. Before the groups existed it said unknown group "Team" (). |
Distribute reads each target group's builds (GET /v1/betaGroups/{id}/relationships/builds), reports internal all-builds groups as "already available" and never sends them the build; unknownGroupMessage points at groups sync for configured groups. APPLE_LIVE_REJECTED (cli/api/client.ts, also refused by storebound api); tests/asc-query-guard.test.ts checks every App Store Connect GET with a query in cli/ against the spec's declared parameters and that list; the fake App Store Connect answers 400 to them (tests/idempotency.test.ts). |
| 41 | 2026-10 | Every Play App content card ended in an owner-only Save, so the owner came back once per card (privacy policy, ads, app access, advertising ID, government, financial, health, target audience, content rating, data safety). Saving an App content declaration is not public: it goes to Google only with the next "Send changes for review" in Publishing overview (verified 2026-10-13). | Card Saves are the agent's (declaration-save): declarationSaveAllowed (exactly Save / Kaydet, a button on an /app-content/ page) and flowAllowed for content rating and target audience, while publishingControl refuses "Send changes for review", "Submit N changes for review", Submit, Publish, "Restart review" everywhere; the plans' buttons.final is "Send changes for review"; console fill presses the declaration's Save; console plan play-app-content --all walks every card and ends with one owner list (cli/lib/console-forms.ts, cli/lib/console-plan.ts, cli/lib/console-play-plans.ts, cli/console/fill.ts; tests/console-play-plans.test.ts, tests/console-flow.test.ts). |
| 40 | 2026-10 | Finding a new app's Play Console app id (play.appId) took several steps: the app list shows 10 apps per page and the new app was not on page 1. The id was in the URL Create app redirects to (/developers//app//app-dashboard) all along. |
console verify play-app-record --url <url> reads the id from the redirect (or the capture JSON's url), shows the play.appId change as a dry diff and --write upserts it (upsertNestedString); storebound play app-id finds it by package name across captured app list pages (search box or pages) as an agent step (cli/lib/play-app-id.ts, cli/play/app-id.ts; tests/play-app-id.test.ts, a modeled 3-page list). |
| 39 | 2026-10 | Play IARC questionnaire: page 1's Next did nothing until the IARC Terms of Use box was ticked, an owner-only agreement, so the agent stopped mid-run and waited for the owner. | The box is in the plan's ONE owner batch before the run with its exact place (ownerBatch, printed first; play-app-content and play-content-rating); the step says not to press Next again and again; console fill waits for the tick ("agreement needed on …", blocksNext) instead of pressing a Next that does nothing (tests/console-play-plans.test.ts, tests/console-flow.test.ts). |
| 38 | 2026-10 | App Store Connect's New App dialog: a programmatic value set (a form_input-style set) on the Name and SKU textboxes left the dialog's React state empty (""), while clicking the field and typing worked. | The apple-app-record plan says "type, don't set value" (uiFacts and the fill step); console fill types every text field key by key and reads it back (typeText in cli/console/fill.ts); tests/console-flow.test.ts (a New App fixture whose state follows key presses only). |
| 37 | 2026-10 | A scan.ignore entry whose match text was in the evidence (a code line) instead of the finding title accepted nothing and said nothing. init --profile game wrote example-looking values into .storebound/credentials.json (YOUR_KEY_ID, /absolute/path/to/upload.p12, sk_your_revenuecat_v2_secret_key), which commands read as real credentials. |
scan.ignore[].evidence matches an evidence line ("file:line text"), match stays the title; scan (report notes) and check (warning) name every entry that accepts no finding (ignoreMatches / applyIgnore / unusedIgnores in cli/lib/scan/rules.ts). templates/credentials.example.json holds empty strings only; isPlaceholder (cli/lib/credentials.ts) makes "your-…", <…>, "xxx", "/absolute/path/to/…", "example" and the zero Apple Ads client id missing in every credential read; check warns, doctor shows set / missing / placeholder per key. tests/game-report.test.ts. |
| 36 | 2026-10 | Privacy answers derived from the wrong source: privacy-manifest --propose wrote an EMPTY NSPrivacyCollectedDataTypes while appPrivacy.types listed 5 collected types; console plan apple-app-privacy added "Other Usage Data" from dataSafety (PSL_OTHER_APP_ACTIVITY) although appPrivacy.types was given; console plan play-advertising-id answered "uses advertising ID" Yes because dataSafety declared PSL_DEVICE_ID (a random install id, not the advertising ID). |
--propose builds NSPrivacyCollectedDataTypes from appPrivacy.types (types, linked, tracking, purposes as Apple's manifest keys; NSPrivacyTracking still only with app-level domains, lesson 29) and check warns on an empty list against collected types. appPrivacy.types is authoritative in the App Privacy sheet (unlisted = not collected, certain); without it, tracking false and no ad / attribution SDK make Advertising Data, Customer Support, Environment Scanning, Hands and Head "not collected" unless a project signal suggests them (cli/lib/project-signals.ts). Advertising ID comes from ad / attribution SDKs, the AD_ID permission (source or merged manifest, or its tools:node="remove"), ads.appAdsTxt or console.appContent.advertisingId; none: No (certain), a conflict: ask (advertisingIdUse). tests/game-report.test.ts. |
| 35 | 2026-10 | scan on a Capacitor game: rule review-prompt (3.2.2(x), reward for rating) fired on const rate = F.genRate(lv, bonus.gen(r)); (a bare "rate" next to "bonus"); every account finding cited import './buildings/register'; as sign-up evidence; and 5.1.1(v) cleared as soon as accounts.inApp named a screen the code did not have. |
The incentive signal needs a rate-us / leave-a-review phrase next to a reward, a reward "for rating / reviewing", or a reward granted within a few lines of a system review prompt call (rewardNearReviewApi). Sign-in / sign-up / deletion signals skip import, require and export-from lines (codeLine), and auth endpoints must be an absolute path, URL or ${base}/..., never a module path; config-only accounts say "from config (accounts.enabled)". A config claim of in-app deletion without code evidence stays a finding (medium, "claimed by config, not found in code"). tests/game-report.test.ts with tests/fixtures/scan/capacitor-game. |
| 34 | 2026-10 | The macOS desktop release ran the universal app's x86_64 slice on the Apple silicon build Mac (arch -x86_64 storebound-desktop --self-test, and the x64 sidecar's version) to cover both CPU types. That starts Rosetta, and macOS showed the owner "App Update Required: this version of storebound includes a component that will not open in macOS 28". |
tools/desktop-release.ts never runs an x86_64 slice: sliceProblems checks every Mach-O in the bundle with lipo -archs (each must carry arm64 and x86_64), the self-test and the sidecar version run natively (arm64) only; tests/desktop-release.test.ts (slice check, and no arch -x86_64 / Rosetta launch in the release tools); docs/desktop-release.md. |
| 33 | 2026-10 | Play Console: pressing "Submit N changes for review" in Publishing overview while a review was running opened a dialog "Do you want to restart your review?" with "Restart review". Pressing it restarts the running review with the new changes; the plan did not know the dialog. | play-production-release plan: the dialog is a known step and UI fact, "Restart review" is in neverPress (the owner's button), Cancel is allowed (closes it, nothing changes); play-first-release and release-management skills explain it. tests/console-play-plans.test.ts. |
| 32 | 2026-10 | play build passed -PversionCode / -PversionName and android.injected.version.*, but a Capacitor app/build.gradle read versionCode from an env var, so the AAB stayed at versionCode 1 (the post-build check caught it). Checked with AGP 8.13: -Pandroid.injected.version.code=42 on versionCode 1 gives an AAB with versionCode 1, so the injected property does not override defaultConfig for bundleRelease. The Groovy line versionCode (project.findProperty('versionCode') ?: 1) as Integer fails ("Value is null": it parses as versionCode(...) as Integer); the cast belongs inside the parentheses. |
versionSourceIssues (cli/lib/gradle.ts) reads defaultConfig of the app module's build.gradle(.kts): a literal, an env var or anything but the project property is a warning in play build (before Gradle, also in the dry run and --json as versionWarnings) and in check, with the exact line (Groovy versionCode((project.findProperty('versionCode') ?: <current>) as Integer), versionName project.findProperty('versionName') ?: '<current>'; Kotlin versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: <current>, versionName = project.findProperty("versionName") as String? ?: "<current>"), both lines built with AGP 8.13; the post-build versionCode check is now a hard stop (the AAB is not copied). tests/build-sign.test.ts, tests/web-assets.test.ts. |
| 31 | 2026-10 | After the owner canceled a WAITING_FOR_REVIEW submission, the app version went DEVELOPER_REJECTED, then PREPARE_FOR_SUBMISSION, and every IAP version stayed DEVELOPER_REJECTED. apple iap-submit --with-version --dry skipped all of them ("latest version is DEVELOPER_REJECTED: change it in App Store Connect (or --new-version)") and said "nothing to submit". Apple takes the same version again: POST /v1/reviewSubmissionItems with that inAppPurchaseVersion → 201 READY_FOR_REVIEW (all 7), then PATCH submitted: true → 200 WAITING_FOR_REVIEW; an empty READY_FOR_REVIEW draft was reused for it. |
DEVELOPER_REJECTED IAP, subscription and subscription group versions are submittable as they are (planIapSubmission; the app version already was): the plan says "add (developer rejected, re-submitted as is)" and apple resubmit's table the same; --new-version only for APPROVED / REJECTED; an empty draft is reused (chooseSubmission). The fake ASC leaves the versions of a canceled submission DEVELOPER_REJECTED, accepts them as items and refuses APPROVED ones. tests/resubmit.test.ts. |
| 30 | 2026-10 | Resubmitting after an Invalid Binary rejection: apple iap-submit --with-version --yes created a second, empty draft next to the rejected submission (UNRESOLVED_ISSUES, holding the REJECTED version and 7 IAP versions) and stopped with "the store says it already exists but does not list it yet (lists lag)". The answer was really 409 ITEM_PART_OF_ANOTHER_SUBMISSION naming the rejected submission; and right after a new build is attached Apple answers the submit with 409 "Version is not ready to be submitted yet". That 409 lasted 25 minutes with a VALID, APP_STORE_ELIGIBLE build attached and the console's Resubmit / Add for Review disabled: the appStoreVersion item was still REJECTED. PATCH /v1/reviewSubmissionItems/{id} {"data":{"type":"reviewSubmissionItems","id":...,"attributes":{"resolved":true}}} → 200, item READY_FOR_REVIEW, then the submit passed. An empty READY_FOR_REVIEW draft cannot be canceled (409 "Resource is not in cancellable state"). |
chooseSubmission lists the open submissions (READY_FOR_REVIEW, UNRESOLVED_ISSUES, WAITING_FOR_REVIEW, IN_REVIEW) and reuses one, never creating a second; isConflict excludes the code and addItem names the other submission, a draft the run created is left as is (not cancellable, harmless) and the named one used; REJECTED items are marked resolved before the submit (resolveRejected: the version item in apple resubmit, IAP / subscription items with --resolve-iaps); submitWithRetry re-reads the items on "not ready", stops at once naming a REJECTED item out of scope and how to resolve it, and backs off (15 s, 30 s, 60 s, 2 min, heartbeat, 10 min limit) only when none is left; apple status labels an empty draft harmless and treats its cancel 409 as such; new apple resubmit --build <n> (build by number, plan table, --yes); apple status shows the UNRESOLVED_ISSUES items and the command. tests/resubmit.test.ts. |
| 29 | 2026-10 | Apple refused build 1 as Invalid Binary one minute after submission: "ITMS-91064: Invalid tracking information - A PrivacyInfo.xcprivacy file contains invalid tracking information at the following path: "PrivacyInfo.xcprivacy". NSPrivacyTracking must be true if NSPrivacyTrackingDomains isn't empty. Keys and values in your app's privacy manifests must be valid." The app's own manifest had NSPrivacyTracking true with an EMPTY NSPrivacyTrackingDomains, as storebound recommended from appPrivacy.tracking: true although the tracking came from an ad SDK (LevelPlay) whose own manifest declared it. Apple's wording names the other direction (domains listed, tracking false); both are invalid. After the fix (app manifest false, App Privacy still "used for tracking") check wrongly demanded the app manifest say true. |
Rule (trackingProblem / trackingIssue / itms91064 in cli/lib/privacy-manifest.ts): a manifest's NSPrivacyTracking and NSPrivacyTrackingDomains agree, in the app's and every SDK's; check, scan (blocker) and privacy-manifest (blocker) fail on either mismatch with the exact ITMS text and never suggest true without domains. App Privacy tracking no longer needs the app manifest's true: check looks for SDK manifests (Pods, frameworks, SwiftPM checkouts, Carthage, Capacitor plugins, the last built IPA; cli/lib/privacy-files.ts) that declare tracking (info), known tracking SDKs in the dependencies (info), else warns. privacy-manifest --propose and the scan proposal write true only with appPrivacy.trackingDomains (the app's own domains), else false. apple upload reads every PrivacyInfo.xcprivacy in the IPA (app root, Frameworks, bundles; XML or binary) and stops before any network call; apple build checks the exported IPA. tests/privacy-tracking.test.ts. |
| 28 | 2026-10 | First App Store release with IAPs: the console route failed an agent (the IAP page's "Add for Review" menu ignored scripted clicks; the version page no longer has an "In-App Purchases and Subscriptions" section), while apple iap-submit --with-version --yes added the IAPs to the draft submission that held 1.0.0 and submitted them first try. App Privacy's Publish opens a "Publish Your App Privacy Responses?" dialog and leaves a "Published … ago by …" line. |
appstore-first-release and store-iap-setup point to apple iap-submit --with-version (the owner's yes: it submits), never the console. console verify apple-app-privacy reads the "Published …" line as the published signal; the plan names the dialog. tests/console-play-plans.test.ts. |
| 27 | 2026-10 | A real Play first release (an agent with a browser tool) found the steps no API does: the App category stayed "Not selected" (play listing cannot set it: edits.details has no category field); production had 0 countries (edits.countryAvailability is read-only); App content had ten cards with their own pages; the IARC questionnaire is one long page whose Yes answers reveal follow-ups, with an IARC Terms of Use agreement on page 1 and a final "Save" (not "Submit") on the Summary; Play's material checkboxes, like its radios, ignore native input; long evaluate calls timed out at 45 s; "Send changes for review" ignored a JS click; managed publishing is off by default, so approved means live. |
Plans play-store-settings, play-app-content (+ one per card), play-production-countries, play-production-release; config play.category (Play's lists), play.countries, play.managedPublishing, console.contentRating.details, console.appContent, social, products[].randomRewards; every ask-owner answer in one batch; check warns without a category and counts the open App content answers; validate reports the category reminder and "production has 0 countries"; play status and play release say what is console-only; the IARC Terms box is never-press; material checkboxes get the inner [role=checkbox] click (plan and console fill); every plan ships inPageLoop. tests/console-play-plans.test.ts. |
| 26 | 2026-10 | An agent using storebound spent hours handing the owner one click, command or answer at a time ("press Next", "download the CSV template", "now run the upload", a compliance question in the middle of a run): skills said "let the owner do it" / "by hand" for navigation, exports and uploads. | The first rule "Do it, don't ask" heads docs/agents-core.md (every generated adapter), README and the store-pitfalls skill: the agent does API calls, uploads, builds, exports, console navigation and forms itself; the owner presses only the final public or irreversible buttons and answers the facts no file proves in one batch at the start. tests/do-it-dont-ask.test.ts checks the rule is there and fails on skill text that hands navigation, draft saves, exports, uploads or form filling to the owner. |
| 25 | 2026-10 | In a non-interactive shell play build stopped in Gradle with "SDK location not found" (no ANDROID_HOME there, local.properties gitignored) although the SDK was in ~/Library/Android/sdk; apple upload --no-wait --yes sent the IPA for over 10 minutes without a line (progress every 10th part only, no per-part or per-call timeout). |
resolveAndroidSdk (ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir in local.properties, the Android Studio default folder; only folders with platforms/ or platform-tools/) hands the SDK to Gradle in the environment, never writes local.properties, stops before Gradle with the places checked; doctor shows it (cli/lib/tools.ts, cli/play/build.ts, cli/doctor.ts). apple upload prints the size, a line per part and a 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, --timeout exits 7 with the resume hint (sendParts in cli/apple/upload.ts, cli/lib/progress.ts; tests/tools-path.test.ts, tests/build-sign.test.ts, tests/upload-progress.test.ts). |
| 24 | 2026-10 | flow run release --apply --yes ran apple upload --no-wait, which printed "not a terminal: re-run with --yes to confirm" and declined, yet the flow logged "4/13 upload: ok" and went on to apple testflight wait for a build that was never uploaded: a declined confirmation exited 0, and the flow's --yes never reached the steps. |
confirm() (cli/lib/ctx.ts): every decline (answered no, not a terminal without --yes, under serve) prints declined: <reason> and exits 3 (DECLINED_EXIT). flow run reports "step N declined: " (status declined) and stops; --apply --yes adds --yes to write steps only (planStep, declinedReason; tests/flow.test.ts). |
| 23 | 2026-10 | Capacitor: native build ships old web assets. flow run release --apply went straight into apple build (xcodebuild archive) with ios/App/App/public and android/app/src/main/assets/public 3.5 hours older than the latest source commit: the native projects hold the copy npx cap sync made last, and nothing ran the web build + cap sync first, so the old game would have shipped. |
New build.prebuild (per store apple.prebuild / play.prebuild): apple build / play build run it first (printed, streamed, nonzero exit stops; --dry only prints; --no-prebuild; once per flow run through STOREBOUND_PREBUILD_DONE). After it, a Capacitor project's copied public/index.html must be newer than the last commit outside ios/ android/ node_modules/ store/ .storebound/ and <webDir>/index.html, else the build stops ("web assets are older than the source ..."; --allow-stale-web overrides). check warns when stale without a prebuild; build output and dry plan show the bundle's sha256 + mtime. Monorepo (apps/native with its own capacitor.config.ts, android/, ios/; play build failed with "no Gradle project ... in android or ."): the one Capacitor project one or two levels down is found and named, several ask for play.build.dir / apple.build.* (cli/lib/web-assets.ts, tests/web-assets.test.ts). |
| 22 | 2026-10 | play build built a good AAB (Capacitor 8 / AGP), then failed its own check with "AAB manifest without package / versionCode". The aapt2 proto manifest was read with element field numbers one too high (name 4, attribute 5, child 6 instead of Resources.proto's 3, 4, 5). The test fixture came from the same wrong encoder, so the tests passed. |
aabInfo / encodeProtoManifest in cli/lib/gradle.ts follow Resources.proto. tests/build-sign.test.ts builds the manifest bytes by tag, without the encoder, and the parser was checked against real AGP bundles. Lesson: a parser's fixture must not come from the same code's encoder. |
| 21 | 2026-10 | Play Data safety: conditional required rows. play data-safety for a game with username accounts (accountCreation: ['PSL_ACM_USER_ID_OTHER_AUTH']) got 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL": once account creation is not PSL_ACM_NONE, Play requires the delete-account URL and the data-only deletion question (PSL_SUPPORT_DATA_DELETION_BY_USER, and PSL_DATA_DELETION_URL when it is yes), and refuses PSL_HAS_OUTSIDE_APP_ACCOUNTS; storebound wrote none of them. Later (2026-10-12) a game got 400 "Line 7: Response ID missing" for PSL_SUPPORT_DATA_DELETION_BY_USER,,FALSE: the current form asks it as a single choice (DATA_DELETION_YES / DATA_DELETION_NO / DATA_DELETION_NO_AUTO_DELETED) and no longer has PSL_DATA_COLLECTION_USER_REQUEST_DELETE; the CSV must use the current form's Response IDs. |
dataSafetyRows writes them from accounts.deletionUrl and dataSafety.partialDeletion / partialDeletionUrl; DATA_SAFETY_RULES / typeRules / dataSafetyIssues in cli/lib/data-safety.ts check every conditional row in check, project.declarations and play data-safety (dry and real, before the POST); tests/pure.test.ts (conditional required rows), tests/idempotency.test.ts, tests/app-privacy.test.ts. The single choice is written one row per option (`partialDeletion: true |
| 20 | 2026-10 | Hours of selector-fix cycles of the scripted console engine (console fill): the real console kept differing from the model (App Privacy "Set Up" as plain text, purpose checkboxes without an accessible name, two information steps in a type dialog, Play's material radios that ignore the native input). |
Agent-first console mode: console plan <form> gives an AI agent the start URL, steps, answer sheet with the console's labels, never-press list and verified UI facts; the agent drives the page with its own browser tool and stops before the final button; console verify <form> checks the result (App Privacy summaries from the page text, Play Create app from input states, API for the App Store record and age rating). The scripted fill stays a fast path (and now handles the verified facts). MCP: console_plan, console_verify (read-only). tests/console-plan.test.ts, tests/console-flow.test.ts. |
| 19 | 2026-10 | play images --dry aborted with "en-US: no sevenInchScreenshots ... (render first)" on a phone-only project: Play's 7" and 10" tablet screenshots are optional (required: 512 icon, 1024x500 feature graphic, at least 2 screenshots; Play Console Help answer 9866151), but every image type was treated as required. |
play images uploads what exists (icon, feature graphic, phone screenshots, tablet / TV / Wear only when rendered), prints an info line for missing optional sets, never deletes what Play has for them without --prune; play.tablets (default: tablet renders exist) switches tablets for shots render / shots check / shots gallery / validate / play images, and true with missing renders is an error; then a 404 "Listing for language 'de-DE' not found." for languages still status=draft: play images takes the languages play listing pushes and skips one with no Play listing as info (noListing) (cli/play/images.ts langImages, resolvedTablets in cli/shots/report.ts, tests/play-post-reads.test.ts). |
| 18 | 2026-10 | console fill play-app-record with a copy of the owner's Chrome cookies reached the Play account chooser once, then Google answered /v3/signin/rejected and the chooser: Google session cookies are device-bound (Chrome's Device Bound Session Credentials) and Google signs out sessions whose cookies look stolen, which can also sign the owner out of their real Chrome. |
Never copy Google cookies by default: storebound's own profile is the default (the owner signs in once by hand), started by storebound as a normal Chrome (no --enable-automation, navigator.webdriver false) and attached over CDP; --use-chrome-session google|apple is an explicit opt-in that warns; /signin/rejected is said plainly (cli/console/session.ts, tests/console-session.test.ts). Confirmed 2026-10-12: the earlier default copy DID sign the owner out of Play Console in their real Chrome. |
| 17 | 2026-10 | After the data types were saved, the real App Privacy page shows each type's "Set Up " as a plain <p> in a div with a click handler (no button role, no link) and puts the types in a box after the Data Types heading's box: role-based locators and the "nearest heading" check found nothing, so no type was set up. A "?" (More information) button sits next to each type. |
setUpAllowed / setUpTexts (cli/lib/console-forms.ts: exact text "Set Up " of a type storebound answers, in the Data Types section, outside dialogs) and setUpClick / setUpType / flowWhere (last heading before the control) in cli/console/fill.ts; the per-type dialog steps are page sections, so an unknown step dumps and pauses instead of ending the run; tests/fixtures/console/apple-app-privacy-setup.html (structure of the real page) and tests/console-flow.test.ts. |
| 16 | 2026-10 | console fill play-app-record with the copied Chrome cookies printed only "still waiting for the sign-in" for 5+ minutes on a signed-in Play Console: Google pages embed accounts.google.com frames (the account widget, cookie rotation), and the detector counted any frame on a sign-in host. |
signInReason: a sign-in page only by its top-level URL, a visible password / email-entry form in the main frame, or a visible Apple sign-in frame (idmsa / appleid); other frames never count. Each detection logs "sign-in detected because: at "; heartbeats name the page; a copied session that lands on a sign-in URL is said plainly ("did not carry over"); the watchdog dumps the sign-in wait once after --step-timeout, --debug-dump up to 3 times (tests/console-fill.test.ts, chooser / home with the widget frames, a top-level accounts.google.com page). |
| 15 | 2026-10 | The real App Privacy "Data Collection" dialog names each data type checkbox by its label run together with its help text ("NameIncluding first or last name"), and labels some types differently from Apple's help page ("Purchases", "Other Data"): exact-name matching found none. | labelNames (exact name, then the label as a prefix that ends where the help text starts) and TYPE_ALIASES in cli/lib/console-forms.ts; the flow fixture mirrors the real dialog's structure (tests/console-flow.test.ts). |
| 14 | 2026-10 | console fill apple-app-privacy clicked Next onto a real console page it did not recognise and then waited 8 hours without a single log line. |
Every wait of the console engine is bounded: a step watchdog (--step-timeout, 30 s) writes a sanitized debug dump to .storebound/console-debug/ and prints the page's buttons and the manual steps; a line a minute while waiting; no progress for 20 min stops the run with exit 1; --debug-dump dumps every step (cli/console/debug.ts, tests/console-flow.test.ts, which also scans the engine for waits without a deadline). |
| 13 | 2026-10 | App Privacy purposes were over-mapped: Play "advertising" became both Third-Party Advertising and Developer's Advertising or Marketing (Device ID, Coarse Location, Product Interaction, Performance Data). | applePurpose maps one Play purpose to one Apple purpose (advertising: third-party only); Developer's Advertising only from appPrivacy.types; check compares appPrivacy.types with Info.plist / PrivacyInfo.xcprivacy (tests/app-privacy.test.ts). |
| 12 | 2026-10 | A new Play app has no API: Play Console's "Create app" is the only way, its page-level button carries the same words as the form's final submit, and Free cannot become Paid later. | console fill play-app-record: createAppAllowed lets the opener through only on the app list, never in a form or dialog (EN + TR); declarations never ticked; Free / Paid outlined as an owner check (tests/console-fill.test.ts). Verified 2026-10-12: the form is at /developers/<id>/create-new-app and has an App package name field with a read-only "Check availability" (filled from play.packageName; the package name no longer comes from the first AAB upload); App or game / Free or paid are <material-radio>s whose model only updates on a click of the inner [role=radio] (the engine clicks it; console verify play-app-record checks aria-checked and the "Select …" hints). After Create app the owner adds the new app to the service account (app-level permissions do not cover later apps). |
| 11 | 2026-10 | Google Play has no public search API, so there is no Play keyword rank or Play competitor list to read without scraping. | aso reads App Store search only (iTunes Search API); aso audit covers Play through the listing text (short description, density); the aso-basics skill says so. |
| 10 | 2026-10 | The iTunes Search API order is close to, not equal to, App Store search (no personalisation, no search ads, no in-app event rows) and it has no subtitle field. | aso rank / aso competitors / aso audit label ranks as a trend and show no competitor subtitles; popularity only from Apple Ads, never estimated (cli/lib/aso-intel.ts). |
| 9 | 2026-10 | A retried or repeated write can duplicate on a live store: a POST create retried after a timeout or 5xx, a re-run during the list lag right after a create, two runs at once, a Play edit redone after an unclear commit, a crash in the middle of a screenshot set. | Every write is an upsert (cli/lib/upsert.ts: findOrCreate with a fresh re-check and a journal of recent creates, createOnce treating 409 as found, no blind retry of a create); API clients retry only requests that are safe twice; Play edits are all or nothing; asset sets keep the matching prefix; one lock per app and store (cli/lib/lock.ts, --force-unlock). Table: docs/idempotency.md; tests/idempotency.test.ts runs every write twice and asserts zero writes on the second run. |
| 8 | 2026-10 | Capacitor plugins in a monorepo live in apps/native/node_modules, so scan missed them. |
scan / privacy-manifest resolve workspace packages (cli/lib/scan/project.ts, cli/lib/node-packages.ts). |
| 7 | 2026-10 | Console UIs: the App Store Connect sign-in runs in an idmsa.apple.com iframe; "User Access" is a plain heading, not a labeled group; Chrome drops session cookies without --restore-last-session. |
console session and fill (cli/console/), tests/console-*.test.ts. |
| 6 | 2026-10 | Screenshot fonts: only part of the glyphs a language needs were available, so Turkish / Polish / Vietnamese characters fell back to another font silently. | shots fonts / shots check check every language's characters against the fonts (tests/glyphs.test.ts). |
| 5 | 2026-10 | Account features do not imply email collection; Data safety said "email collected" without evidence. | scan marks email only with code evidence, else "ask owner" (cli/lib/scan/declare.ts). |
| 4 | 2026-10 | (Superseded by 43: the limit counts characters.) The App Store keyword limit (100) was taken to count UTF-8 bytes. | See 43. |
| 3 | 2026-10 | App Store app names are unique per storefront: "already being used" although the developer owns no such app. | apple.name (App Store name with a suffix the owner picks); app.name stays the brand. |
| 2 | 2026-10 | Dry runs that print no plan ("listing: 20 locales") break "show the dry run to the owner". | cli/lib/dry.ts dryPlan(title, rows); tests/pitfalls.test.ts scans cli/** for --dry branches that print only a count (allowlist with TODOs for commands being fixed). |
| 1 | 2026-10 | App Store Connect's /inAppPurchasesV2 list lags right after apple iap creates a product; apple iap-review then skipped it silently. |
apple iap-review names config products missing from the store's list and exits 1; apple iap and play products re-read the list after creating and warn (cli/lib/store-lag.ts, tests/pitfalls.test.ts). |
Adding an entry
- A row above (next number, month, one-sentence lesson, the guard with file or test names).
- A section in
skills/store-pitfalls/SKILL.md: symptom → cause → what to do → which command guards it. Thenbun tools/gen-adapters.ts. - Where the code can know it: a check (
check,validate,scan, a warning in the command) and a test with recorded store answers that fails without the fix.