connect-apk-upgrade
Upgrade the pinned Connect/CommCare APK end to end — release lookup, calibrate, flip every pin, activate, verify, roll back. Use when a new APK becomes ACE's default.
SKILL.mdskills/connect-apk-upgrade/SKILL.md
GitHubConnect APK Upgrade
Move ACE's pinned CommCare Android APK from one version to the next — the whole transition, not just the constant. Manual, cross-opp, once per APK version.
Why this skill exists. Jonathan, 2026-09-05: "We need to have a clear process ACE does to bump and then make sure everything still works as expected in the new version — we had a lot of gotchas last time we upgraded because I didn't do an explicit update-version step." The bump itself is one line. What was missing was (a) an explicit enumeration of every place a version is pinned, (b) proof the new version still navigates on a device, and (c) a checklist that answers "does everything still work" afterwards. That is what this owns.
This skill is a thin orchestrator. It does not reimplement calibration
(selector-map-calibrate), reactive selector repair (selector-map-heal), or
version comparison (lib/ccz-min-version.ts). It sequences them and adds the
pin-flip + activation + verification they all omit.
Inputs
| Source | Artifact | Used for |
|---|---|---|
| Operator | Target APK version (e.g. 2.64.0) |
everything downstream |
| Live | github.com/dimagi/commcare-android releases |
does the release exist, and does an asset resolve |
| Static | lib/apk-pin-sites.ts (via its test) |
the machine-discovered pin-site list |
| Static | mcp/mobile/selectors/connect-<old>.yaml |
the map the new one is seeded from |
| Tool | bin/ace-doctor selector_map_currency |
BEFORE/AFTER state |
Products
mcp/mobile/selectors/connect-<new>.yaml— a live-calibrated map (produced byselector-map-calibrate, not transcribed).docs/mobile-calibration/connect-<new>-<date>.md— the calibration report.docs/mobile-atlas/connect-<new>.md— the surface atlas for the new version.- Every pin site flipped in ONE commit (§ Step 5).
ACE/_common/connect-screenshots/<new>/(Drive) — the common training screenshot pool for the new version, produced bycommon-screenshot-capture(§ Step 7b).docs/mobile-calibration/connect-<new>-upgrade-verification.md— the § Verification checklist, filled in.
Preconditions (restore, don't adapt)
Per CLAUDE.md § Phase preconditions are restored, not adapted:
- No
/ace:runis in flight on this machine. This skill runs/ace:setup --force-envand forces a full Claude Code restart — both of which a live run owns. Ask; do not probe-and-guess. - A device is reachable and is yours to wipe.
mobile_ensure_avd_runningcold-boots with-wipe-dataevery dispatch, so it kills whatever is running. AVD contention is real — as of 2026-09-05mobile_diagnosereported 11 other liveace-mobileMCPs sharing 2 AVDs across 2 macOS accounts. Serialise the calibration, and read a dead-looking device as probably a peer's, not as broken. - Never assume the adb port. It is ALLOCATED (
mcp/mobile/port-allocator.tswalks upward from 5037; 5038 was live on 2026-09-05). Askmobile_diagnosefor the port + serial. Never probe with bareadb— it starts a daemon on any port it is pointed at, so a port scan manufactures the servers it then reports. To find listeners by hand:lsof -nP -iTCP -sTCP:LISTEN | awk '$1 ~ /^adb/ {print $9}'. - A Learn-NOT-complete opportunity for the Learn-dependent states. Learn
completion is one-way per
(test user, opportunity)and is server-side — a device wipe does NOT reset it (jjackson/ace#568). Consuming one to "diagnose" costs a fresh/ace:run.
Process
Step 0 — Record the BEFORE state
Do this first; the rollback and the verification both diff against it.
bin/ace-doctor # capture the whole selector_map_currency block
Record apk_version, pin, code_default, newest_map, rows_verified,
rows_unverified, unresolved_selectors. Then record the live device's
version, so "what we were on" is a measurement rather than a memory:
# serial + adb port come from mobile_diagnose — do NOT assume 5037
adb -s <serial> shell dumpsys package org.commcare.dalvik | grep versionName
Step 1 — Prove the release resolves to a downloadable asset
Before editing any pin. Dimagi has renamed the release asset at least three
times, and mcp/mobile/client.ts probes the three known conventions in order:
| APK | Asset name |
|---|---|
| 2.62.0 | app-commcare-release.apk |
| 2.63.0 / 2.63.1 | commcare-<v>-release.apk |
| 2.63.2 | commcare-<v>.apk |
| 2.64.0 | app-commcare-release.apk — reverted to the 2.62.0 form |
The naming is per-release and NON-MONOTONIC. Do not infer it from the
version. This table used to end | 2.63.2+ | commcare-<v>.apk |, which
implied newest-convention-always-wins. Measured 2026-09-06, that is false —
2.64.0 went back to the oldest form, and resolves only on the THIRD candidate:
B=https://github.com/dimagi/commcare-android/releases/download/commcare_2.64.0
curl -o /dev/null -w '%{http_code}\n' -L $B/commcare-2.64.0.apk # 404
curl -o /dev/null -w '%{http_code}\n' -L $B/commcare-2.64.0-release.apk # 404
curl -o /dev/null -w '%{http_code}\n' -L $B/app-commcare-release.apk # 200
So every convention stays in the probe list forever; a cleanup that drops
"legacy" forms would break the CURRENT pin. Enforced:
test/mcp/mobile/apk-asset-conventions.test.ts.
The 2.63.2 form was missing from that probe list until 2026-07-25, so pinning a
published release failed with APK_DOWNLOAD_FAILED. A pin whose asset does
not resolve is indistinguishable from a network fault at the point it bites.
gh release view "commcare_<new>" -R dimagi/commcare-android \
--json isDraft,isPrerelease,assets \
--jq '{isDraft, isPrerelease, assets: [.assets[].name]}'
Halt unless ALL of:
- the tag exists (a 404 here is the whole finding — do not proceed),
- at least one asset name ends in
.apk. This is the real test, and it is strictly stronger than "is it published". - at least one
.apkasset matches one of the conventions above.
Do NOT gate on isDraft alone. This step used to say "a draft release has
no assets — mcp/mobile-server.ts records 2.63.3 as exactly this: a GitHub
draft." Re-checked 2026-09-06, that is false:
$ gh release view commcare_2.63.3 -R dimagi/commcare-android \
--json isDraft,isPrerelease,assets
{"isDraft":false,"isPrerelease":false,"assets":["app-lts-release.aab"]}
2.63.3 is published; its only asset is an .aab (an App Bundle, which
adb install cannot take). So it is still unpinnable — for a different reason
than the one recorded. Anyone re-deriving the old rule would read
isDraft: false and pin it. The .apk-asset check catches both cases; the
draft check catches neither reliably.
If no asset matches any known convention, Dimagi renamed it again: add the new
form to the candidateUrls list in mcp/mobile/client.ts in this PR, with
a row in OBSERVED_CONVENTIONS in the test above, before continuing.
Step 1b — Diff the two APKs' RESOURCE TABLES, before any device time
This is the step that catches the class Step 2 is structurally blind to. Everything downstream hunts changed selectors: a rename, a removal, a value that drifted. But an APK can rename nothing this map uses and still break every recipe — by ADDING A SCREEN. 2.64.0 is the proof. Zero id drift across the whole map, and registration still stranded, because a new PersonalID email step was inserted mid-flow (ace#2029). "No id drift" is not "no flow drift."
Two comms over the two resource tables name the change in one command, for
free, before an emulator is booted:
AAPT=~/Library/Android/sdk/build-tools/35.0.0/aapt2 # any recent build-tools
ids() { "$AAPT" dump resources "$1" \
| awk '$1=="resource" && $3 ~ /^id\// {sub(/^id\//,"",$3); print $3}' \
| sort -u; }
# mktemp, never a predictable /tmp name — ace#1046: on a multi-user Mac the
# write can EACCES while the follow-up read succeeds against another
# account's stale file, and the diff is then plausible and wrong.
OLD=$(mktemp "${TMPDIR:-/tmp}/ace-ids-XXXXXX.txt")
NEW=$(mktemp "${TMPDIR:-/tmp}/ace-ids-XXXXXX.txt")
ids "$TMPDIR/ace-mobile-apk-cache/commcare-<old>.apk" > "$OLD"
ids "$TMPDIR/ace-mobile-apk-cache/commcare-<new>.apk" > "$NEW"
comm -13 "$OLD" "$NEW" # ADDED in <new> <- the new surfaces
comm -23 "$OLD" "$NEW" # REMOVED in <new> <- what may break
Commit both lists next to the walk's ui-dumps (docs/mobile-atlas/evidence/ connect-<new>/resource-ids-<v>.txt) — every "this id is 2.64.0-only" claim in
the map cites them, and a claim whose evidence lives in $TMPDIR is not cited.
(The APKs land in that cache the moment Step 2.0 downloads them; client.ts
caches by version, so both are usually already there.)
Read the ADDED set for clusters, not individuals. On 2.64.0 it named the whole change outright before anyone touched a device:
personalid_email_skip_button, personalid_email_continue_button, email_text_value,
personalid_email_verify_button, personalid_email_resend_button, otp_code_view,
action_personalid_backupcode_to_personalid_email <- the nav graph says it too
action_* entries are Navigation-component destinations, so they hand you the
transition by name — backupcode -> email is a flow change stated in the
resource table.
Then, for every cluster, ask the question the selector diff cannot: does a
recipe walk through where this was inserted? Write the answer down. A new
cluster on a surface no recipe visits is a note; one inserted mid-registration
is blocks-e2e.
Also diff the REMOVED set against the map you are about to seed — a row whose id
is gone in <new> must not be carried over as if it still resolved.
Step 2.0 — Get the new APK ONTO the device, and verify it landed
Do this before Step 2, not after Step 5. Nothing else in this skill installs
the APK, and calibrating the old one is silent: the bootstrap reports success and
the branch says <new> while the device runs <old>.
The reason is the ordering trap Step 7 describes, seen from the other end.
mobile_ensure_avd_running installs the version named by
ACE_CONNECT_APK_VERSION in the INSTALLED .env, which the live MCP
subprocess read at its own startup. Editing DEFAULT_APK_VERSION in the repo
changes nothing about a device walk in this session. So swap it out of band:
# serial + adb port from mobile_diagnose — never assume 5037
export ANDROID_ADB_SERVER_PORT=<port>
adb -s <serial> uninstall org.commcare.dalvik
adb -s <serial> install -r "$TMPDIR/ace-mobile-apk-cache/commcare-<new>.apk"
# VERIFY — this line is part of the step, not a courtesy
adb -s <serial> shell dumpsys package org.commcare.dalvik | grep versionName
If the cache has no commcare-<new>.apk yet, one mobile_ensure_avd_running
downloads it (that also exercises Step 1's asset resolution end to end).
Re-enable Google Play Services first, and use the command that exists.
mobile_ensure_avd_running deliberately leaves GMS disabled so in-app
face capture falls back to ManualMode. PersonalIdActivity then declines to
render, showing an "Enable Google Play services" AlertDialog with a single
ENABLE button — which reads as broken registration rather than a device setting.
Observed, not predicted: repro in the 2.64.0 walk, 2026-09-06, ace#1997 —
docs/mobile-calibration/connect-2.64.0-2026-09-06.md finding 2.
adb -s <serial> shell pm enable com.google.android.gms
pm enable-user does not exist (Unknown command); only disable-user
does. The pair is asymmetric, and mcp/mobile/backends/avd.ts already uses it
correctly — copy from there, not from muscle memory.
Step 2 — Seed the new map and calibrate it against a live device
Copy
mcp/mobile/selectors/connect-<old>.yaml→connect-<new>.yaml, set itsapk_version:to<new>, and mark every rowunverified: true.The copy is a SCAFFOLD, never a shipped answer. The 2.63.0 map was copied unverified from 2.62.0 and shipped as a permanent placeholder; the drift was only found when a device walk burned wall-clock (jjackson/ace#591/#593). CLAUDE.md § close the loop to the source of truth: selector values are device truth. Do not transcribe from a sibling version, and do not back-copy a newly calibrated row into an older map to make a test pass.
Run
selector-map-calibrateagainst<new>. It owns Steps 0–7: the cold-boot, the 10-state walk, the ui-dump harvest,scripts/probe-atlas-drift.ts, the map reconciliation, the recipe migration off raw resource-ids, and the on-device re-run. Do not reimplement any of it here.Serialise it (§ Preconditions 2). Its Step 6 — re-running each migrated recipe on-device — is the non-negotiable one.
This delegation is UNEXERCISED as of 2026-09-06. The 2.64.0 calibration was hand-driven (
adb shell uiautomator dumpper surface) because a dump per surface was what the harvest needed.selector-map-calibratemay well work; nobody has shown that it does. Treat a failure inside it as a finding about the delegation, file it, and fall back to the hand-driven loop rather than abandoning the calibration —docs/mobile-calibration/connect-2.64.0-2026-09-06.mdis a worked example of the manual path and its output shape.Its product is
docs/mobile-calibration/connect-<new>-<date>.md. If it records residual gaps, they are carried into § Verification, not dropped.
Step 2b — Cover the version-upgrade prompt (dimagi-internal/ace#1998)
This screen appears exactly during a version transition, which is why it is this skill's job and not calibration's. When CommCare's minimum exceeds the installed APK it interposes a soft gate instead of launching the app, and there is no selector coverage for it. Live ui-dump, APK 2.63.2:
org.commcare.dalvik:id/prompt_title
"The application requires CommCare version 2.64.0. You are currently running 2.63.2."
org.commcare.dalvik:id/action_button "UPDATE COMMCARE VIA THE PLAY STORE"
org.commcare.dalvik:id/do_later_button "I'LL UPDATE LATER" <-- dismisses it
Today the claim recipe falls through to its nsv_home_screen assertion and
captures the failure under claim-START-HANDOFF-WEDGED-issue629 — a label for
a different class (#629 is the INERT btn_start handoff, where the launch
never fired; here the launch worked and CommCare deliberately gated it).
During an upgrade, add:
- Rows in
connect-<new>.yaml:commcare-version-prompt-title,commcare-version-update-later(.../do_later_button),commcare-version-update-now(.../action_button). - A guarded
runFlow when visible: <version-prompt-title>inconnect-claim-opp.yaml,learn-launch.yamlanddeliver-launch.yaml, ahead of thensv_home_screenassertion, that captures under its own label and fails — see the next paragraph. - Its own capture label, so the two causes are never conflated again. In
connect-claim-opp.yamlthe...issue629capture must ALSO become guarded onnotVisible: <version-prompt-title>; adding the new branch without excluding the old one leaves both reachable on the same screen.
RECOGNISE the gate; do NOT dismiss it. This step originally said the branch
should tap do_later_button. Implemented 2026-09-06 (ace#1998) as
recognise-and-fail instead, deliberately:
- That
do_later_buttonexists is recorded device evidence. What dismissing it then permits is not — no ACE run has observed the post-dismiss state. "The gate is soft, so a walk need not die here" is an inference from the button's presence, not an observation, and encoding it is the mirror image of the thing CLAUDE.md § a guard that PREDICTS another system's rejection must cite a reproducer forbids. - If dismissing does let the walk proceed, it proceeds on a runtime the CCZ itself declares unsupported — and Phase 6 then mints screenshots, QA verdicts and a training deck from it. False green is worse than a clean halt. That is the CLAUDE.md "correctness-skip" footgun: reading one system's signal and declining to act on a transition a later step depends on.
- The gate is the authoritative signal that the pin is wrong. Auto-dismissing suppresses the single loudest correct message in the system.
Revisit ONLY with an on-device observation of the post-dismiss state. Until then the remedy is the pin (ace#1997), and the value delivered here is that the failure is named correctly — which was the whole of ace#1998's complaint.
Evidence class for this step. The selector VALUES are device truth (take
them from a real ui-dump; the ace#1998 body carries one for 2.63.2). Which
branch a guard selects and how a capture is NAMED are static structure — a
unit test over the recipe YAML is complete evidence for those, and
test/mcp/mobile/version-gate-recognition.test.ts is that test. If you extend
this to tap anything, the tap is device truth again: stage it via
ACE_MOBILE_STATIC_RECIPES_DIR=<repo>/mcp/mobile/recipes/static plus a full
Claude Code restart (playbook/integrations/mobile-integration.md § Validating a palette fix pre-merge) — a palette staged next to the recipe is shadowed
by the palette dir and silently unused. If you cannot reach a device, merge
with the residual named in the PR (CLAUDE.md § the device gate is a
PREFERENCE), and say exactly what would falsify it.
Note this is a mitigation. The root cause of the gate is the CCZ-vs-APK mismatch in dimagi-internal/ace#1997.
Step 3 — Prove every static recipe still navigates
Calibration re-runs the recipes it migrated. An upgrade must clear all of them, because a recipe that touched no migrated row can still break on a surface the new APK reshaped.
For every file in mcp/mobile/recipes/static/:
mobile_validate_recipewithapkVersion: <new>— lint must stay clean (watchrunFlow-guard-scope-mismatch).mobile_resolve_selectorswithapkVersion: <new>— no unresolved${SELECTOR:...}placeholders.- Run it on-device as part of at least one full journey (registration →
claim → Learn → Deliver). A statically-resolved recipe that was never re-run
is still a guess about whether the recipe acts correctly — substitution
into nested
below:/when:positions has bitten us (jjackson/ace#663).
Record per recipe: validated / resolved / ran-on-device / not-reached (reason). "Not reached" is an honest residual; a silent omission is not.
Step 4 — Add the atlas entry
Write docs/mobile-atlas/connect-<new>.md from the calibration walk's dumps.
test/mcp/mobile/static-palette-health.test.ts keys the expected atlas filename
off DEFAULT_APK_VERSION, so Step 5 goes red without it — by design.
Step 5 — Flip every pin, atomically, in one commit
Enumerate from a live scan, never from memory. lib/apk-pin-sites.ts
discovers the sites; test/lib/apk-pin-sites.test.ts fails if a site exists
that this checklist does not name, and fails if any pin disagrees with any
other. That test is the reason this list cannot rot — a fourth pin site in a
new syntax trips its suspect scan.
npx vitest run test/lib/apk-pin-sites.test.ts # the enumeration, machine-checked
pin — MUST all flip together:
| Site | Form |
|---|---|
mcp/mobile/client.ts |
export const DEFAULT_APK_VERSION = '<v>' |
mcp/mobile-server.ts |
apkVersion: z.string().default('<v>') — twice (mobile_validate_recipe, mobile_resolve_selectors) |
mcp/mobile/recipe-resolver.ts |
prepareRecipeForMaestro's apkVersion default parameter |
scripts/probe-atlas-drift.ts |
process.env.ACE_CONNECT_APK_VERSION || '<v>' |
doc-claim — prose that asserts what the default IS; flips or it lies:
| Site | Claim |
|---|---|
CLAUDE.md |
(default APK <v>) in the ace-mobile bullet |
playbook/integrations/mobile-integration.md |
ACE_CONNECT_APK_VERSION (default <v>) |
doc-example — review, may legitimately lag (illustrative manifest
snippets whose real value is read from the device at capture time):
skills/connect-baseline-screenshots/SKILL.md,
skills/common-screenshot-capture/SKILL.md. Only the prose snippets in those
two files may lag. The pool they produce may not: it is a family member,
listed in the next paragraph.
Version-keyed artifact FAMILIES — an upgrade ADDS a member, never rewrites
one: mcp/mobile/selectors/ (connect-<v>.yaml, each declaring its own
apk_version:), docs/mobile-atlas/ (connect-<v>.md),
templates/training-deck/_common/composites/ (connect-<v>.yaml, the composited
pool frames' spec, § Step 7b), and the Drive
common screenshot pool ACE/_common/connect-screenshots/<v>/ (manifest.yaml
- PNGs). Training decks and FLW guides resolve that pool by the pinned version,
so a pin flip without a matching pool leaves every
ref: _common/platform-setupslide and every common-navigation guide step with nothing to show. The pool is not in git, so the pin-site test cannot see it. § Step 7b produces it, andbin/ace-doctor'scommon_screenshot_poolprobe fails while it is missing. That is how 2.64.0 shipped with no pool for six weeks: the pool was listed only as adoc-example(dimagi-internal/ace#3060).
vault — flips by hand, outside git: ACE_CONNECT_APK_VERSION=<v> in
ACE's Canopy vault on canopy-web (agent ace, Settings → Credentials). It is
what every machine's and runner's .env is written from, so a repo-only flip
leaves every install on the old version. Set it after the PR lands, then Step 7.
The pin-site test cannot see it.
Never sed the whole repo. 2.63.2 → 2.64.0 across the tree rewrites the
historical maps, the atlas for older versions, and every CHANGELOG entry.
Step 6 — Ship
npm test + npx tsc --noEmit green, then follow skills/shipping
(bump → PR → arm auto-merge → wait → verify it landed). VERSION collisions
are expected — bash scripts/version-bump.sh --rebase-first then
git push --force-with-lease.
State the evidence class in the PR body: which parts are device-validated (selector map, the #1998 branch, the recipe runs) and which are static (the pin flip, the pin-site test).
Step 7 — Activate, in this order
The order is the whole point, and reversing it fails silently.
Set
ACE_CONNECT_APK_VERSION=<v>in ACE's Canopy vault, then/ace:setup --force-env(it runscanopy agent env --plugin aceand keeps local-only keys likeACE_WEB_PAT_TOKEN)./ace:updatedoes not touch the installed.env, so the machine stays pinned to the old version until this runs.Then quit and reopen Claude Code — a full process restart. Every MCP server calls
dotenvConfig()at module top level and consumes the result at import, so a subprocess spawned before the.envwrite holds the old value for its whole life (ace#880: the connect MCP came up at 21:17:09,.envwas written at 21:17:54, and the registry read the stale value with a correct parser)./reload-pluginsreloads skills/agents/commands/hooks and does not respawn MCP subprocesses.Confirm the running subprocess, not the version files.
VERSIONandinstalled_plugins.jsonare on-disk facts a live session can have rewritten after spawning its children:ps -eo ppid,command | awk -v c="$PPID" '$1==c' | grep -o "0\.13\.[0-9]*" cat ~/.claude/plugins/cache/ace/ace/<dir-from-above>/VERSIONThe directory NAME can lie (it comes from
plugin.json, the contents from the marketplace clone's HEAD); theVERSIONfile inside it is authoritative.Recipes do not hot-patch their way out of this.
mcp/mobile/recipes/*.yamlare re-read per call — but from the version directory the subprocess was LAUNCHED from, not the newest on disk. Patching the newest cache dir after an update does nothing, with no symptom. Measured: a device walk ran against recipes 15 versions stale, five of them changed (ace#1500).
Step 7b — Produce <new>'s common screenshot pool
Run common-screenshot-capture against the device that now runs <new>. It
writes ACE/_common/connect-screenshots/<new>/. This step is required. The
previous version's pool is not a substitute: a deck that reads it shows the old
UI captioned as the current one, and a deck that finds no pool at all degrades to
prose without saying so.
- Bar: every alias that was
source: livein<old>'s manifest issource: livein<new>'s, or has a named reason in the manifestnote. Quarantined aliases (sync-button, ace#1832) and template assets (the PersonalID completion screens, ace#873) stay as they are. - Distinctness: Step 5.5 of that skill (
classifyPoolDistinctness) must pass before anything is published aslive. - Opp-agnostic frames:
connect-homeandclaim-oppalways show a real program, so they are published composited: the new version's real capture with only the opp strings replaced (common-screenshot-capture§ Step 5.4). Addtemplates/training-deck/_common/composites/connect-<new>.yaml. Its coverage check refuses any string the new APK added until a rule accounts for it. - If the device leg cannot run this session, say so in the verification checklist (row C6) and leave the upgrade's tracking issue open. Do not mark the upgrade done.
Step 8 — Verify (§ Verification checklist)
Run the checklist below and write it to
docs/mobile-calibration/connect-<new>-upgrade-verification.md. This is the
half the operator says was missing: the artifact that answers "does everything
still work in the new version."
Verification checklist
Each line names what it ASSERTS, so a green tick is a claim someone can check.
A. The pin actually took
| # | Assert | How |
|---|---|---|
| A1 | bin/ace-doctor selector_map_currency is pass, and pin == code_default == newest_map == <new> |
bin/ace-doctor |
| A2 | The residual names exactly which rows are unverified and why (rows_unverified: 0 is the ideal, not the bar — see below) |
doctor block |
| A3 | unresolved_selectors: [] |
doctor block |
| A4 | env_freshness names no stale pid |
doctor block |
| A5 | The live MCP subprocess runs the merged version | the $PPID + inner-VERSION read in Step 7.3 |
| A6 | The device actually runs <new> |
dumpsys package org.commcare.dalvik | grep versionName |
| A7 | Every pin site agrees and no unclassified pin site exists |
npx vitest run test/lib/apk-pin-sites.test.ts |
A2 in full, because the strict reading is not reachable in one pass. A
calibration walk can only verify the surfaces it can REACH, and most of the
unreached ones need a fresh /ace:run opportunity: Learn completion is one-way
per (test user, opportunity) (#568), so the Learn assessment, the Deliver
download gate and the case list cannot be harvested off a borrowed opp. The
2.64.0 pass ended at 46 verified / 45 unverified for exactly that reason, and
demanding zero would have meant either blocking the upgrade or laundering rows —
the failure mode this skill exists to prevent.
So the NAMED-RESIDUAL form is the bar, and rows_unverified: 0 is the thing to
converge toward on the next walk. bin/ace-doctor already agrees: its
selector_map_currency probe PASSES with unverified rows and emits an info
(bin/ace-doctor:2964-2966); only UNRESOLVED selectors warn. Nothing downstream
requires zero. What is not optional is the naming — list the rows, and for each
one the surface and why it was not reached.
B. The device still works
| # | Assert | How |
|---|---|---|
| B1 | A cold boot reaches the precondition (registered demo user at Connect home) | mobile_ensure_avd_running |
| B2 | The APK downloads — the asset convention resolved | no APK_DOWNLOAD_FAILED in the boot log |
| B3 | Registration incl. the camera/photo surface completes | the cold-boot registration recipes |
| B4 | Claim → Learn → Deliver walks end-to-end on one journey | mobile_run_recipe per Step 3 |
| B5 | Every static recipe is validated + resolved + ran-on-device or has a named reason |
Step 3's per-recipe table |
| B6 | The version-upgrade prompt branch — see the caveat below; usually NOT verifiable in this session | Step 2b |
B6 in full: this one is structurally unverifiable during the very upgrade
that fixes it. The version-upgrade prompt fires only on version SKEW — the
live 2.63.2 capture read "The application requires CommCare version 2.64.0. You
are currently running 2.63.2." Installing <new> removes the precondition, so
by the time you reach the checklist the screen cannot be made to appear. Tested,
not assumed, on 2.64.0: a fresh CCZ install on a wiped device went straight to
StandardHomeActivity, and
$ grep -l "prompt_title\|do_later_button" docs/mobile-atlas/evidence/connect-2.64.0/*.xml
(no matches)
Two consequences, and Step 2b's "validate the branch on-device in this session" must be read against them:
- Capture the rows from the OLD version's failure dump BEFORE the pins flip — that is the only window in which the surface exists. Do it in Step 0.
- Then mark B6
unverifiable-post-upgradeand ship the dismissal branch as DEFENCE for the next skew, flaggedunverifiedin the map. That is an honest residual, not a skipped check. Re-manufacturing the skew (downgrading the APK under a CCZ that requires the new one) to satisfy a tick costs a device walk and proves something the next upgrade will re-prove for free.
C. The rest of ACE still agrees with the new pin
| # | Assert | How |
|---|---|---|
| C1 | npm test green |
npm test |
| C2 | npx tsc --noEmit clean |
npx tsc --noEmit |
| C3 | The atlas for <new> exists (the palette-health ratchet keys on it) |
docs/mobile-atlas/connect-<new>.md |
| C4 | app-release-qa's CCZ min-version check changed disposition — see below |
re-read lib/ccz-min-version.ts's severity table |
| C5 | The calibration report's residual gaps are carried forward, not dropped | docs/mobile-calibration/connect-<new>-<date>.md |
| C6 | The common screenshot pool for <new> exists in Drive, and every alias that was live in <old> is live (or has a named reason) and distinct |
bin/ace-doctor common_screenshot_pool is pass, plus the pool's verdict.yaml from § Step 7b |
C4 in full, because completing this skill CHANGES another check's behaviour.
lib/ccz-min-version.ts compares a released CCZ's profile.ccpr minimum
against the pinned APK, and its severity keys on whether a remedy is reachable:
required <= pinned→ok.required > pinned, a selector map coveringrequiredexists →blocker.required > pinned, no such map →warn.
So the moment this skill lands connect-<new>.yaml, every run whose CCZ requires
<= <new> flips ok, and any run still requiring MORE than the pin flips
warn → blocker, because repinning is now something an operator can
actually do. That is intended: the check gets sharper as coverage lands. Say so
in the PR, and expect Phase 3 to start halting on mismatches it previously only
warned about.
Rollback — if the new version is bad
The upgrade is designed to be reversible because the old map is never deleted.
- Stop before blaming the APK. Read
failureForensics.screenshotPath+.uiDumpPathon any recipe failure first (playbook/integrations/mobile-integration.md § Failure forensics); a-FAILURE.xmlelement tree distinguishes "wrong selector" from "wrong screen" in one step. And if something that WORKED now fails, runupstream-regression-triagebefore concluding the APK is at fault. - Fast, machine-local revert (no PR): set
ACE_CONNECT_APK_VERSION=<old>in the installed.env, then restart Claude Code (Step 7 order applies in reverse too). The old map is still on disk, so this is a complete rollback of runtime behaviour. Expectselector_map_currencyto reportfailwith "pin is stale (behind code default)" — that is the pin doing its job, and it is the signal that a repo rollback is still owed. - Repo revert: revert the Step 5 commit (all pins move back together —
which is why they shipped together). Keep
connect-<new>.yamland the atlas: they are live-measured evidence and cost a device walk. Deleting them throws away the only calibration you have and re-enters the transcribed-map class the next time someone tries. - File what you learned against
dimagi-internal/acewith the run/repro, labelledblocks-e2eif a run cannot complete. Search first, in its own Bash call. Self-heal it if the fix is bounded.
Related skills
selector-map-calibrate— the live-device state walk this skill delegates Step 2 to. Systematic, per-APK-version, manual.selector-map-heal— narrow, reactive, additive-only repair from ONE failure dump. Not an upgrade path; it never flips a pin.upstream-regression-triage— when a path that worked now fails and ACE's own code is unchanged.shipping— the bump → PR → wait → merge → verify mechanics for Step 6.
Failure modes
- Pinning a version whose asset does not resolve.
APK_DOWNLOAD_FAILEDon a published release, because Dimagi renamed the asset again (the 2.63.2 case). Step 1 is the guard, and it gates on an.apkASSET, not onisDraft— 2.63.3 is published but ships only an.aab(verified 2026-09-06), so a draft-only check would have waved it through. - Transcribing the map from the sibling version. The 2.63.0 placeholder problem (#591/#593). The copy is a scaffold; calibration is the answer.
- Reading "zero selector drift" as "nothing to do". An APK that renames
nothing can still insert a whole SCREEN into a flow every recipe walks —
2.64.0's PersonalID email step (ace#2029) did exactly that, and it is invisible
to a selector-centric check by construction. Step 1b's resource-table
commis the guard, and it costs one command. - Calibrating the OLD APK. Nothing but Step 2.0 installs the new one, and
mobile_ensure_avd_runninginstalls whatever the INSTALLED.envsays. The bootstrap reports success either way; the branch says<new>and the device runs<old>. - Assuming the release asset name progresses. It does not: 2.64.0 reverted to
2.62.0's
app-commcare-release.apk. Read it off the release (Step 1). - Flipping some pins and not others.
mobile_resolve_selectorsthen reads a different map than the runtime loads, and the disagreement is invisible until a device walk. Step 5 +test/lib/apk-pin-sites.test.tsare the guard. - Comparing versions as strings.
'2.64.0' < '2.9.0'and'2.63.10' < '2.63.9'. UsecompareVersionTriplesfromlib/ccz-min-version.ts; never write a third comparator. - Restarting before
/ace:setup --force-env. The MCP children re-read the OLD.env, and nothing surfaces it exceptenv_freshness(ace#880). - Hot-patching the newest plugin-cache dir. A live subprocess re-reads recipes from the directory it was LAUNCHED from; the patch is faithfully ignored (ace#1500).
- Consuming the Learn precondition to "diagnose". One-way per
(test user, opportunity); the only restore is a fresh/ace:run(#568). - Treating a peer's AVD as a dead device. Many concurrent
ace-mobileMCPs share few AVDs; the adb port is allocated, not fixed. Askmobile_diagnose.
Change log
| Date | Change | Author |
|---|---|---|
| 2026-10-16 | The Drive common screenshot pool is now a version-keyed family member (dimagi-internal/ace#3060). 2.64.0 shipped with no ACE/_common/connect-screenshots/2.64.0/. The skill listed the two screenshot skills only as doc-example ("may legitimately lag"), and the families list named only the git-side families. Nothing failed, so _common/platform-setup decks could not render and the tekzal/20261013-2314 FLW guide fell back to prose. Added the pool to Products and Families, a new § Step 7b that produces it, and checklist row C6, which is gated by doctor's common_screenshot_pool. |
ACE team |
| 2026-09-06 | First execution (2.64.0), and everything it produced. Premise errors the skill would have propagated: the asset-convention table implied newest-wins (2.64.0 REVERTED to the 2.62.0 name — measured, now a 4th row + test/mcp/mobile/apk-asset-conventions.test.ts); Step 1 gated on isDraft (2.63.3 is published with an .aab-only asset, so the real test is "has an .apk asset"); Step 2b prescribed TAPPING do_later_button (changed to recognise-and-fail — the button's existence is recorded evidence, what dismissing permits is not, and a dismissed gate risks false-green Phase 6 artifacts), and the ...issue629 capture was split so both causes cannot be reachable on one screen. Steps the first execution found MISSING: Step 1b (resource-table comm diff — the only step that can see an ADDED screen, which is what 2.64.0 actually shipped: zero id drift, one new registration surface, ace#2029) and Step 2.0 (install the new APK on the device + verify, before calibrating — nothing else did, and mobile_ensure_avd_running installs whatever the INSTALLED .env says; includes the pm enable GMS note, since pm enable-user does not exist). Checklist rows reframed: A2 (rows_unverified: 0 is unreachable in one pass and not required by doctor — the named residual is the bar) and B6 (structurally unverifiable during the upgrade that removes the version skew; capture it in the NEXT upgrade's Step 0). Recorded that the selector-map-calibrate delegation is still unexercised. Full review: docs/mobile-calibration/connect-2.64.0-2026-09-06.md; filled-in checklist: docs/mobile-calibration/connect-2.64.0-upgrade-verification.md. |
ACE team |
| 2026-09-05 | Initial version. Authored because the previous APK upgrade had no explicit update-version step, so pin sites were missed and nothing verified the new version end-to-end. Orchestrates selector-map-calibrate; adds the pin flip, the ace#1998 version-prompt coverage, the activation ORDER, the verification checklist, and rollback. Pin-site enumeration is machine-discovered by lib/apk-pin-sites.ts + test/lib/apk-pin-sites.test.ts, so this checklist cannot silently rot. |
ACE team |