app-release
Build and release Learn and Deliver apps on CommCare HQ so Connect can read their form schema and deliver units. Needs the HQ app ids.
SKILL.mdskills/app-release/SKILL.md
GitHubApp Release
Run workspace: when the dispatch carries a run_workspace: block, its values replace the .env ones this skill names — ACE_HQ_DOMAIN → run_workspace.hq_domain — and a partner workspace never falls back to .env. Contract: skills/_run-workspace.md.
Make a new build of each app on CCHQ and mark it as Released, so Connect can read its form schema and surface deliver units to the opportunity.
Inputs
The skill releases any HQ app, whether ACE built it this run or it is a live program's app.
Pass the values explicitly. Inside /ace:run each one defaults to the run's Phase 3 artifacts.
| Parameter | Used for | Resolver |
|---|---|---|
hq_domain |
the project space the apps live in | explicit argument; default: run_workspace.hq_domain, else app-deploy_summary.md frontmatter hq_domain |
learn_app_id / deliver_app_id |
the 32-char HQ app ids to build + release (either or both) | explicit argument; default: 3-commcare/app-deploy_summary.md frontmatter |
learn_nova_app_id / deliver_nova_app_id |
Step 3a drift check and the Step 4a build-rejection fix loop | explicit argument; default: 3-commcare/pdd-to-{learn,deliver}-app_summary.md frontmatter nova_app_id. Omit for an app with no Nova source: Step 3a then records conclusive: false and Step 4a can only surface the rejection |
deployed_at |
Step 3a deployedAt signal |
explicit argument; default: app-deploy_summary.md frontmatter uploaded_at; leave unset when unknown (the classifier handles an omitted signal, never a guessed one) |
opportunity_id |
an EXISTING opportunity wired to these apps, whose deliver units Step 7 re-syncs | explicit argument; default: run_state phases.connect-setup.products.opportunity.id when that phase has run; absent on a first build |
summary_folder_id |
where the release summary is written | default: the run's 3-commcare/ folder; outside a run, the program project's Drive folder (skills/partner-on-connect-program) |
Existing objects
Releasing a new build of an app that a live opportunity already uses changes what working FLWs run, and some of what it touches is shared:
- Workers get the new build. A released build is what CommCare offers on the next app update. Release only content you mean every worker on every opportunity wired to this app to receive.
- Deliver units are keyed per app and shared within the holding org (ace#573). Re-releasing
the same
deliver_app_idre-uses the existingDeliverUnitrows: it cannot give you a fresh payment unit. A new deliver form shows up only after the deliver-unit sync in Step 7. - The Learn passing score lives on the shared
CommCareApprow (ace#1350), not on the opportunity. A release does not change it, but every opportunity on this Learn app reads the same score, so don't "fix" it per opportunity. - Whether a live opportunity picks up a re-released build without a manual sync is
unverified. Read back with
connect_list_deliver_unitsafter Step 7 rather than assuming. Seeplaybook/integrations/connect-api.md.
Products
3-commcare/app-release_summary.md— released build IDs + version numbers per app. Outside a run the same file goes intosummary_folder_id(§ Inputs).Frontmatter contract (ace#1439).
app-release-eval § both_apps_releasedreads these keys, so they are declared here rather than left to whatever the run happened to write. This file is the sole owner of released build state —is_released,build_idandversionlive HERE and nowhere else, and every reader (app-release-eval,app-release-qa,llo-launch's app-verdict-freshness gate) reads them from here:apps: learn_app: { hq_app_id: <id>, build_id: <id>, version: <n>, is_released: true, released_at: <iso> } deliver_app: { hq_app_id: <id>, build_id: <id>, version: <n>, is_released: true, released_at: <iso> } # Step 3a's pre-build drift decision, per app (ace#1643). Present on # every run — `drift: false` is a RESULT and must be recorded, because a # skip that is not written down is indistinguishable from a check that # never ran. drift_check: learn: { drift: <bool>, conclusive: <bool>, action: <build-directly|reupload-reapply-settings-then-build>, reupload: <bool>, settings_reapplied: [...], reasons: [...] } deliver: { drift: <bool>, conclusive: <bool>, action: <...>, reupload: <bool>, settings_reapplied: [...], reasons: [...] }is_releasedandversionmirror what HQ returns from the release POST ({"is_released": true, "latest_released_version": <n>}— note HQ's field islatest_released_version; the artifact key isversion, and conflating the two is what put a phantom key in the rubric for four months).
Why this skill exists
Nova uploads apps to CCHQ as a draft only; Connect surfaces deliver units only from a released build. This skill makes the versioned build and releases it. For the full rationale, see reference.md § Why this skill exists.
Prerequisites
The CCHQ user backing ACE_HQ_USERNAME (ace@dimagi-ai.com) must
have a role with edit_apps permission on the target project space.
The standard Admin role on connect-ace-prod includes this. Verify
once via the Web Users page (/a/<domain>/settings/users/) if you hit
auth-shaped errors. Note the CCHQ Sorry, you don't have permission to do this action! banner is a generic Knockout error fallback, NOT a
literal permission verdict — see § Failure Modes before treating it as
diagnostic.
Endpoints (verified 2026-04-29)
CCHQ's URL patterns are internal UI routes, not public APIs. For how they were discovered, see reference.md § How the endpoints were discovered.
# Step 1 — Make a versioned build (creates a "build" doc; sets built_on)
GET /a/<domain>/apps/view/<app_id>/releases/ # GET first to refresh csrftoken
POST /a/<domain>/apps/save/<app_id>/ # empty body suffices
→ 200 { "saved_app": { "_id": "<build_id>", ... } } # _id IS the new build_id
# Step 2 — Release that build (toggles is_released=true)
POST /a/<domain>/apps/view/<app_id>/releases/release/<build_id>/
Body: ajax=true&is_released=true
→ 200 { "is_released": true, "latest_released_version": <n> }
CSRF: extract from the csrftoken cookie set by the GET in step 1; pass
it as X-CSRFToken header on both POSTs.
Verify via the read-only HQ API (no UI session needed):
GET /a/<domain>/api/v0.5/application/<build_id>/
Authorization: ApiKey <ACE_HQ_USERNAME>:<ACE_HQ_API_KEY>
→ { "is_released": true, "version": <n>, "built_on": "<iso>" }
If the URL pattern shifts in a future CCHQ release, use the probe procedure below to rediscover.
Probe procedure (only when the verified URLs above fail)
- Open
/a/<domain>/apps/view/<app_id>/releases/in/browsewith the CCHQ cookies imported from~/.ace/connect-session.json. - Inject an XHR/fetch interceptor (
window._cap = []; XMLHttpRequest…). - Click
Make New Version. Capture the POST URL + body. - Once a build appears, click the
Releasedtoggle (CSS-styled button, bound to$root.toggleRelease). Capture that POST URL + body.
Process
Resolve the app ids per § Inputs. Explicit arguments win.
- Inside a run,
ACE/<opp-name>/runs/<run-id>/3-commcare/app-deploy_summary.mdfrontmatter haslearn_app_idanddeliver_app_id(the 32-char HQ app IDs Nova wrote there), plushq_domain(typicallyconnect-ace-prod) andhq_base_url. - Outside a run, take them from the caller, or from the program context doc's HQ apps row.
Confirm each id exists in
hq_domain(commcare_list_apps) before building, so a stale id fails here and not mid-release.
- Inside a run,
Establish session. Use the
~/.ace/connect-session.jsoncookie jar (Connect's OAuth-via-CCHQ flow leaves valid CCHQ cookies). If expired, run/ace:connect-loginto refresh.Pre-flight: Connect-marker coverage must be clean. Releasing an app whose forms lack Connect markers succeeds at the build level, but the opportunity then gets stuck with empty deliver units (Phase 4 Step 2 on a build, a silently unpayable form on a live program). This gate is not optional, and it is a check this skill RUNS, never a file it trusts blindly:
- In a run where Phase 3 Step 1.5 already ran: read
3-commcare/app-connect-coverage_{learn,deliver}.md.status: cleanpasses.blockedhalts. - Otherwise (no coverage verdict for this app, or outside a run): run
app-connect-coveragenow against each app's Nova app id. Its auto-fix edits Nova, so any fix it makes leaves the HQ draft stale. Step 3a then reports drift and forces the re-upload before the build. With no Nova app id, check the HQ draft's form XML for the markers instead (commcare_get_form_source). If any are missing, halt: there is no source to fix them in. - Either way,
blockedor missing markers halt. Never build past them.
- In a run where Phase 3 Step 1.5 already ran: read
3a. Pre-build drift check — is the HQ draft still the Nova app? (ace#1643)
`commcare_make_build` versions the **CCHQ draft**. It does not pull
from Nova. So any Nova edit made after `app-deploy` is absent from
the released CCZ and the release still reports success. Run this
check per app, BEFORE Step 4.
**Step 6's marker verification cannot cover this.** The markers were
correct on the stale build too — it was a well-formed build of the
wrong content. **Marker integrity is not a proxy for content
integrity**, which is why this check is content-level and sits
before the build. (`app-release-eval`, ace#1643.)
1. **Gather the signals** (all cheap, all read-only):
| Signal | Source |
|---|---|
| `deployedAt` | `deployed_at` per § Inputs (in a run: `3-commcare/app-deploy_summary.md` frontmatter `uploaded_at`) |
| `novaEditedSinceDeploy` | THIS RUN's own knowledge — did any step dispatch `/nova:edit`, an eval-driven repair, or a Step 4a build-rejection fix on this app AFTER `app-deploy`? `true` / `false`; leave unset if genuinely unknown (the usual case outside a run — the content counts below still decide) |
| `novaFormCount` | `get_app({app_id: <nova_app_id>})` — one call per app |
| `novaVisibleFieldCount` | Same `get_app` response, normalized to the HQ walk's basis: **every non-`hidden` LEAF, plus every `group`/`repeat` whose label is NON-EMPTY**. Use `countNovaVisibleFields` from `lib/app-release-drift.ts` — don't do this arithmetic by hand. Two corrections, each found by a live mismatch: the walk emits no hidden fields (`user_score`, `qN_score`, `case_name`, `entity_key`, `entity_label`, … — every ACE app has them, ace#1789), and it emits a container only when that container rendered a `<label>`, so an EMPTY-labelled group compiles to a self-closing `<group ref="…"/>` and contributes nothing while Nova still counts 1 (ace#1807). **The exclusion is on the LABEL, not on "all children are hidden"** — the compiled CCZ emits the group element either way; ace#1807's original wording said otherwise and was refuted against `test/fixtures/ccz/spark-facilitator-meeting-record.xml` |
| `hqDraftFormCount` / `hqDraftVisibleFieldCount` | `run-form-walk.ts <hq_domain> <hq_app_id> --draft-only --with-fields --out-scratch` (same invocation `app-hq-settings § Step 2` documents; check `fields_available: true` before trusting the field count) — already visible-only, nothing to exclude on this side |
| `novaEditedAt` | optional — pass only if the Nova surface actually exposes a last-edited timestamp. Do NOT invent one; an omitted signal is handled, a guessed one is not |
2. **Classify** with `classifyAppDrift` from `lib/app-release-drift.ts`
(a pure function, unit-tested in `test/lib/app-release-drift.test.ts`
— the decision is not left to prose). It returns
`{drift, action, conclusive, reasons}`.
Two properties of the classifier are deliberate and must not be
"optimised" away:
- **Matching counts never clear the build-directly branch.** Of the
three drifting Deliver edits in the ace#1643 repro, only
`gps_lat`/`gps_lon` moved a count; the extended consent paragraph
and the `area_ref` constraint moved none. Counts detect drift;
they cannot prove its absence. Clearing the skip needs an
ORDERING fact.
- **Unresolved signals default to `drift: true`** (with
`conclusive: false`). A needless re-upload costs one idempotent
call; a skipped one ships the wrong app and reports success.
- **A field-count mismatch is a SOFT signal; a form-count mismatch
is not (ace#1789).** A hidden field never creates a new form, so a
form-count mismatch always forces drift. A field-count mismatch
can be a basis artifact (see `novaVisibleFieldCount` above), so
once an ORDERING fact is clear it is downgraded to corroboration
rather than forcing drift — it still forces drift when no
ordering fact resolves the question, matching the conservative
default above.
3. **On `action: 'reupload-reapply-settings-then-build'` — an ordered
TRIPLE, and the middle item is the one a naive fix omits:**
1. `/nova:upload_to_hq <nova_app_id> <ACE_HQ_DOMAIN>` — updates the
HQ app in place (`hq_app_action: updated`, id unchanged,
`left_behind: []`). Honour `app-deploy § HQ-id stability` if the
id DID change.
2. **Re-apply `app-hq-settings` and re-verify from
`GET /apps/source/`.** The re-upload REVERTS two of the four
settings that skill applies — measured on
hh-poverty-targeting/20260824-1404 (ace#1643):
| Setting | Survives a re-upload? |
|---|---|
| `appearance="acquire"` on Deliver image fields | **no — wiped** |
| per-module `display_style: grid` | **no — reverted to list** |
| app-level `use_grid_menus` | yes |
| app-level `grid_form_menus` | yes |
`app-release-qa` Step 2.8 BLOCKER-gates all three grid fields
and the `acquire` appearance, so re-uploading WITHOUT this
re-apply converts a silent stale-content bug into a hard Phase 3
halt.
After a re-upload the form uids are the 40-hex SHA-1 variant, not
32-hex. `commcare_get_form_source` / `commcare_patch_xform` and
`run-form-walk --draft-only` all accept both widths (ace#1644,
`lib/hq-unique-id.ts`) — a `unique_id` validation error or a
`resolved 0 forms` here means the plugin predates that fix, and
**MCP code needs a full Claude Code restart, not `/reload-plugins`**.
3. Only then proceed to Step 4.
4. **On `action: 'build-directly'`**, go straight to Step 4 — and say
so explicitly in the summary. A skip that is not recorded is
indistinguishable from a check that never ran.
5. **Record the decision** in `3-commcare/app-release_summary.md`
frontmatter, per app, so it is auditable:
```yaml
drift_check:
learn: { drift: false, conclusive: true, action: build-directly, reupload: false, settings_reapplied: [], reasons: [...] }
deliver: { drift: true, conclusive: true, action: reupload-reapply-settings-then-build, reupload: true, settings_reapplied: [camera_only_acquire, module_display_style_grid], reasons: [...] }
```
`reasons` is `AppDriftDecision.reasons` verbatim. When
`conclusive: false`, the body MUST say the verdict was a default
taken on missing signals, not an observation.
- For each app (learn + deliver): run the verified Step 1 + Step 2
POSTs above. Each call is idempotent on the build side: re-POSTing
/apps/save/after a release creates a new build at the next version, leaving prior builds released. So safe to re-run.
4a. If commcare_make_build throws BuildRejectedError — auto-fix
loop with the Nova architect. This loop fixes the bad XForm at the
source (the Nova app) and retries. For origin, see reference.md
§ BuildRejectedError auto-fix loop.
The MCP atom now returns
`{error: 'build_rejected', app_id, error_text, error_html, retryable: false}`
with `error_text` already HTML-stripped (see
`mcp/connect/backends/commcare.ts § BuildRejectedError`). Catch
it; do **not** treat `retryable: false` as "give up." That field
means "the same args won't succeed against this app id" — the loop
fixes the app first, then retries.
**Loop invariants (max 3 iterations per app):**
1. **Parse `error_text` into a structured form locator.** CCHQ's
canonical shape is:
Cannot make new version
"<form-name>" Form in the "<menu-name>" Menu
Error parsing XML: <parser-message>, line <N>, column <M>
Error in form "<form-name> [<lang>]": <repeat>
Extract: `form_name`, `menu_name`, `line`, `col`,
`parser_message`. The form-name is the human label (e.g. "Unique
ID check"), not the form_unique_id; it's all you have at this
point because the CCZ never built.
2. **Map `form_name` → Nova `form_id`.** Resolve the app's
`nova_app_id` per § Inputs (in a run:
`3-commcare/pdd-to-{learn,deliver}-app_summary.md` frontmatter
`nova_app_id`; with none, skip to step 6 and surface the rejection —
there is no source to fix). Call
`nova__get_app({app_id})` for the live structure and walk to
the form whose name matches `form_name`. On ambiguity (two
forms with the same name in different modules), use
`menu_name` to disambiguate. If still ambiguous, halt the loop
and surface the ambiguity in `auto_surfaced` — operator decides.
3. **Dispatch the Nova architect** via `/nova:edit <nova_app_id>`
with a brief that names the form, the line/col, the parser
message, and the most-likely fix class. Template:
Form "<form_name>" in module "<menu_name>" produces invalid
XForm XML. CCHQ rejected `make_build` with:
<parser_message>
at line <N>, column <M>
The most common cause is unencoded `<`/`>`/`&`/`"` in label,
option, hint, or constraint-message text — Nova's emitter
does not entity-encode these (tracked at
voidcraft-labs/nova-plugin issue #15). Inspect every label,
option, hint, and constraint_message in this form via
`get_form` + the per-field `edit_field` getter. For any
string that contains a literal `<`, `>`, `&`, or `"`,
replace with words ("three letters") or backticks
(`three letters`) via `update_form` / `edit_field`, then
SAVE the checkpoint with `save_work` and confirm it answered
`saved: true` — only saved changes are uploaded, and the
save is where Nova validates the whole candidate. If you
cannot save, report the work id instead of claiming a fix.
4. **Re-upload via `/nova:upload_to_hq <nova_app_id> <ACE_HQ_DOMAIN>`**
— only once the edit's return names a saved checkpoint. `/nova:edit`
(plugin v2) works in private work (voidcraft-labs/commcare-nova#693):
if it returns a `work_id` with pending changes, the fix is NOT in the
saved app and re-uploading changes nothing — treat that iteration as
a failed fix, not a retry of the build.
Pass the target project space explicitly (same as `app-deploy`,
Nova plugin voidcraft-labs/nova-plugin#12). Nova **updates the
HQ app in place**: the id is unchanged and `hq_app_action` comes
back `updated` (verified live 2026-08-18 — see
`playbook/integrations/nova-integration.md § Uploading to HQ
updates in place`). So there is normally no new id to chase and
no orphan left behind.
Read `hq_app_action` and `deployment.left_behind` anyway rather
than assuming. If the id DID change (`left_behind` non-empty, or
`hq_app_action: created` for an app already uploaded — reachable
when the linked HQ app was deleted on HQ and the call refused
with `remote_app_missing`), update the in-memory app reference to
the new `hq_app_id`, record both ids in
`3-commcare/app-release_summary.md.frontmatter.hq_app_id_history`
so Phase 4's downstream wiring (which reads the LATEST id) lines
up, and surface the change loudly.
5. **Retry `commcare_make_build` against the current HQ app id** (the
same one, unless step 4 reported it changed). If
it still throws `BuildRejectedError`, parse the new
`error_text` (it may name a different form / line / cause) and
loop. **Cap at 3 total attempts per app.**
6. **On exhaustion (3 failed attempts),** surface the FINAL
`BuildRejectedError` to the orchestrator as a `[BLOCKER]` in
this skill's returned summary with: every iteration's
`error_text`, every Nova edit dispatched, the final
`hq_app_id`, the operator-facing remediation (manual CCHQ
form-designer edit on the final orphan id, OR wait for Nova
upstream fix). Phase 3 halts. Do not silently downgrade to
success.
**Why bounded (3 attempts).** For the rationale, see reference.md
§ Why the BuildRejectedError loop is bounded at 3.
**Dispatch-depth note.** `/nova:edit` runs the Nova architect via
`Agent`, which costs a level of dispatch depth. `app-release` is
invoked from Phase 3 (`commcare-setup`), a subagent, so the architect
lands at depth 2. Moving `app-release` under more layers pushes it down;
past the budget the `Agent` tool is withheld and the loop degrades
silently instead of failing. `test/lib/agent-depth.test.ts` holds
the number. Keep the
invariant.
Verify both apps show
is_released: truevia the API.Verify the released CCZ via
commcare_download_cczprojection. Callcommcare_download_ccz(domain, app_id, build_id, include_multimedia=false)and read all three gates on the response:projected_connect_state.collision_count— MUST be0. This is a deterministic projection of what Connect's HQ→Connect sync will produce: every<learn:deliver>/<learn:module>/<learn:task>/<learn:assessment>element across every form, deduplicated by(app, slug)exactly likecommcare-connect/opportunity/tasks.py:sync_learn_modules_and_deliver_units. A non-zero count means N forms emit the sameidattribute and Connect will silently collapse them, leaving the non-first forms unwired to any payment_unit and unpaid in production.- Per-type record counts (
projected_connect_state.deliver_units.lengthetc.) MUST be > 0 for the app type — Learn apps have ≥ 1 module, Deliver apps have ≥ 1 deliver_unit. Zero means the form lacks Connect metadata at the source (Nova didn't generate it). projected_connect_state.oversized_slugs— every per-type array MUST be empty (oversized_slugs.deliver_units === [] && .learn_modules === [] && .task_units === [] && .assessments === []). Equivalently:projected_connect_state.max_slug_length <= projected_connect_state.slug_length_limit(50 today; constant on the projection so this gate is self-documenting). Connect'sLearnModule.slugandDeliverUnit.slugareSlugField()with the Django defaultmax_length=50. A slug > 50 chars raises PostgresDataError: value too long for type character varying(50)at sync time, which falls through the narrowexcept (CommCareHQAPIException, AppNoBuildException, httpx.*)incommcare_connect/program/api/views.py:102and surfaces as HTTP 500 with an empty body — Phase 4'sconnect_create_opportunity500s opaquely. Reproducer:leep-paint-collection/20260517-1515Phase 4, module name "Stage 2: Sample Preparation, Drying, Bagging, Shipment" → slugmodule_6_stage_2_sample_prep_drying_bagging_shipment(52 chars). Seedocs/learnings/2026-05-12-boundary-probe-registry.md.
On
collision_count > 0, halt with a[BLOCKER]in this skill's returned summary to the orchestrator. The BLOCKER MUST name everycollisions.deliver_units[].slug+ thekeptform + eachdroppedform so the operator can grep the source. Concrete remediation: re-build the affected app (typically Deliver) with one form per module, since Nova'scompile_appemits the module slug as<learn:deliver id>for every form in a module, and the only reliable way to get N unique slugs is N modules. Seefeedback_connect_deliver_unit_per_modulememory for full mechanism.On any
oversized_slugs.*non-empty, halt with a[BLOCKER]inauto_surfaced. It MUST list each offender as<type>: <slug> (<length> chars, in <first_seen_in>). Concrete remediation: rename the offending Nova module / deliver-unit to produce a shorter slug. Rule of thumb — keepconnect.learn_module.name/connect.deliver_unit.name≤ 40 chars so Nova'smodule_<index>_prefix + slugified name fits Connect's 50-char column. Thepdd-to-learn-app/pdd-to-deliver-appSKILL.md brief templates carry this constraint upstream ofapp-release— but oversized slugs can still leak past the brief (e.g., when an operator re-runs Nova manually), so this gate is the structural wall.On
< 1records of the expected type, halt with the same Step 3 remediation (re-run coverage; this is a Nova autobuild marker-skip, not a slug collision).Pre-0.13.81 fallback (legacy
connect_markerscount, retained for one release): if the response only hasconnect_markersand notprojected_connect_state, fall back to the old shape — count > 0 per type, but cannot detect slug collisions. Treat that path as a degraded build (operator has not pulled the projection-aware MCP).Trigger Connect's deliver-unit sync. Connect caches per-opp deliver units; after a release, the next opp create or wizard step will pick up the new schema. If an opp ALREADY exists (a re-release of an app that a live or earlier opportunity already uses), sync THAT opportunity. Do not re-run
connect-opp-setup: it creates a new opportunity rather than re-syncing the existing one. A new opportunity on the same release also cannot get a payment unit, because deliver units are shared (ace#573).No atom syncs deliver units on its own.
connect_list_deliver_unitsandconnect_create_payment_unit(s)fire the sync only as a side effect, and only when the create-payment-unit form's checkbox list is EMPTY (mcp/connect/backends/playwright.tslistDeliverUnits/createPaymentUnit). So on an opp that already has deliver units, neither one re-syncs a changed schema. Instead:- POST the sync route below for the existing opportunity at the
program org's URL, the same org
app-modifyuses (runConnectOrgs()pm_org_slug). Or clickSync Deliver Unitsin that opp's wizard. The full live-change procedure, including a new deliver unit that needs its own payment unit, isskills/app-modify. - Read back with
connect_list_deliver_unitson the same opp, and confirm every deliver-unit slug in the new release'sprojected_connect_stateis listed. - If an expected slug is still missing after the sync, halt and report it. Do not create a new opportunity to work around it.
Sync URL pattern:
POST /a/<connect_org>/opportunity/<opp_id>/sync_deliver_units/ X-CSRFToken: <from hx-headers in form HTML>opp_idtakes the opportunity UUID OR the int PK — either works, so there is nothing to discover from a wizard page. Connect resolves it withget_object_by_uuid_or_int(..., uuid_field="opportunity_id")inside@opportunity_required(commcare_connect/organization/decorators.py:141,commcare_connect/utils/db.py:66-79). The route itself iscommcare_connect/opportunity/urls.py:160under thea/<org_slug>/opportunity/prefix (config/urls.py:44).Verification status (2026-09-07, ace#2185). Route and ID-shape are verified against upstream SOURCE at the commit above; the POST was not exercised live (it is a state mutation and needs an existing opp). Falsified by: a 404 on that path, or a 404 from the decorator when a UUID is passed. The prior "int FK only, read it off the wizard's
hx-post" note was over-constrained and is retired.- POST the sync route below for the existing opportunity at the
program org's URL, the same org
Verify Connect's HQ credential can see the release. Optional but recommended sanity check before Phase 4 starts. Steps 5–6 already establish
is_releasedauthoritatively on the HQ side, and Connect stores no independent copy of that fact — it re-reads the same/a/<domain>/api/v0.5/application/endpoint. So the only additional thing this step can tell you is whether Connect's own stored HQ API key reaches<hq_domain>and resolves your app as released. That is a real Phase-4 precondition (a wrong/expired key here surfaces as a confusing opportunity-create failure), and it is all this step is for. It cannot detect a release problem Steps 5–6 missed.Do NOT load the wizard page and read the dropdown — the app
<select>ships as<option value="">Loading...</option>and is populated lazily over htmx. Call the fragment endpoint directly:GET /users/api_keys/?hq_server=<n> -> <option value="<api_key_id>">… GET /hq/applications/?hq_server=<n>&learn_app_domain=<hq_domain>&api_key=<api_key_id>hq_serveris Connect's own FK, not ACE's cluster key:1=https://www.commcarehq.org,2=https://india.commcarehq.org,3=https://eu.commcarehq.org. Usedeliver_app_domain=instead oflearn_app_domain=for the Deliver app; the view accepts either (commcare_connect/commcarehq/views.py,get_application).Match by app id, never by label text. Each
<option value>is HTML-escaped JSON{"id": "<hq_app_id>", "name": "<label>"}. Find the option whoseidequals thehq_app_idrecorded in Step 5, then judge:Observation Meaning option present, label is the bare app name released — PASS option present, label starts Unreleased -not released — investigate Steps 5–6 option absent INCONCLUSIVE — not a failure (see below) There is no
Released -prefix. Connect prefixes only the negative case:app_name = f"Unreleased - {app_name}"when no version hasis_released(commcare_connect/utils/commcarehq_api.py:74-82). Asserting the presence ofReleased -waits for a string the server never emits and would report a correctly-released app as unreleased (ace#2185).Absence is inconclusive, not a failure.
get_applicationdrops every app already bound to an active, unexpired opportunity on that domain (existing_appsin the same view). So the same app that passes here before Phase 4 legitimately vanishes from this list after Phase 4 activates the opportunity. Never treat a missing option as "the release did not propagate", and never assert "noUnreleased -prefix appears" over the whole response — that passes vacuously when the option isn't there at all.Verification status (2026-09-07, ace#2185). Live-verified read-only against
connect.dimagi.comon runbednet-check-2-visit/20260907-1126: the fragment returned 143 options, 19 carryingUnreleased -, zero carryingReleased -, with both released app ids (cbf35cb3…,0f4f2a46…) present under bare labels and matched by option-valueid. Theexisting_appsexclusion is verified against upstream source only, not observed live.If you need the wizard page itself, it is
GET /a/<connect_org>/program/<program_id>/opportunity-init— hyphen, no trailing slash (commcare_connect/program/urls.py, route nameopportunity_init;program_idcomes fromopp.yamlconnect.program.id). The trailing-slash and the old/a/<org>/opportunity/init/spellings both 404.
Connect-marker verification
The app-connect-coverage skill (Phase 3 Step 1.5) verifies and
auto-fixes Connect markers before deploy — this skill consumes its
clean | blocked verdict. Step 6's CCZ structural verification
(the projected_connect_state per-type record counts) is the
post-release boundary check.
If you hand-check the CCZ, match markers by NAMESPACE, never by a
<learn:prefix. Nova emits them as default-namespace elements — there is noxmlns:learndeclaration anywhere in the CCZ, so a literalgrep '<learn:module'returns 0 on a perfectly clean app and reads as a missing-marker halt. The wire format is:<module xmlns="http://commcareconnect.com/data/v1/learn" id="m1_role"> <assessment xmlns="http://commcareconnect.com/data/v1/learn" id="readiness_quiz"> <deliver xmlns="http://commcareconnect.com/data/v1/learn" id="vmf_visit">
commcare_download_ccz's own parser is namespace-aware and correct — prefer itsconnect_markers/projected_connect_statecounts over any hand-rolled grep. The prefixed spelling used elsewhere in this file is shorthand for the element TYPE, not a literal string to search for. (dimagi-internal/ace#680 fixed this in the siblingapp-release-qa; observed again from this file on spark-facilitator/20260813-2126.)
MCP Tools Used
Google Drive MCP:
drive_read_file,drive_update_fileace-connect MCP (CCHQ atoms, added 0.10.38+):
commcare_make_build— POST/apps/save/<app_id>/, returns build_id.commcare_release_build— POST/apps/view/<app_id>/releases/release/<build_id>/, setsis_released: true.commcare_download_ccz— GET/apps/api/download_ccz/?app_id=...&latest=release, returns CCZ bytes (base64) + Connect-marker counts grepped from the inflated form XML. These run againstACE_HQ_BASE_URL(defaulthttps://www.commcarehq.org) using the same Playwright session as the Connect atoms — Connect's OAuth-via-CCHQ flow leaves valid CCHQ cookies in~/.ace/connect-session.json, so a single login covers both services.
Prefer these atoms over raw
Bash+curl. The bash/curl path documented earlier in this file is the fallback when the URL contract shifts and a re-probe is needed. For why, see reference.md § Why prefer the MCP atoms over raw Bash + curl.
Mode Behavior
- Auto: Pre-flight, build, release, verify, update summary, proceed.
- Review: Same, but pause after step 4 and present the release status for confirmation before updating the summary.
Dry-Run Behavior
When --dry-run is active:
- Run pre-flight (read-only).
- Do NOT POST to
save/orrelease/. - Write the intended invocations and the current
is_releasedstate tocomms-log/dry-run-app-release.md. - State tracks as
dry-run-success.
Failure Modes
- HTTP 500 on
/apps/save/with full payload: usually a malformed app-state JSON (deprecated case types, missing required fields, etc). CCHQ's UI shows the genericSorry, you don't have permission to do this action!banner regardless of the underlying cause — that text is bound tobuildState() == 'error'in Knockout, NOT to a literal permission check. Don't let the banner mislead the diagnosis. Look at the actual response body or the CCHQ project's logs. - HTTP 200 from
/apps/save/but no new build appears: empty-body POST to /apps/save/ is a no-op that just reflects the saved app. You need to POST the full app-state JSON, which is non-trivial to reproduce outside the UI's Knockout viewmodel. - Broken existing build warning: CCHQ's app-versions page sometimes shows "One or more of your versions is broken". This is a benign warning about prior builds; new build creation should still work.
is_releaseddoesn't flip after release POST: probe URL pattern; the release endpoint may have moved.- The release succeeded but ships stale content (ace#1643): the
symptom is a green build + a green Step 6 whose CCZ is missing a Nova
edit made after
app-deploy. Step 3a is the guard; if the summary has nodrift_checkblock, the guard did not run. Repair is the same ordered triple Step 3a prescribes — re-upload, re-applyapp-hq-settings, rebuild, re-release. unique_idvalidation error, or--draft-only resolved 0 forms, right after a re-upload (ace#1644): CCHQ hands back 40-hex SHA-1 form uids afterupload_app_to_hq. Both widths are accepted since the ace#1644 fix (lib/hq-unique-id.ts). Seeing the old behaviour means the running MCP subprocess predates it — quit and reopen Claude Code;/ace:update+/reload-pluginsdo not respawn MCP children. | 2026-09-28 | The build-rejection fix loop requires a SAVED/nova:editcheckpoint (voidcraft-labs/commcare-nova#693). Nova now stages edits untilsave_work, and upload reads only the saved app. The edit brief now asks the architect to save and confirmsaved: true(replacing a call to the non-existentvalidate_app), and step 4 re-uploads only after a saved checkpoint — a returnedwork_idwith pending changes is a failed fix. | ACE team |