storebound/skills/store-pitfalls/SKILL.md

92 KiB


name: store-pitfalls description: Known pitfalls with App Store Connect, Google Play and storebound itself, learned from real releases, and what to do about each - first: do the work, don't ask the owner for every step; store lists that lag right after a create, dry runs that print a count instead of a plan, App Store names taken on a storefront, keyword limits in characters (not bytes), Data safety answers without code evidence, missing font glyphs in screenshots, console sign-in and sessions, Capacitor monorepo plugins, duplicates from retries and re-runs, search ranks that are only a trend, no Play search API, Play's Create app form (no API, opener and submit share a name, Free is final), App Privacy purposes over-mapped (Play advertising is Third-Party Advertising only), an unknown console page, a signed-in console read as a sign-in page, console forms driven agent-first, stale Capacitor web assets, a declined flow step logged ok. Read it before any store task, and when a store command does something you did not expect.

Store pitfalls

Each entry: symptom → cause → what to do → what guards it now. The full changelog of lessons with dates is docs/pitfalls.md in the storebound repository. When you hit a new one, tell the owner and add it there (and here).

First: do it, don't ask (the costliest pitfall)

You and storebound do everything: API calls, uploads, builds, CSV / template exports, every console navigation (open, Get Started, Edit, Next, Continue, Save draft), console forms, verification. Never hand the owner "run this" or "click X". The owner presses only the final public or irreversible buttons (Submit for review, release or a production rollout, App Privacy Publish, legal agreements; deleting store data, live prices, review replies, testers) and answers, in ONE batch at the start, only the facts no file proves (compliance answers that check / validate list). Loop: plan, drive, verify, continue. Full rule: docs/agents-core.md (AGENTS.md); lesson 26.

1. A product you just created is "not there"

  • Symptom: apple iap-review uploads nothing for a product apple iap created a minute ago; a list looks short.
  • Cause: App Store Connect's /inAppPurchasesV2 list (and Play's product list) can lag for a short while after a create. A command that walks the list skips what is not in it yet.
  • Do: wait a minute and re-run. Never assume a product was handled because the command finished.
  • Guard: apple iap-review compares the config's products with the store's list, names the missing ones ("the store's list can lag a minute after creating; re-run") and exits 1. apple iap and play products read the list again after creating and warn about any new product that is not in it yet; their dry runs name products that are not on the store.

