storebound/skills/store-pitfalls/SKILL.md

513 lines
46 KiB
Markdown

---
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 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 "too long" at fewer than 100 characters
- **Symptom:** App Store Connect rejects keywords that look short enough, mostly in Turkish, German, CJK.
- **Cause:** the 100 limit counts UTF-8 bytes, not characters ("ş" is 2 bytes, a CJK character 3).
- **Do:** shorten by bytes; drop words, not commas.
- **Guard:** `storebound check` counts bytes for keywords.
## 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/console-flow.test.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/console-flow.test.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: <rule> at <url>" 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/console-fill.test.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 <type>" and nothing clicked.
- **Cause:** the real page shows "Set Up <type>" 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 <type>" 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/console-flow.test.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/console-session.test.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: <reason>" (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
Summary's final button is "Save" (the owner's). "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`.