2. A dry run that only prints a count

  • Symptom: --dry says "listing: 20 locales" or "3 change(s)", but not which fields would change to what.
  • Cause: the command logged a summary instead of the plan.
  • Do: never ask the owner to approve a count. Show the rows that change (item, action, old → new). If a command only prints a count, say so and show the owner the diff from pull / --json output instead.
  • Guard: cli/lib/dry.ts dryPlan(title, rows) prints the standard plan table (apple iap, apple iap-review, play products use it); tests/pitfalls.test.ts scans cli/** for --dry branches that print nothing but a count.

3. "The app name you entered is already being used"

  • Symptom: creating the App Store record or saving the name fails although you own no app with that name.
  • Cause: App Store app names are unique per storefront, across all developers.
  • Do: ask the owner for a variant (a short suffix such as "Example Notes: Lists") and set apple.name in the config; keep app.name for Play and the brand. Do not pick the suffix yourself.
  • Guard: apple.name overrides the App Store name.

4. Keyword field length (superseded by 43)

  • The limit is 100 characters, not UTF-8 bytes (see 43). Count characters; drop whole words, not commas.

5. Data safety says "email collected" for an app that never asks for one

  • Symptom: the proposal marks email (or phone) as collected because the app has accounts.
  • Cause: account features do not imply email collection (anonymous ids, Game Center, Play Games sign-in).
  • Do: answer only what the code proves; ask the owner for the rest (rule 5).
  • Guard: storebound scan marks email only with evidence (an email field, an email sign-up call) and otherwise says "ask owner".

6. Boxes or wrong glyphs in localized screenshots

  • Symptom: Turkish, Polish or Vietnamese captions show fallback glyphs (ş, ğ, ł, ệ) while the English ones are fine.
  • Cause: the font (or the Google Fonts subset that was downloaded) does not cover those characters, and the browser silently draws them with another font.
  • Do: check every language's characters before rendering, pick a font that covers them or let the fallback fill in.
  • Guard: storebound shots fonts / shots check check each language's characters against the fonts' glyphs.

7. Console automation stops at sign-in or loses the session

  • Symptom: console fill waits on a sign-in page, cannot find fields, or is signed out on the next run.
  • Cause: the App Store Connect sign-in runs inside an idmsa.apple.com iframe; "User Access" is a plain heading, not a labeled group; Chrome drops session cookies unless the last session is restored.
  • Do: let the owner sign in in the opened window (never type credentials); re-run after the sign-in.
  • Guard: the console session handles the iframe and the plain heading and starts Chrome with --restore-last-session; a partial miss fills every field found and leaves only the missing ones to the owner.

8. scan misses Capacitor plugins in a monorepo

  • Symptom: SDKs or privacy manifests of Capacitor plugins are not found.
  • Cause: the plugins live in apps/native/node_modules (a workspace package), not in the root node_modules.
  • Do: run scan from the project root; check that the stack line lists the plugins.
  • Guard: storebound scan and privacy-manifest resolve packages through workspace folders too.

9. A second run (or a retry) creates a duplicate

  • Symptom: two in-app purchases or subscription groups with the same name, a screenshot twice in a set, an offer code campaign or a custom product page twice, after a timeout, a crash, a re-run a minute later, or two runs at once.
  • Cause: a POST create retried after a timeout or a 5xx may have been applied the first time; the store's lists lag right after a create, so a re-run does not see what the last run made; two writers race; a Play edit redone after a commit whose answer was lost.
  • Do: just run the command again: every write command is an upsert and converges (docs/idempotency.md). Never retry a create by hand (storebound api) without a GET first. If a run says another storebound run holds the lock, wait for it; use --force-unlock only when that run is really gone. Never run two write commands for the same app in parallel.
  • Guard: cli/lib/upsert.ts (findOrCreate: fresh re-check + journal .storebound/created.json; createOnce: 409 = found, an unclear answer is looked up, never re-sent blindly), retries only for requests that are safe twice, Play edits deleted on every error and never redone after an unclear commit, screenshot / preview sets keep the matching prefix in order, .storebound/locks/ per app and store. tests/idempotency.test.ts.

10. "We rank #4 for this keyword" (from the Search API)

  • Symptom: aso rank says #4, the owner's phone shows the app lower (or higher) in App Store search.
  • Cause: the iTunes Search API order is close to, not equal to, App Store search: no personalisation, no search ads, no in-app event rows. It has no subtitle field either.
  • Do: read ranks as a trend over weeks (aso audit arrows), never as a promise; never claim a competitor's subtitle (no public API has it); a popularity number exists only from Apple Ads, never estimate one.
  • Guard: aso rank, aso competitors and aso audit say it in their output; aso suggest shows popularity "unknown" without Apple Ads credentials.

11. "Check our Google Play keyword ranks"

  • Symptom: the owner asks for Play ranks or Play competitors per keyword.
  • Cause: Google Play has no public search API; reading Play search pages would be scraping.
  • Do: say so. Measure Play through the Play Console acquisition reports (search terms) and analytics snapshot (store listing visitors, acquisitions); improve the listing text with aso audit (short description, density).
  • Guard: aso covers App Store search only; the aso-basics skill states the limit.

12. A new Play app: "Create app" is both the opener and the submit

  • Symptom: an agent cannot create the Play app through the API, or clicks "Create app" without knowing whether it opens the form or submits it; or an app goes out as Free and the owner later wants it paid.
  • Cause: the Play Developer API cannot create apps. Play Console's Home has a "Create app" button that opens the form, and the form's final button has the same words. Free cannot become Paid later on Google Play. The form (/console/u/0/developers/<developerId>/create-new-app, verified 2026-10-12) has an App package name field (max 150) with a read-only "Check availability" button ("Package name available"); the package name is fixed for good. App or game and Free or paid are <material-radio>s: setting the native input leaves the form saying "Select app or game" until the inner [role=radio] is clicked (a JS click works in a background window).
  • Do: storebound console plan play-app-record and drive the form with your browser tool (or the scripted console fill play-app-record); fill the package name and press Check availability; click the inner [role=radio]s and check that no "Select …" hint is left (console verify play-app-record); tell the owner that Free is final; the owner ticks the declarations and presses Create app. Afterwards the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (set play.appId). The service account's app-level permissions do NOT cover the new app: the OWNER adds it (Users and permissions > the service account > Add app > Admin, or the minimal set > Apply > Save changes; never an agent: it is a permission grant), then storebound apps check --store play.
  • Guard: createAppAllowed lets the opener through only on the app list, never inside a form or dialog (English and Turkish); agreement checkboxes are never ticked; Free or paid is outlined as an owner check.

13. App Privacy purposes over-mapped

  • Symptom: App Privacy says Developer's Advertising or Marketing for Device ID, Coarse Location, Product Interaction or Performance Data, although the app only shows an ad SDK's ads.
  • Cause: a Play purpose was turned into two Apple purposes ("advertising" -> Third-Party Advertising AND Developer's Advertising or Marketing). Apple's definitions (developer.apple.com/app-store/app-privacy-details/): third-party ads are Third-Party Advertising; Developer's Advertising is first-party ads or marketing to your users.
  • Do: one Apple purpose per Play purpose; put the owner's real answers in appPrivacy.types (Apple type ids, purposes, linked, tracking) and let storebound check compare them with Info.plist and PrivacyInfo.xcprivacy.
  • Guard: applePurpose (cli/lib/declarations.ts), appPrivacy.types validation and appPrivacyMismatches (cli/lib/app-privacy.ts), tests/app-privacy.test.ts.

14. An unknown console page: bounded wait and a debug dump

  • Symptom: console fill clicked Next (or opened the form) and then nothing happened in the log for a long time.
  • Cause: the console showed a page storebound does not recognise (a changed layout, a new question): no field it can fill and no button it may click.
  • Do: read the warning: it names the page ("the page after "Next""), its buttons, and where the debug dump is. Drive that part yourself with your browser tool from storebound console plan <form> (agent-first), then go on. To report it, re-run with --debug-dump and attach the folder .storebound/console-debug/<form>-<time>/ after the owner looked at screen.jpg (the screenshot shows what the window showed). page.json / page.html hold labels and structure only: no values, cookies or tokens; emails, phone numbers and the account area are replaced.
  • Guard: the watchdog in fillSession (--step-timeout, default 30 s; a heartbeat line a minute; 20 min without progress ends the run with exit 1), cli/console/debug.ts, tests/browser/console-flow.browser.ts (unknown-page fixture and a scan for waits without a deadline).

15. App Privacy data types not found in the real dialog

  • Symptom: after Get Started and Next, console fill apple-app-privacy finds no data type checkbox.
  • Cause: the real "Data Collection" dialog names each checkbox by its label run together with its help text ("NameIncluding first or last name"), and some labels differ from Apple's help page ("Purchases", "Other Data").
  • Do: nothing by hand: storebound matches the label as a prefix. If the console changes again, the watchdog (pitfall 14) writes a debug dump: re-run with --debug-dump and attach it.
  • Guard: labelNames and TYPE_ALIASES (cli/lib/console-forms.ts), tests/browser/console-flow.browser.ts.

16. "Still waiting for the sign-in" on a console that is signed in

  • Symptom: console fill (often with the copied Chrome cookies) prints only "still waiting for the sign-in" while the window shows Play Console (the account chooser, the app list).
  • Cause: Google pages embed accounts.google.com / ogs.google.com frames (the account widget, cookie rotation) while the owner is signed in; an older detector counted any frame on a sign-in host as a sign-in page.
  • Do: read the line "sign-in detected because: at " and the heartbeats (they name the page). A top-level accounts.google.com / idmsa.apple.com / App Store Connect /login URL right after the launch means the copied Chrome session did not carry over (expired or tied to that device): sign in once in the window, or run without --use-chrome-session (pitfall 18). Anything else: re-run with --debug-dump (up to 3 dumps of the sign-in wait) and attach the folder after the owner looked at screen.jpg.
  • Guard: signInReason in cli/console/fill.ts (top-level URL, the main frame's password / email-entry form, a visible Apple sign-in frame; no other frame counts), the sign-in watchdog dump, tests/browser/console-fill.browser.ts.

17. App Privacy data types listed but never set up

  • Symptom: after the data types were saved, console fill apple-app-privacy stops on the App Privacy page with every type showing "Set Up " and nothing clicked.
  • Cause: the real page shows "Set Up " as plain text (a <p> in a div with a click handler: no button, no link) in a box after the Data Types heading, so role-based lookups miss it.
  • Do: nothing by hand: storebound clicks each "Set Up " by its exact text (only for types it answers, never the "?" help buttons or Publish) and fills that type's dialog step by step. A dialog step it does not know: it writes a debug dump, says paused on "<type>", and goes on once the owner answered that step.
  • Guard: setUpAllowed (cli/lib/console-forms.ts), setUpClick / setUpType (cli/console/fill.ts), tests/browser/console-flow.browser.ts with tests/fixtures/console/apple-app-privacy-setup.html.

18. Never copy Google session cookies: they are device-bound

  • Symptom: console fill play-app-record with the owner's copied Chrome cookies reached the Play account chooser once (22:32); between runs Google showed accounts.google.com/v3/signin/rejected; from 23:12 the same copy landed on the account chooser.
  • Cause: Google binds its session cookies to the device. Chrome's Device Bound Session Credentials keep a key in the TPM / Secure Enclave that cannot leave the machine; Google only renews the short-lived cookies for the browser that proves it holds that key (public on Windows from Chrome 146, rolling out on macOS from Chrome 147). Google also signs out sessions whose cookies look stolen ("logged out of their account for that session and any related suspicious sessions on that device", then an extra verification). Copied cookies in a second Chrome look exactly like cookie theft: the copy is refused, and the owner's REAL Chrome session can be signed out or the account asked to verify again.
  • Do: never copy Google cookies. The default is storebound's own profile (~/.storebound/console-profile/<store>): the owner signs in there once, by hand; it is kept. --use-chrome-session google exists only as an explicit owner opt-in that prints the warning; an agent never adds it. Apple publishes no such binding, but --use-chrome-session apple is opt-in too. If Google says "Couldn't sign you in" in storebound's window, storebound prints "Google refused sign-in in this window (/signin/rejected)": try again, or sign in at accounts.google.com first in that window.
  • Confirmed (2026-10-12): the earlier default copy of the Google cookies DID sign the owner out of Play Console in their real Chrome.
  • Guard: browserPlan / openConsoleBrowser in cli/console/session.ts (no copy without the flag, the copy keeps only the form's own provider), Chrome started by storebound without --enable-automation and with navigator.webdriver false, googleRejected; tests/browser/console-session.browser.ts.
  • Sources: https://blog.google/security/protecting-cookies-with-device-bound-session-credentials/ , https://developer.chrome.com/docs/web-platform/device-bound-session-credentials , https://knowledge.workspace.google.com/admin/security/investigate-and-take-action-on-suspicious-session-cookies

19. play images aborts on missing tablet screenshots

  • Symptom: storebound play images --dry on a phone-only project (renders with feature, ios65, ios69 and play, no tab7 / tab10) stopped with en-US: no sevenInchScreenshots in .../store/renders/en (render first: storebound shots render).
  • Cause: every Play image type was treated as required. Play requires only the 512 x 512 icon, the 1024 x 500 feature graphic and at least 2 screenshots (Play Console Help, "Add preview assets to showcase your app", answer 9866151); 7" / 10" tablet, TV and Wear screenshots are optional (without tablet screenshots the app is not eligible for tablet featuring).
  • Do: nothing special: play images uploads what exists and prints tablet screenshots: none (optional; ...). To render and require tablets set play.tablets: true; to never ask for them, play.tablets: false (default: true when tablet renders exist). A missing optional set never deletes the tablet screenshots Play already has; the dry run shows keeps sevenInchScreenshots on Play, and only --prune (owner's call) deletes them.
  • Also: the next abort was GET .../listings/de-DE/icon -> 404 "Listing for language 'de-DE' not found.": 20 languages were still status=draft, so play listing had (rightly) not created them. play images now takes the same languages as play listing (drafts skipped unless --include-drafts) and skips a language with no Play listing (skipped de-DE: no Play listing yet), never failing on it. Run play listing before play images.
  • Guard: langImages / missingChange in cli/play/images.ts, resolvedTablets in cli/shots/report.ts; tests/play-post-reads.test.ts (phone-only fixture: dry plan, info line, exit 0; tablets: true without renders is an error; no tablet DELETE without --prune).

20. Drive consoles agent-first; the scripted fill is a fast path

  • Symptom: hours went into fix cycles of the scripted engine's selectors (console fill) while the console UI kept differing from the model (App Privacy's "Set Up" as plain text, unlabeled purpose checkboxes, two information steps, Play's material radios), each one a stop until a new release.
  • Cause: a script only knows the page it was written for. An AI agent with a browser tool reads the page as it is and adapts.
  • Do: run storebound console plan <form> --json and drive the page yourself (Claude in Chrome, a built-in browser, Playwright) from its answer sheet, never-press list and UI facts; long flows as an in-page async loop that writes window.__log (one evaluate call times out around 45 s); stop before the final button and tell the owner what to press; then storebound console verify <form>. console fill is the optional scripted fast path; when it stalls, switch to the plan instead of waiting for a selector fix.
  • Guard: console plan / console verify (CLI and the read-only MCP tools console_plan, console_verify), the console-autofill skill puts them first; tests/console-plan.test.ts.

21. Play Data safety: conditional required rows

  • Symptom: play data-safety answers 400 "Invalid safety labels declaration: Response missing for PSL_ACCOUNT_DELETION_URL" (or another PSL_... id) although --dry looked fine.
  • Cause: some questions are required only after another answer. Account creation not PSL_ACM_NONE requires the delete-account URL (PSL_ACCOUNT_DELETION_URL) and the data-only deletion question (PSL_SUPPORT_DATA_DELETION_BY_USER; when yes, PSL_DATA_DELETION_URL), and Play then refuses PSL_HAS_OUTSIDE_APP_ACCOUNTS. A selected data type needs its whole usage block. The CSV must also use the current form's Response IDs: PSL_SUPPORT_DATA_DELETION_BY_USER became a single choice (DATA_DELETION_YES / DATA_DELETION_NO / DATA_DELETION_NO_AUTO_DELETED) and a bare FALSE got 400 "Line 7: Response ID missing"; PSL_DATA_COLLECTION_USER_REQUEST_DELETE was removed from the form.
  • Do: set accounts.deletionUrl (it also answers PSL_SUPPORT_DATA_DELETION_BY_USER with yes: that is the general "can users request deletion?" question, not "without deleting the account"; a no next to a delete-account URL was a false declaration storebound wrote once), set partialDeletionUrl only for a data-only page, run storebound check / play data-safety --dry until no missing: <id> is left. If Play names an id storebound does not know, export the form (Play Console > App content > Data safety > Export to CSV) and add the row in dataSafety.extra (same id = replaces the generated row). When Play changes the form, export it empty and save it over cli/data/play-data-safety-form.csv.
  • Guard: DATA_SAFETY_RULES / typeRules / dataSafetyIssues in cli/lib/data-safety.ts; check, project.declarations and play data-safety (dry and real, before the POST) report them. storebound checks every (Question ID, Response ID) pair, dataSafety.extra included, against Google's exported empty form (cli/data/play-data-safety-form.csv, 2026-10-12; formPairIssues) and takes each Answer requirement from it; tests/pure.test.ts (pairs from Play's exported form).
  • Sources: https://support.google.com/googleplay/android-developer/answer/10787469 (Data safety form, CSV import), https://support.google.com/googleplay/android-developer/answer/13327111 (account deletion: delete-account link; deleting other data is a separate question).

Adding a lesson

Every bug found in real use gets an entry in docs/pitfalls.md and here, and, where possible, a check or a test that fails without the fix (see CONTRIBUTING.md).

22. play build: "AAB manifest without package / versionCode" on a good bundle

  • Symptom: Gradle printed BUILD SUCCESSFUL, then storebound stopped with AAB manifest without package / versionCode (storebound before this fix). play upload and play release used the same reader.
  • Cause: storebound read the AAB's proto manifest (aapt2 Resources.proto) with the wrong field numbers. Nothing was wrong with the bundle.
  • Do: update storebound. Don't rebuild or change the Gradle setup for this error.
  • Guard: aabInfo in cli/lib/gradle.ts; a byte-level test that doesn't use storebound's own encoder.

23. Capacitor: native build ships old web assets

  • Symptom: storebound flow run release --apply went straight into apple build (xcodebuild archive) while ios/App/App/public and android/app/src/main/assets/public were 3.5 hours older than the latest source commit: the build would have shipped the old game.
  • Cause: a Capacitor app's native projects contain a copy of the web build that npx cap sync made last. Neither xcodebuild nor Gradle rebuilds or re-copies it; nothing ran the web build + cap sync before the native build.
  • Do: set build.prebuild (e.g. "npm run build && npx cap sync"; per store apple.prebuild / play.prebuild). apple build / play build run it first in the project root (printed, output streamed, a nonzero exit stops the build; --dry only prints it; --no-prebuild skips it); inside one flow run the shared command runs once, not per store. Never pass --allow-stale-web to get past the check without the owner knowing that the old web build ships.
  • Guard: after the prebuild, a Capacitor project's copied public/index.html must be newer than the last git commit outside ios/ android/ node_modules/ store/ .storebound/ and than <webDir>/index.html, else the build stops with "web assets are older than the source (copied ..., source ...)" (webStep / webStaleness in cli/lib/web-assets.ts). storebound check warns when the assets are stale and no prebuild is set. The build output and the dry-run plan show the web bundle that goes in (sha256 of the copied index.html and its mtime). tests/web-assets.test.ts.
  • Monorepo: with the Capacitor app in apps/native (its own capacitor.config.ts, android/, ios/), play build once failed with "no Gradle project (settings.gradle) in android or .". The builds, the staleness check and check now find the one Capacitor project one or two levels down (workspaces, apps/, packages/) and name it; several are listed and need play.build.dir / apple.build.workspace|project (capacitorProject in cli/lib/web-assets.ts).

24. A flow step that declined its confirmation was logged "ok"

  • Symptom: flow run release --apply ran storebound apple upload --no-wait, which printed "not a terminal: re-run with --yes to confirm" and uploaded nothing, yet the flow logged "4/13 upload: ok" and went on to apple testflight wait for a build that was never uploaded.
  • Cause: a declined confirmation (confirm() in cli/lib/ctx.ts) returned from the command with exit 0, and flow run --yes was not passed on to the steps, so a run without a terminal could never confirm.
  • Do: read a step's output, not only its status. After showing the dry run, run the writes yourself with flow run <name> --apply --yes (owner steps never run in a flow, they print as "YOUR CLICK").
  • Guard: every declined or unanswerable confirmation prints declined: <reason> and exits 3 (DECLINED_EXIT); flow run stops with "step N declined: " (status declined, never ok) and adds --yes to write steps only with --apply --yes (planStep in cli/lib/flow.ts, declinedReason in cli/flow.ts). tests/flow.test.ts (non-TTY flow with a confirming step and no --yes fails at that step with "declined").

25. Builds and uploads in a non-interactive shell: no SDK, no output

  • Symptom: storebound play build stopped in Gradle with "SDK location not found. Define ANDROID_HOME or sdk.dir in local.properties" although the SDK was in ~/Library/Android/sdk. storebound apple upload --no-wait --yes ran for over 10 minutes with no line at all while it sent the IPA.
  • Cause: agents and scripts run non-interactive shells: ANDROID_HOME from .zshrc is not set there, and local.properties (where Android Studio writes sdk.dir) is gitignored, so a fresh checkout has none. The upload logged only every 10th part and had no timeout per part or per API call, so one large or stalled part was silence.
  • Do: nothing special now. play build finds the SDK like Android Studio (ANDROID_HOME, ANDROID_SDK_ROOT, sdk.dir in local.properties, the Android Studio default folder per OS), passes it to Gradle in the environment and never writes local.properties; without one it stops before Gradle and lists what it checked (storebound doctor shows the SDK and its source). apple upload prints the size, a line per part and a heartbeat every 20 s (also for altool and the processing wait); --timeout stops it with exit 7, and running the same command again resumes. Still run long uploads in the background with a log and report its lines.
  • Guard: resolveAndroidSdk / androidSdkEnv in cli/lib/tools.ts, cli/play/build.ts, cli/doctor.ts; sendParts in cli/apple/upload.ts, cli/lib/progress.ts (injectable heartbeat), waitForBuild in cli/apple/testflight.ts, a 2-minute limit per App Store Connect call (ASC_REQUEST_TIMEOUT_MS); tests/tools-path.test.ts, tests/build-sign.test.ts, tests/upload-progress.test.ts.

26. Asking the owner for every step

  • Symptom: an agent using storebound spent hours handing the owner one click, one command or one answer at a time: "open App content", "press Next", "download the CSV template", "now run the upload", "what is your answer for X?" in the middle of a run.
  • Cause: skills and rules said "let the owner do it" and "by hand" for navigation, draft saves, exports and uploads, and compliance questions were asked when the run reached them, not at the start.
  • Do: follow the first rule (top of this file, docs/agents-core.md): do every API call, upload, build, export, console navigation and form yourself, verify by reading back, go on. Collect every fact no file proves in one batch at the start (storebound check / validate list them) and save the answers in the config. Stop only for the owner steps (Submit for review, release / production rollout, App Privacy Publish, legal agreements, deleting store data, live prices, review replies, testers), prepared with the dry run shown. Make check / --dry catch store refusals so they never surface as a 400 mid-run.
  • Guard: the rule is the first section of docs/agents-core.md and so of every generated adapter (AGENTS.md, GEMINI.md, Copilot and Cursor rules), of README and of this skill; tests/do-it-dont-ask.test.ts checks that and fails when a skill or the rules hand navigation, draft saves, exports, uploads or form filling to the owner (navigation "by hand", uploads or exports left to the owner, asking the owner to click through a form).

27. Play steps without an API: category, countries, App content, IARC

  • Symptom: after a first Play release through the API the App category showed "Not selected", production had 0 countries, and an agent hunted for each App content card; the IARC questionnaire kept revealing new questions, an evaluate call timed out at 45 s, material checkboxes ignored form_input, and "Send changes for review" ignored a JS click.
  • Cause: play listing cannot set the category (edits.details has no category field) and edits.countryAvailability is read-only; the IARC questionnaire is one long page where each Yes reveals follow-ups; Play's <material-checkbox> / <material-radio> keep their own model.
  • Do: set play.category, play.countries (or all), console.contentRating.details, console.appContent, social and products[].randomRewards from ONE batch of owner answers (storebound console plan play-app-content lists them), then drive console plan play-store-settings, play-app-content and its cards, play-production-countries and play-production-release. Click the inner [role=radio] / [role=checkbox]; run long flows with the plan's inPageLoop and poll window.__log. Never tick the IARC Terms of Use box (the owner ticks it before the run: lesson 39); the Summary's button is "Save", and like every App content card Save it is yours (lesson 41). "Send changes for review" is the owner's real click; managed publishing is off by default, so approved means live (play.managedPublishing).
  • Guard: the plans above, check (category warning, open App content answers), validate (category reminder, "production has 0 countries"), play status / play release notes; tests/console-play-plans.test.ts.

28. First App Store IAPs: apple iap-submit --with-version, not the console

  • Symptom: the IAP page's "Add for Review" menu ignored scripted clicks, and the version page no longer has an "In-App Purchases and Subscriptions" section.
  • Do: storebound apple iap-submit --with-version --dry, then after the owner's yes (it submits) --with-version --yes: on a real first release it added the IAPs to the draft submission that already held version 1.0.0 and submitted them, first try. App Privacy: the owner's Publish opens "Publish Your App Privacy Responses?" (Cancel / Publish); afterwards "Published … ago by …" shows, which console verify apple-app-privacy reads as the published signal.
  • Guard: appstore-first-release and store-iap-setup skills; tests/console-play-plans.test.ts.

29. ITMS-91064: NSPrivacyTracking true with an empty NSPrivacyTrackingDomains

  • Symptom: Apple marks the build Invalid Binary a minute after the upload: "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."
  • Cause: the app's own PrivacyInfo.xcprivacy said NSPrivacyTracking true with an empty NSPrivacyTrackingDomains because App Privacy says "used for tracking", but the tracking came from an ad SDK (mediation, attribution) whose own manifest declares it. Apple's text names the other direction (domains listed with false); both are refused.
  • Do: in every manifest, NSPrivacyTracking true needs a non-empty NSPrivacyTrackingDomains (domains the code of THAT bundle sends tracking data to), and a non-empty list needs true. When only an SDK tracks, the app's manifest says false; App Privacy in App Store Connect still says tracking = yes and ATT stays. Set appPrivacy.trackingDomains only for the app's own tracking domains. A bad SDK manifest: update the SDK. Rebuild and upload a new build number.
  • Guard: check, scan and privacy-manifest fail on either mismatch with the ITMS text; check reports tracking declared by an SDK manifest as info and warns only when no manifest declares it; privacy-manifest --propose writes true only with appPrivacy.trackingDomains; apple upload checks every manifest in the IPA (app root, Frameworks, bundles) before any network call, apple build after the export; tests/privacy-tracking.test.ts.

30. Resubmit after a rejection: reuse the UNRESOLVED_ISSUES submission

  • Symptom: after an Invalid Binary rejection, apple iap-submit --with-version --yes created a new, empty draft and stopped with "the store says it already exists but does not list it yet (its lists lag ...)". The real answer was 409 ITEM_PART_OF_ANOTHER_SUBMISSION: the version was still in the rejected submission.
  • Cause: a rejected submission stays open with UNRESOLVED_ISSUES and keeps its items (the REJECTED version, its IAPs); storebound only looked for READY_FOR_REVIEW drafts, and read the 409's "already added" as a duplicate.
  • Also seen (same day): with build 2 VALID, APP_STORE_ELIGIBLE and attached, every PATCH reviewSubmissions/{id} submitted: true answered 409 STATE_ERROR "Version is not ready to be submitted yet, please try again later" for 25 minutes, and the console's Resubmit and Add for Review buttons were disabled. Retrying never helps: the appStoreVersion item was still REJECTED. PATCH /v1/reviewSubmissionItems/{item id} with {"data":{"type":"reviewSubmissionItems","id":"<item id>","attributes":{"resolved":true}}} answered 200, the item went to READY_FOR_REVIEW, and the submit then passed (WAITING_FOR_REVIEW). The 7 IAP items were READY_FOR_REVIEW. An empty READY_FOR_REVIEW draft (0 items) cannot be canceled: canceled: true answers 409 "Resource is not in cancellable state". It is harmless; leave it (a later run reuses it).
  • Do: upload the fixed build, storebound apple status (shows the submission, its items, which are REJECTED and not resolved, and the command), then storebound apple resubmit --build <n> (dry run, plan table with "mark resolved" rows) and after the owner's yes --yes: it attaches the build, marks the REJECTED version item resolved, then submits. REJECTED IAP / subscription items are marked resolved only with --resolve-iaps (after fixing what App Review asked for). Right after a build is attached, "not ready yet" with nothing left REJECTED is retried with backoff (up to 10 min) and a re-run continues where it stopped. Never offer a cancel command for an empty draft.
  • Guard: chooseSubmission (cli/lib/review-submit.ts) reuses an open submission and never creates a second one; addItem turns the 409 into a message naming the other submission (never the lag message: isConflict excludes it), a draft created by the same run is left as is (not cancellable) and the named one used; resolveRejected / toResolve mark REJECTED items in scope resolved before the submit; submitWithRetry (cli/apple/submission.ts) re-reads the items on "not ready", resolves those in scope and stops at once (naming the item and how to resolve it) when one out of scope is left, backing off only when none is; apple status labels an empty draft "empty draft (harmless; App Store Connect keeps it, not cancellable)" and treats the cancel 409 as harmless; the fake ASC blocks the submit while an item is REJECTED and refuses to cancel an empty draft; tests/resubmit.test.ts.

31. After a canceled submission: DEVELOPER_REJECTED versions go in as they are

  • Symptom: the owner canceled a WAITING_FOR_REVIEW submission; apple iap-submit --with-version --dry then skipped every IAP ("latest version is DEVELOPER_REJECTED: change it in App Store Connect (or --new-version)") and said "nothing to submit".
  • Cause: canceling leaves the app version DEVELOPER_REJECTED (then PREPARE_FOR_SUBMISSION) and every IAP version DEVELOPER_REJECTED. Apple accepts the same IAP version again: POST /v1/reviewSubmissionItems → 201 READY_FOR_REVIEW, then the submit → 200 WAITING_FOR_REVIEW. storebound treated it like an approved product.
  • Do: storebound apple iap-submit --with-version --dry: the plan lists them as "add (developer rejected, re-submitted as is)" into the open draft (an empty one is reused); then after the owner's yes --yes. Use --new-version only for APPROVED or REJECTED products whose metadata changed.
  • Guard: planIapSubmission (cli/lib/review-submit.ts) takes DEVELOPER_REJECTED IAP, subscription and group versions; the fake ASC models the cancel and the re-add; tests/resubmit.test.ts.

32. play build: the AAB keeps the build file's versionCode

  • Symptom: play build passed versionCode 3, the AAB had versionCode 1 (the post-build check stopped it). The Capacitor app/build.gradle read versionCode from an env var.
  • Cause: storebound passes -PversionCode / -PversionName (and android.injected.version.*); a build file that does not read the project property ignores them. android.injected.version.code does NOT override defaultConfig for bundleRelease (checked with AGP 8.13).
  • Do: in defaultConfig, Groovy: versionCode((project.findProperty('versionCode') ?: 1) as Integer) and versionName project.findProperty('versionName') ?: '1.0'; Kotlin: versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1 and versionName = project.findProperty("versionName") as String? ?: "1.0" (your current values as defaults). Not versionCode (project.findProperty('versionCode') ?: 1) as Integer: Groovy reads it as versionCode(...) as Integer and Gradle fails with "Value is null".
  • Guard: play build (also --dry) and check warn before Gradle with the file, the line and the line to use; the post-build versionCode check is a hard stop; tests/build-sign.test.ts, tests/web-assets.test.ts.

33. Play "Do you want to restart your review?"

  • Symptom: in Publishing overview, "Submit N changes for review" while a review is running opens "Do you want to restart your review?" with "Restart review".
  • Cause: Play reviews one set of changes at a time; restarting sends the new changes and starts the running review over.
  • Do: never press "Restart review" as an agent: tell the owner a review is running and what restarting means; the owner decides (Cancel keeps the running review).
  • Guard: the play-production-release console plan lists the dialog, "Restart review" is in neverPress; tests/console-play-plans.test.ts.

34. Desktop release: never run the x86_64 slice on the build Mac

  • Symptom: while building the storebound desktop app, macOS tells the owner "App Update Required: this version of storebound includes a component that will not open in macOS 28".
  • Cause: a check ran the universal app's Intel slice (arch -x86_64 ... --self-test) on Apple silicon, which starts Rosetta; macOS warns about every app that does.
  • Do: check the x86_64 slice only by structure (lipo -archs, file, codesign) and run self-tests natively (arm64). The same holds for any universal app or game you build: never launch the Intel slice to test it.
  • Guard: tools/desktop-release.ts (archProblems before signing: every Mach-O must contain arm64, no Intel-only file, no Info.plist that forces Intel; sliceProblems: every Mach-O in the bundle must carry arm64 and x86_64, nothing x86_64 is run); tests/desktop-release.test.ts.

35. Scan findings from a bare word, a module path or a config claim

  • Symptom: scan reports "A reward for rating or reviewing the app" on const rate = F.genRate(lv, bonus.gen(r));; account findings cite import './buildings/register';; 5.1.1(v) disappears once accounts.inApp is set although the app has no delete-account code.
  • Cause: the signals matched "rate" next to "bonus", and "/register'" anywhere on a line; the config's word was taken as proof.
  • Do: read every finding's evidence before acting on it. A finding whose evidence is not real code is a scan bug: report it (and accept it with scan.ignore and the owner's reason meanwhile). For 5.1.1(v), show the deletion code path or screen; a config line alone is a claim, not evidence.
  • Guard: the incentive signal needs a rate-us / review phrase next to a reward, a reward "for rating", or a reward next to a system review prompt call; sign-in / sign-up / deletion signals skip import and require lines and accept only absolute auth paths or URLs; config-only accounts read "from config (accounts.enabled)"; deletion claimed only by the config stays a medium finding ("claimed by config, not found in code"). tests/game-report.test.ts.

36. Privacy answers derived from the wrong source

  • Symptom: privacy-manifest --propose prints an empty NSPrivacyCollectedDataTypes while appPrivacy.types lists collected types; the App Privacy plan adds "Other Usage Data" you never listed; the Play plan answers "uses advertising ID: Yes" because Data safety declares a Device ID.
  • Cause: the proposal only kept the manifest's own entries; the App Privacy sheet mixed in types derived from dataSafety; the advertising ID answer read PSL_DEVICE_ID (a random install id is not the advertising ID).
  • Do: treat appPrivacy.types as the App Privacy answer: a type it does not list is "not collected". Answer the advertising ID from the SDKs and the merged manifest (com.google.android.gms.permission.AD_ID), never from Data safety alone; set console.appContent.advertisingId when the signals disagree.
  • Guard: --propose builds the list from appPrivacy.types (linked, tracking, purposes; NSPrivacyTracking true only with app-level domains, lesson 29) and check warns on an empty list; console plan scans the project (ad / attribution SDKs, AD_ID, support and AR code) and answers No with no signal, asks on a conflict. tests/game-report.test.ts.

37. scan.ignore that matches nothing; placeholder credentials

  • Symptom: a scan.ignore entry does not accept the finding and nothing says why; commands treat YOUR_KEY_ID or /absolute/path/to/upload.p12 in .storebound/credentials.json as a credential.
  • Cause: match is compared with the finding TITLE, not the evidence; the 0.7.1 credentials template held example-looking values.
  • Do: scan.ignore: [{ rule, reason, match?, evidence?, store? }]: match = text in the finding title (one check of a rule with several), evidence = text in one evidence line as the report prints it (src/main.ts:12 or a code fragment); both together narrow further. Never add an entry without the owner's reason. Fill credentials with real values or leave them empty.
  • Guard: scan (notes) and check (warnings) name every entry that accepts no finding; the credentials template is empty strings; "your-…", <…>, "xxx", "/absolute/path/to/…" and "example" values count as missing everywhere (check warns, doctor lists them). tests/game-report.test.ts.

38. New App: Name and SKU empty after a value set

  • Symptom: App Store Connect's New App dialog showed the Name and SKU, yet the dialog's state was empty ("").
  • Cause: the dialog is React: a programmatic value set (form_input, el.value = …) changes the input, not the component state; clicking the field and typing works (verified 2026-10-13).
  • Do: type, don't set value: click the field, type the text key by key, read it back.
  • Guard: the apple-app-record plan (uiFacts, the fill step); console fill types every text field (typeText); tests/browser/console-flow.browser.ts.

39. IARC page 1: Next does nothing until the Terms box is ticked

  • Symptom: on the content rating questionnaire's page 1 Next did nothing, and the agent waited mid-run for the owner.
  • Cause: the IARC Terms of Use checkbox gates Next, and it is an agreement only the owner ticks.
  • Do: hand the owner the plan's ownerBatch in ONE message before the run: it has "Tick the IARC Terms of Use box" with the exact place (Content ratings > Start questionnaire, page 1). Never tick it yourself; if it is still unticked, say so once and poll it, do not press Next again and again.
  • Guard: ownerBatch in console plan play-content-rating / play-app-content (printed first); console fill waits for the tick ("agreement needed on …"); tests/console-play-plans.test.ts, tests/browser/console-flow.browser.ts.

40. Finding play.appId in a paginated app list

  • Symptom: finding a new app's Play Console app id took several steps: 10 apps per page, the new app not on page 1.
  • Cause: the Play API does not return the id; it is only in console URLs.
  • Do: after Create app read location.href (/app/<appId>/app-dashboard) and run storebound console verify play-app-record --url "<url>" (dry diff), then --write (an upsert of play.appId). Missed it: storebound play app-id (search the package name in the list or walk the pages, pipe the captures in), then --write.
  • Guard: playAppIdInText, findPlayAppId, upsertNestedString; tests/play-app-id.test.ts.

41. App content card Saves are the agent's

  • Symptom: every Play App content card ended in an owner-only Save, so the owner came back once per card.
  • Cause: a saved declaration was treated as public. It is not: Google gets it only with the next "Send changes for review" in Publishing overview (verified 2026-10-13).
  • Do: press each card's Save yourself (privacy policy, ads, app access, advertising ID, government, financial, health, target audience, the content rating questionnaire and Summary, data safety). The owner keeps: the IARC Terms box (before), typing the demo account only when it is not in .storebound/credentials.json (otherwise you type it, never printed), and "Send changes for review" / Submit / Publish / "Restart review" (after). storebound console plan play-app-content --all walks every card and ends with that one owner list.
  • Guard: declarationSaveAllowed, publishingControl, flowAllowed (cli/lib/console-forms.ts), the plans' buttons.final "Send changes for review", console fill's declaration Save; tests/console-play-plans.test.ts.

42. A filter the spec declares, refused live (TestFlight distribute)

  • Symptom: apple testflight distribute --build 1 --group Team --yes printed the build state, then failed: GET /v1/betaGroups?filter[app]=…&filter[builds]=… -> 400. Before the groups existed it said unknown group "Team" ().
  • Cause: Apple's OpenAPI spec declares filter[builds] on GET /v1/betaGroups, but App Store Connect answers 400; the spec has no GET of a build's groups. An internal group with all builds already has every build and refuses one added by hand.
  • Do: read a build's groups per group (GET /v1/betaGroups/{id}/relationships/builds); skip internal all-builds groups ("already available"). A configured group missing on App Store Connect: storebound apple testflight groups sync first. Never use a query parameter that is not declared for the operation (storebound api list), and add one refused live to APPLE_LIVE_REJECTED (cli/api/client.ts).
  • Guard: tests/asc-query-guard.test.ts (every App Store Connect GET with a query in cli/ against the spec and APPLE_LIVE_REJECTED), the fake App Store Connect's 400 for such parameters, unknownGroupMessage; tests/idempotency.test.ts (distribute), tests/testflight.test.ts.

43. Keyword field: 100 characters, not bytes

  • Symptom: check / listing check / check --aso called live keyword fields too long ("105 bytes > 100") that App Store Connect accepted.
  • Cause: App Store Connect counts characters. Live READY_FOR_SALE versions (read 2026-10-13): Thai 95 characters / 265 bytes, Russian 91 / 169, Turkish 100 / 105, Polish 99 / 105. Entry 4 (bytes) was wrong.
  • Do: count characters ([...text].length); fill up to 100 characters in any script.
  • Guard: every keyword check counts characters (cli/lib/copy.ts, aso.ts, listing-translate.ts, aso-intel.ts); tests with Thai, Turkish and Polish fields over 100 bytes.

44. "No 6.7-inch iPhone screenshots" on a live app with the 6.5-inch set

  • Symptom: validate blocked every locale of a live app: "en-US: no APP_IPHONE_67 screenshots".
  • Cause: App Store Connect requires the 6.9" set (APP_IPHONE_67) or, when it is missing, the 6.5" set (APP_IPHONE_65), and scales the smaller sizes from it.
  • Do: treat the 6.5" set as meeting the requirement; adding a 6.9" set is a recommendation.
  • Guard: ACCEPTED_INSTEAD in cli/lib/validate-rules.ts (a warning screenshots-size, no blocker).

45. Play graphics missing in extra languages

  • Symptom: validate --play listed an icon and a feature graphic blocker for every non-default language.
  • Cause: Play shows the default language's graphics in a language that has none of its own.
  • Do: only the default language's graphics are required; a language with none uses them (a note).
  • Guard: playRules (images-fallback note per type).

46. Play Developer Reporting API off

  • Symptom: apps list --store play and Play vitals failed with a 403 SERVICE_DISABLED.
  • Cause: the Reporting API is not enabled in the service account's Google Cloud project.
  • Do: open the link the finding prints (…/playdeveloperreporting.googleapis.com?project=<id>), press Enable, wait a few minutes. Meanwhile apps list shows the Play apps of this machine's folders and store-only entries.
  • Guard: reportingEnableUrl, knownPlayPackages (cli/apps.ts), healthFindings.

47. Onboarding a live app left its products out ("no USD prices")

  • Symptom: after init + listing pull the config had no products although the app sells some.
  • Cause: only texts were pulled, never prices; Apple's price schedule can have a base territory other than the USA.
  • Do: storebound onboard (then --write): products and subscriptions from both stores with usd = the US price, Apple's base territory as an override, one-store items marked with platforms. Then storebound catalog diff (clean). Store prices are never changed; an item with no USD price anywhere is named with its base price.
  • Guard: importCatalog (cli/lib/onboard.ts), tests/onboard.test.ts.

48. Contact e-mail, website and App Review contact asked of the owner

  • Symptom: onboarding kept support@example.com and asked for the website and review contact.
  • Cause: nothing read Play's app details or App Store Connect's review detail.
  • Do: storebound onboard reads them; template values are replaced, real ones kept; the review contact goes to .storebound/credentials.json (masked in output, the phone never printed).
  • Guard: contactProposal, reviewContactOf, maskContact.

49. Sign-in for review defaulted to yes

  • Symptom: every onboarded app got "sign-in required: add a demo account".
  • Do: decide from Apple's review detail (demoAccountRequired; a demo account there is imported, never printed) and from the code's auth signals; ask only when neither proves it.
  • Guard: signInDecision.

50. Account deletion URL asked although the site has the page

  • Do: storebound onboard tries /delete-account, /account/delete, /delete, /account-deletion and the privacy policy's deletion links; not found is reported as the real gap it is (Play requires the page). Never invent one.
  • Guard: findDeletionUrl.

51. No default credential profile except one named "default"

  • Do: storebound accounts default <profile> (--clear). Order: env > the project's named profile > the project's own keys > this default > the only profile.
  • Guard: defaultProfile, tests/machine.test.ts.

52. A live app with no local project is missing from the desktop

  • Do: storebound apps add --store apple --app-id <id> --account <profile>; the desktop shows it read-only ("Store only": status, reviews, analytics). apps remove apple:<id> takes it out.
  • Guard: storeApps in ~/.storebound/projects.json (cli/lib/registry.ts, registry.rs), the boot check.

53. apple upload passed a stale IPA as "already VALID"

  • Do: upload the IPA apple build just printed (build/ios/export/<version>-<build>/App.ipa), or apple upload --build N. An IPA whose build number is already on App Store Connect now fails with exit 1: the last build failed; run apple build again. --allow-existing only for re-checking a build this command uploaded before (flows use it with --build ${BUILD}).
  • Guard: per-build export folders, existingBuild / exportedIpas (cli/apple/upload.ts), exportIpa (cli/apple/build.ts), tests/apple-build-export.test.ts.

54. "exportArchive Copy failed": a Homebrew rsync on PATH

  • Do: read the IDEDistribution log lines apple build prints. "rsync error: syntax or usage error" means a non-Apple rsync (Homebrew 3.x) is first on PATH; storebound runs xcodebuild with /usr/bin first, storebound doctor names the culprit. Never install or brew link a global tool as a side effect on a shared machine.
  • Guard: xcodePath, foreignRsync, distributionErrors (cli/lib/xcode.ts), doctor, publishSteps (tools/desktop-release.ts, rsync by absolute path).

55. The feature graphic cut the phone off at the bottom

  • Do: nothing by default: shots.feature.fit 'contain' sizes the (rotated) phone so its outline stays inside the 1024 x 500 canvas with a 24 px margin. fit: 'bleed' keeps the larger, cropped phone on purpose; scale (0.5-1.5) resizes it. shots check fails a feature graphic whose device leaves the canvas (measured from the page's layout at render time, devices in report.json), and warns when a screenshot's front device runs off a side or the top.
  • Guard: featureLayout, rotatedBox (cli/shots/lib.ts), deviceIssues (cli/shots/report.ts), tests/browser/feature-fit.browser.ts (geometry, both templates rendered at 1024 x 500, a bleeding fixture).

56. "Live on Play" while Google was still reviewing, and 1.0 / 1.0.0 shown as two versions

  • Do: never call a Google Play production release live from the Developer API alone: a release reads completed (userFraction 1) from the moment it is sent, also while Google reviews the first release. play status checks the public store page (no auth, 4 s timeout, cached 5 minutes) and decides, never "maybe": the page's "Version" (About this app, read from its AF_initDataCallback data) equals the release's versionName = "Live"; 404 = "In review" (with play.managedPublishing: "In review / ready to publish (managed publishing)"); an older version on the page = "In review (live: )"; "Varies with device" = live. Only a network failure gives "Couldn't check the store page", and then the last known page answer is used with its time. Match versions across the stores by versionKey (trailing .0 parts dropped: 1 == 1.0 == 1.0.0); show each store's own string.
  • Guard: playPublicPage (cli/lib/play-public.ts), playReleases / versionKey / sameVersion (cli/lib/portfolio.ts), versionHistory (cli/lib/train.ts), tests/play-public-version-match.test.ts, the desktop boot check's release-state cases (page 404 and 200).

57. A raw Google JSON error in the window, and "Exit code 1" for a scan that found problems

  • Do: never show an API error body to the owner. Parse Google errors into a typed finding (parseGoogleError): SERVICE_DISABLED = the API is off in the service account's Cloud project (say which API and project, link the console page to turn it on), PERMISSION_DENIED = the service account has no access to that app (Play Console > Users and permissions), 401 / invalid_grant = the key is revoked or expired, 429 / RESOURCE_EXHAUSTED = quota. One project that blocks several apps is one notice, not one error per app. Keep the raw text in a collapsed Details and the log. Exit 1 of scan / check / validate / health with the JSON result printed means "found problems": show the findings; a real failure shows the command's own error line, never a bare exit code.
  • Guard: cli/lib/google-errors.ts, outcomeOf / errorLine (cli/lib/desktop-protocol.ts), tests/google-errors.test.ts, the desktop boot check's errors cases (api-disabled, scan-findings, scan-failed).

58. Parallel Play reads deleted each other's edits, and a failed read said "not on Google Play yet"

  • Do: Google Play keeps one open edit per app: a second edits.insert for the same package deletes the first, so a read in it answers 400 "This Edit has been deleted". Every edit (read or write) of a package takes the machine-wide lock ~/.storebound/locks/play-edit-<package>.lock (short wait with a heartbeat log, 90 s timeout for reads, 10 min for writes; stale when its process is gone or untouched for a minute); other packages stay parallel. A read whose edit was deleted, committed elsewhere or expired is redone once with a new edit; a write is never redone that way (it keeps its own invalidation retry). A store that could not be read is "couldn't read Google Play" (unknown), never "not on Google Play yet"; its last good releases stay with "couldn't refresh". When the stores' current versions differ (normal), each lane shows its own ("4.1.2 · Live (4.1.9 not submitted)") under a neutral "Current" header; "Not on this store" only when the store has no record of the app.
  • Guard: withMachineLock (cli/lib/lock.ts), read() / edit() (cli/play/client.ts), laneGap / storesDiffer / withLastGood (cli/lib/train.ts), tests/play-edit-lock.test.ts (a fake Play where a second insert invalidates the first), the desktop boot check's auto-read case (two Play reads of one app at once, Orbit Hop on different versions per store).

59. A live app showed missing art and render blockers although both stores had its screenshots

  • Do: for an app that is already live, pull the art instead of asking for render sources: storebound art pull --dry, then --write (onboard runs it too). The App Store CDN re-encodes: a downloaded screenshot never has the MD5 App Store Connect keeps for the uploaded file (sourceFileChecksum), so without a record apple screenshots would re-upload every set. store/renders/pulled.json keeps each pulled file's sha256 with the store's own checksum, and the uploads compare an unchanged pulled file with that checksum (nothing sent). Play serves the original bytes at =s0 (its sha256 equals the one images.list gives; checked on download). Never overwrite a local file that differs from the store without --force: it may be the owner's new render.
  • Guard: cli/art.ts, cli/lib/art-pull.ts (decide, pulledChecksum, storeEmptySets), the pulled-file rules in scanRenders (cli/shots/report.ts) and check, tests/art-pull.test.ts (recorded ASC screenshot sets with templateUrl and Play images.list, the layout, the upsert and --force).

60. init + onboard on live apps left the template compliance answers in the config

  • Do: treat every answer storebound init wrote as unanswered. The game template declares approximate location, app interactions, diagnostics and device id as shared for advertising and PSL_ACM_NONE; pushing that from a live app is a false declaration. Templates list the areas in unanswered; configs from before it are recognised by the template values. check, validate and scan block; play data-safety (also --dry), console plan / fill, apple review-details and apple appinfo refuse. For a live app run storebound onboard --declarations --write: name and copyright from the stores (a copyright with a private term from STOREBOUND_PRIVATE_TERMS is left out), the template answers out, the rows the code proves in (scan proposal, sign-up methods from auth code), the rest as one owner question list. Remove an area from unanswered only after the owner answered it.
  • Guard: cli/lib/template-answers.ts, cli/lib/onboard-declarations.ts, the template-answers scan rule, tests/template-answers.test.ts (init blocks, data-safety --dry refuses, onboard on auth code without an ad SDK).

61. prices ppp --json printed log lines on stdout before the JSON

  • Do: with --json, read stdout as one JSON document and stderr as the log. Every command run with --json goes through withJsonOutput (cli/lib/log.ts, wrapped in cli/index.ts): log / done / tables go to stderr, printJson is the only writer to stdout, prompts go to stderr. Never print a result with console.log(JSON.stringify(...)).
  • Guard: tests/json-stdout.test.ts runs every command whose module reads --json against the fake stores and JSON.parses its stdout, plus one real CLI run (prices ppp --offline --dry --json).

62. PPP prices cut Germany to EUR 3.59 for a USD 4.99 product (store price EUR 5.99)

  • Do: trust prices ppp defaults: only lower prices, only economies with a price level at most 50 % of the US, World Bank high-income economies and mainland China keep the store's own US-equivalent price, never above it; a top-10 revenue market dropping more than 20 % is warned about. The old math set a net price (base x PPP level x exchange rate) against VAT-inclusive store prices and cut every region. Now par = the store's own conversion (Play convertRegionPrices, Apple equalizations; offline base x fx x (1 + VAT), rounded up), share = clamp(level, 0.3, 1). Regions that keep their price are left out of the sheet (kept in --json), so prices import leaves the store's price there. --include-high-income, --max-level, --min-ratio / --max-ratio, --no-only-decreases widen it, only after the owner asks.
  • Guard: shareOf, offlinePar, planRows, topMarketDrops (cli/lib/ppp.ts), tests/ppp.test.ts (Germany keeps EUR 5.99, India and Turkey drop, CN skipped on Play).

63. Creating new IAPs also changed live prices of existing ones (a terminal "y" covered them)

  • Do: never rely on a confirmation for price changes. Creating products never changes an existing product's price unless --allow-price-changes is passed, TTY or not; the terminal question of play products, prices import, play subscriptions and apple subscriptions covers creates and other updates only. With the flag, price changes get their own question listing each old -> new (in a terminal even with --yes). Dry runs list them in a separate "price changes on existing products (held)" section. apple iap never changes an existing schedule.
  • Guard: allowPriceChanges / heldSection (cli/lib/price-gate.ts), tests/price-gate.test.ts (TTY and non-TTY: a "y" creates the new product and holds the live price; the flag's question names each change).

64. signing ensure made a manual App Store profile for an automatic-signing app

  • Do: with apple.signing.style: automatic (the default), apple signing ensure only keeps the App ID and its capabilities in step. It never creates certificates or profiles: Xcode owns them (xcodebuild -allowProvisioningUpdates with the API key) and renews its managed profile when the capabilities change. The dry run says so. Profiles storebound made that nothing uses ("storebound App Store ") are listed by name with the exact storebound api apple profiles_deleteInstance --param id=<id> --yes command; deleting them is the owner's call, never run by storebound.
  • Guard: ensureScope, unusedStoreboundProfiles (cli/lib/signing.ts), tests/signing-capabilities.test.ts.

65. A capability add "succeeded" but the App ID kept only IN_APP_PURCHASE

  • Do: never report a capability as added from the POST answer. signing ensure reads the App ID back after any capability write (a few tries with growing waits, about 45 s at most) and fails with the API's answers when one is missing. The capability names were right (CapabilityType: PUSH_NOTIFICATIONS, GAME_CENTER); what an App ID shows and what a build may use are different things: a provisioning profile keeps the entitlements of the moment it was made, and an app whose project has no .entitlements (or one not set in CODE_SIGN_ENTITLEMENTS) ships without the capability whatever the App ID says. apple status, apple signing status and validate compare the App ID, apple.signing.capabilities and the project's entitlements and name what is missing where.
  • Guard: verifyCapabilities, compareCapabilities, ENTITLEMENT_CAPABILITIES (cli/lib/signing.ts), checkCapabilitiesStuck (cli/apple/signing.ts), tests/signing-capabilities.test.ts (live-shape bundleIdCapabilities answers, lag, a write that never sticks).

66. storebound api apple ... --body f.json --yes only dry-ran from an agent

  • Do: expected: writes through storebound api are sent only from an interactive terminal, by design (since pitfall 78 it is a loud refusal with exit 1, not a dry run). Use the dedicated command the message names (bundle id capabilities: apple signing ensure; IAPs: apple iap; listing: apple listing, ...), which runs anywhere with its own dry run and read-back.
  • Guard: dedicatedCommand (cli/api/gate.ts), tests/signing-capabilities.test.ts.

67. "builds ship without push / Game Center" while the uploaded build had both

  • Do: a checkout without .entitlements says nothing about the build: native changes may live on a release branch on purpose (OTA fingerprint on main). The comparison warns only about this checkout ("built from another branch?"), names the current branch and the local branches / worktrees that have an .entitlements file, and is never a blocker. The real answer is the built IPA: with one in build/ios/export (highest build number, newest file) storebound reads its entitlements (codesign -d --entitlements :- on macOS, else the Mach-O signature blob) and compares those, labelled "uploaded build N". The App Store Connect API does not expose a build's entitlements, so without a local IPA it says that instead of guessing.
  • Guard: checkoutInfo, builtIpaEntitlements (cli/apple/signing.ts), machoEntitlements, compareCapabilities (cli/lib/signing.ts), tests/signing-capabilities.test.ts (entitlements only on another branch; IPAs with entitlements).

68. The owner's Chrome would not open: an orphaned headless storebound Chrome

  • Symptom: clicking Chrome in the Dock does nothing (no window); ps shows a Chrome with --user-data-dir=…storebound-profile-… (or …/.storebound/console-profile/…, storebound-chrome-…), --remote-debugging-port=0 --headless=new and parent PID 1.
  • Cause: a Chrome storebound started for a console flow (here a test run) outlived its run. Started with a DevTools port, Chrome does not notice its parent dying; macOS LaunchServices then counts the windowless instance as the running "Google Chrome", so the Dock only activates it.
  • Do: run any storebound console … command (or bun test tests/chrome-guard.test.ts): every start ends such headless orphans and names orphaned windows. By hand: kill <pid> of the process without --type=. Never kill a Chrome whose parent is a running storebound (a run that waits for its window), or the owner's own Chrome.
  • Guard: guardChrome / startWatchdog / reapStaleChromes (cli/console/chrome-guard.ts): exit, Ctrl+C, SIGTERM / SIGHUP and the parent's death (SIGKILL included) end the Chrome; console fill closes it on errors too; headless runs on macOS prefer Chromium / Chrome for Testing (its own bundle id) over Google Chrome.app (persistentExecutable); tests/chrome-guard.test.ts.

69. bun add -d storebound broke a Next app's build

  • Symptom: next build (or vue-tsc, ts-loader, tsc --noEmit) fails on the scaffolded storebound.config.ts: Cannot find module 'storebound' or its corresponding type declarations.
  • Cause: storebound <= 0.8.5 exported only its TypeScript source (cli/lib/config.ts) and shipped no .d.ts. Bun imports .ts, so everything storebound tested passed; TypeScript never takes a package's .ts source as its types.
  • Do: update to 0.8.6+ (declarations for ES module and CommonJS projects). Remove any tsconfig exclude of storebound.config.ts that was added as a workaround.
  • Guard: buildEntry / pruneDeclarations (tools/build-node.ts, run by prepack) and tests/package-types.test.ts: the template config type-checks against the packed entry in ES module and CommonJS projects under bundler, node16, nodenext and node10 with no @types/node and skipLibCheck off; import() and require() of the package load under Node.

70. The first in-app purchase was not in the version's review submission

  • Symptom: the app version has been WAITING_FOR_REVIEW for days while its first in-app purchase (here a starter pack) still says READY_TO_SUBMIT; the review submission's items hold only the appStoreVersion.
  • Cause: Apple reviews the first in-app purchase of each type (and the first subscription of a group) only together with an app version. A submission made without it (from the App Store Connect version page, or before the product was ready) leaves it out, and the version is approved without it: the product is not sellable. storebound <= 0.8.6 printed the IAP and the submission states separately and called READY_TO_SUBMIT "complete".
  • Do: before submitting, storebound validate (blocker iap-first) and storebound release plan; submit with storebound apple submit --build <n>, which adds every ready product to the same submission and refuses to leave a never-approved first product out. Already in review: withdraw the submission (storebound apple status --cancel-submission <id>) and resubmit with the product, or add it with the next version.
  • Guard: firstProductGaps (cli/lib/review-submit.ts) read by cli/apple/first-products.ts: apple status (WARN and firstProductGaps in --json), release status and the desktop checks (appleFirstProductWarnings), validate / release plan (blocker), the apple submit plan (blocker); iap-review says "ready, not submitted yet"; tests/first-products.test.ts.

71. The owner was sent to a console page with plain directions

  • Symptom: a message said "App Store Connect > App Information > Age Rating" or "go to Play Console" and the owner had to find the app, the page and the card by hand.
  • Cause: storebound's findings, fix hints and errors named console pages as text paths (40+ in cli/), without the app's URL.
  • Do: when you send the owner to a console page, give the absolute deep link (storebound prints it; storebound console plan has start URLs), never just "go to App Store Connect". Set apple.appId, play.developerId and play.appId so links open the app's own page instead of the app list.
  • Guard: cli/lib/console-links.ts (every page key, absolute URL with and without ids; url in validate / scan / declarations / release plan JSON, an Open button in the desktop) and tests/console-links.test.ts, which fails on a console path in cli/ or templates/ without an https URL.

72. Play refused to send an edit for review: "set changesNotSentForReview to true"

  • Symptom: storebound play release <aab> ... --dry (or a real run) uploads and updates the tracks, then edits.validate or edits.commit answers 400 "Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true."
  • Cause: after a rejection (here production 1.0.0 (3), Broken Functionality), with other changes pending in the Console or with managed publishing, Play no longer sends an API commit for review by itself. The edit is fine; only the automatic send is refused. storebound <= 0.8.8 stopped there.
  • Do: nothing by hand: storebound commits the same edit again with changesNotSentForReview=true (only edits.commit binds it; validate answers 400 "Unknown name ... Cannot bind query parameter") (--not-sent-for-review sets it from the start; MCP not_sent_for_review). A dry run says the real run will commit that way. Then the owner presses Send changes for review in Publishing overview (storebound prints the deep link): that button is the owner's, never press or automate it. The API has no read of "waiting for Send changes for review", so storebound records its commit in .storebound/play-review-pending.json and storebound play status shows "changes committed
  • Guard: isReviewRefused and the edit flow in cli/play/client.ts / cli/play/review-pending.ts (only that 400; no other request is repeated, the same edit id), play status (text and reviewPending in --json); tests/play-not-sent-for-review.test.ts (fake Play refuses validate / commit once).

73. Guest-play apps rejected as a login wall

  • Symptom: Play rejects a game that has no sign-in under "Play Console Requirements: provide valid login credentials". The config said reviewAccess: { signInRequired: false } and Sign in details answered "All functionality in my app is available without any access restrictions".
  • Cause: the reviewer's screenshot was the first-run screen: an empty required player-name field, the Continue button disabled until a name is typed, and an "I Have a Code" link that opens a transfer / recovery code dialog. To a reviewer an empty field plus a disabled button plus a code entry looks like a sign-in, and "no restrictions" gave them nothing to follow.
  • Do (console): write English guest steps into reviewAccess.instructions (keep signInRequired: false; at most 500 characters), e.g. "No account needed. Open the app, the player name is pre-filled: tap Continue. 'I Have a Code' is optional (moves progress from another device); skip it." storebound console plan play-sign-in-details then answers "All or some functionality in my app is restricted" with one entry named "Guest play (no account needed)", Username and Password empty and the steps in "Any other information required to access your app". The agent presses Add instructions, fills the dialog and stops: the dialog's required box "Sign in details in this declaration provide full access to all the features and content within this app, including premium or paid content" is a declaration the owner ticks (agents never tick declarations). Then the agent presses Add and Save; Send changes for review stays the owner's. App Review gets the same steps in its notes.
  • Do (in the app): pre-fill a suggested name so Continue is enabled at once, and make the code entry clearly optional ("Moving from another device? I have a code"), never the first thing a new player must do.
  • Guard: storebound check warns "Play may reject as a login wall; add guest steps" for a Play app with a first-run gate in the code (name entry, code / transfer entry, account transfer UI: SIGNALS.firstRunGate) or in-app purchases and no reviewAccess.instructions, and when the steps are longer than 500 characters (cli/lib/profile.ts). The plan's guest path is in cli/lib/console-play-plans.ts (signInDetails); tests/guest-login-wall.test.ts.

74. Remote config and remote paywalls read as hidden features (2.3.1)

  • Symptom: App Review rejects or removes an app under Guideline 2.3.1 (hidden, dormant or undocumented features), or Play flags Deceptive behavior, after a feature or a hard paywall was switched on remotely, or the reviewer saw a different paywall than the one users get.
  • Cause: remote config / feature flag SDKs (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely, PostHog feature flags, Unleash, ConfigCat, Amplitude Experiment, Split) and remote paywalls (RevenueCat Paywalls, Superwall, Adapty, Qonversion, Purchasely, Apphud) change what the app does after review, and the review notes said nothing. Paywall tests and designs are not features and are fine once App Review knows; a business-model change (soft -> hard paywall, freemium -> premium, features moved behind a subscription) or functionality enabled after review is a feature change.
  • Do: this is the developer's call; storebound only warns. Suggest a line in apple.info.reviewNotes (and reviewAccess.instructions, which also go to Play App access) that names the SDK and what changes remotely (paywall designs, prices shown, which flags), document every feature that can be switched on or off, and send a business-model change as a new review with a description that discloses what needs a paid subscription. Never write the note into the developer's config or code yourself.
  • Guard: storebound scan (and validate, which runs it; check prints it as a warning) reports remote-features for Apple (2.3.1) and Play (Deceptive behavior), medium, with the deep links, unless the review notes mention remote config, a feature flag, A/B, a paywall test, an experiment or "remotely" (remoteFeatureFindings in cli/lib/scan/rules.ts, the SDK table REMOTE_SDKS in cli/lib/scan/sdks.ts). A code obfuscator (DexGuard, iXGuard, a JavaScript obfuscator) is a low obfuscation note; ProGuard / R8 minify alone is never reported. tests/remote-features.test.ts.

75. iPhone Duo screenshots: in the console, not in the API

  • Symptom: App Store Connect shows an "iPhone Duo" tab next to "iPhone with Dynamic Island (medium display)" and "iPad 13" display", but an upload for it through the API has no display type to use (a guessed one answers 400).
  • Cause: the API's ScreenshotDisplayType (4.5.1) has no Duo value yet; only the asset library enum lists IPHONE_DUO. Duo is optional now, reportedly required from April 2027 for iOS 27.1 SDK builds.
  • Do: render and check the sets (apple.iphoneDuo: true), add them in the console's iPhone Duo tab (the skip message has the deep link), never guess an enum name. For Play point foldables to the tablet sets; there is no Duo slot.
  • Guard: IPHONE_DUO_DISPLAY in cli/shots/spec.ts (null = not in the API; regenerate with bun tools/gen-api.ts and fill in the exact names), skippedDuoSets in cli/apple/screenshots.ts, the screenshots-duo note in check / validate. tests/ppo.test.ts.

76. Header and Search Results assets are not uploaded like screenshots

  • Symptom: a header or search results image was "uploaded" but the version page shows no header, a run waited on an asset nobody had sent to review, or a re-run uploaded the same file again.
  • Cause: these slots take assets from the app's Asset Library (GET /v1/apps/{id}/assetLibrary), not a set of their own: a slot is an appAssetLibraryPlacements row (PRODUCT_PAGE_HEADER_ASSET or APP_STORE_SEARCH_RESULTS_ASSET, the version localization). An asset is reviewed only once it is in a review submission (its own item, reviewSubmissionItems with appAssetLibraryImage / appAssetLibraryVideo; the console's "Add for Review"); after processing it is "Prepare for Submission". The version page's slots offer only approved assets ("Browse approved assets", seen in a real release, 20 Oct 2026), so placing waits for Apple's approval. Library assets carry no sourceFileChecksum, a rejected asset stays rejected (uploading the same bytes again only adds a copy), and the search slot is the API's APP_STORE_SEARCH_RESULTS_ASSET (SEARCH_RESULTS_ADS_ASSET is the Apple Ads one). Seen live (19 Oct 2026): placements of a live version read back with state ACTIVE, which the 4.5.1 spec's AppAssetLibraryPlacementState does not list; never validate placement states against the spec enum.
  • Do: storebound apple creative-assets: MD5 in referenceName ("storebound "), reuse (or unarchive) a library asset with the same MD5, never re-upload a rejected file or one that failed processing twice. --add-for-review puts the unreviewed assets into the draft submission (with the first version, or alone once the app has an approved version; Submit for Review stays the owner's). An asset that is not APPROVED is reported per locale and placement as "uploaded , waiting for Apple's approval; run storebound apple creative-assets again later"; the run is an upsert, so a later run places it as soon as it is approved (an approved old asset stays shown meanwhile). --verify and validate / the report show the same waiting state. With no editable version the live version is used. Whether the API accepts a new placement on a live version is not documented: storebound places the new asset first and removes the old one only after Apple accepted it, and on a 4xx it says so, leaves the live page unchanged, gives the live version page's deep link (select it there, or create a version and re-run) and does not try the remaining live slots.
  • Guard: cli/apple/creative-assets.ts (classify, pickAsset, waitingText, creativeWaiting, processingTiming), tests/creative-assets.test.ts (fake App Store Connect: upload, wait for approval, place on a re-run, still processing, rejected / archived files, add-for-review without submitting, verify read-only with the waiting state, a refused live placement, the validate finding).

77. What's New shared with Play rejected on the App Store (2.3.10)

  • Symptom: a Mac App Store version was rejected under Guideline 2.3.10 (Accurate Metadata): its What's New came from release notes shared with Google Play and had "Google Play Games" and "Android" lines. storebound wrote it without a word (the old check was a warning in the editor only).
  • Cause: one notes source for both stores, no per-store split, and no write that refused other platforms. Google Play's Metadata policy is the mirror case (iOS, iPhone, App Store, Game Center, TestFlight in Play text).
  • Do: tag store-specific lines [play] / [apple] in shared notes (whats-new skill); storebound check must show no other platform line. A functional mention (a "Continue with Google" / "Sign in with Apple" button) is the owner's call: listing.allowPlatformMentions: [{ store, text, reason }]. Never add an entry yourself.
  • Guard: cli/lib/cross-store.ts (term lists, per locale / field / line, the allowlist, notesForStore). Refused before anything is sent, dry runs included: apple listing, play listing, play release / play upload (release notes), listing set, whats-new check / apply, the desktop editor and storebound edit (appleLocaleIssues / playLangIssues errors). check / validate blockers (other-platforms), scan rules other-platforms (2.3.10) and play-other-platforms (Play: Metadata). tests/cross-store.test.ts.

78. storebound api ... --yes piped or redirected became a silent dry run

  • Symptom: storebound api apple <write> --yes | tail (or > out.json) printed a dry run and exited 0; the owner thought it was sent. Happened twice.
  • Cause: writes need an interactive terminal; without one the gate fell back to a dry run.
  • Do: run writes without a pipe or redirect in a terminal, or use the dedicated storebound command.
  • Guard: decide in cli/api/gate.ts refuses (NOT_A_TTY): exit 1, the message on stderr, nothing sent, the dedicated command named. Without --yes a write is still a plain dry run. tests/api.test.ts.

79. A macOS (tvOS, visionOS) version was invisible next to the iOS one

  • Symptom: apple resubmit said "nothing to resubmit" while a macOS version was REJECTED with an UNRESOLVED_ISSUES submission; apple listing --version 0.8.7 refused because the iOS 0.8.7 was live, although the macOS 0.8.7 was the rejected one to fix; apple status listed versions without saying which platform.
  • Cause: one app record can have a version per platform, each with its own state and its own review submission (reviewSubmissions carry a platform). Lookups filtered on IOS only, or did not filter at all and mixed them.
  • Do: every version and review submission lookup filters by platform (filter[platform]). Commands that need one version (status --wait-review, listing, listing pull, resubmit, submit, release, screenshots) take --platform ios|macos|tvos|visionos; without it they use the platform that has the version they need, and refuse naming both when several do. Status, validate, release status, the report and the desktop show the platform next to every version; validate and release status warn about another platform's editable or rejected version.
  • Guard: cli/apple/platform.ts (pickPlatform, parsePlatform), tests/apple-platforms.test.ts (fake App Store Connect: iOS 0.8.7 live, macOS 0.8.7 rejected in UNRESOLVED_ISSUES).

80. Screenshot text and logo hugging the edges; a Mac app with no screenshots; sheet.png timing out

  • Symptom: a game's hero logo and titles touched the left / right / top edge of the screenshots (the stores crop and round the corners, it reads cramped); a game with a macOS version had no Mac screenshots (storebound had no Mac size); shots render stopped with "sheet.png ... Timeout 30000ms" on a project with 9 sizes.
  • Cause: the templates padded the text by 5 % and drew the logo 86 % wide with a negative top margin, nothing measured it; the size table only knew iPhone, iPad and Play; the contact sheet used the same flat 30 s wait as one screenshot while decoding every image of the language.
  • Do: keep the 7 % / 5 % safe margins (shots check code margin fails anything under 6 % / 4 %, measured from the page's layout boxes in report.json); set apple.mac: true for a Mac app (mac size, 16:10, APP_DESKTOP, uploaded with apple screenshots --platform macos) and capture landscape frames; the sheet's wait now scales with images, sizes and load and logs a heartbeat (or use --no-sheet).
  • Guard: marginViolations / SAFE_MARGIN and renderDims in cli/shots/lib.ts, marginIssues in cli/shots/report.ts, IMAGE_SPECS.mac and macNote in cli/shots/spec.ts, MAC_DISPLAY in cli/apple/screenshots.ts, sheetTimeoutMs; tests/mac-landscape.test.ts, tests/browser/landscape.browser.ts, the macOS case in tests/idempotency.test.ts.

81. storebound apps folders add listed the folders and added nothing

  • Symptom: storebound apps folders add (or with "$PWD") in a freshly init-ed folder printed the folder list, no error, and ~/.storebound/projects.json stayed without the folder.
  • Cause: only apps add added; apps folders ignored every word after it and listed.
  • Do: apps folders add [<folder>] is now the same as apps add [<folder>] (bare = the current folder). A folder whose config still has template values is added with a note; a folder without a config is refused with the reason and the fix (storebound init). Unknown words after apps folders are an error, never a silent list.
  • Guard: folders and templateNote in cli/apps.ts; tests/store-minimums.test.ts (apps folders add).

82. The MCP server failed to connect: spawn ENOENT, CONNECTION_CLOSED

  • Symptom: the client showed storebound (ENOENT) posix_spawn '<home>/…/bin/storebound' or "CONNECTION_CLOSED: Connection closed".
  • Cause: .mcp.json spawned ${CLAUDE_PLUGIN_ROOT}/bin/storebound directly; when that folder is gone (an old plugin cache after an update) or the variable is unset (the repo opened as a project), the spawn fails before any code runs. Under npx, the self-update could also start a second npx download before the handshake.
  • Do: .mcp.json runs sh -c: the plugin's launcher if it exists, else storebound on PATH, else npx -y storebound@latest mcp. mcp and serve skip the self-update. Nothing may print on stdout before the JSON-RPC answer (logs go to stderr).
  • Guard: MCP_LAUNCH in tools/gen-adapters.ts, cli/index.ts; tests/mcp-spawn.test.ts spawns the CLI, the launcher and the .mcp.json command with a missing plugin root and completes initialize within 10 s.

83. Uploads below the store's build minimum (Play target API level, App Store Xcode / SDK)

  • Symptom: Google Play refuses a bundle with targetSdkVersion 35 (API 36+ for new apps and updates since 2026-08-31); App Store Connect refuses an IPA built with an Xcode / SDK older than the minimum (Xcode 26 / the 26 SDKs since 2026-04-28).
  • Do: set targetSdkVersion (and compileSdkVersion) in android/variables.gradle (Capacitor), the app module's build.gradle(.kts), Flutter's android/app/build.gradle or Unity's Target API Level; build iOS with the current Xcode. scan / validate (rules play-target-sdk, apple-min-sdk from the newest IPA in build/ios/export) and play upload / play release / apple upload block before any network call, naming the requirement, date and fix.
  • Guard: the dated tables PLAY_TARGET_SDK_TABLE and APPLE_MIN_SDK_TABLE in cli/lib/store-minimums.ts (a new year is one row); tests/store-minimums.test.ts.

84. Thin app-style headlines, white backgrounds from a string palette.bg

  • Symptom: a variable Google title family (a 200..800 axis) was downloaded as its default 400 instance, so the 750 headlines rendered thin (font-synthesis is off); a custom palette with bg: '#15171C' or a gradient string rendered a white background, and white ink headlines vanished.
  • Do: run storebound shots fonts again (it fetches the weights the templates use: full axis, used range, or static weights) and shots render. Use bg as stops, one colour or a CSS gradient string; for a photo use palette.image / shots.feature.background with an overlay. Read the font-weight and contrast warnings of shots check before uploading.
  • Guard: cli/shots/weights.ts (templateWeights, fetchFaces, weightIssues), cli/shots/palette.ts (bgIssue in config validation, bgCss, contrastIssues, coverCrop); report.json weights / contrast; tests/shots-look.test.ts, tests/browser/palette-bg.browser.ts.