app-deploy
Upload Nova-built Learn and Deliver apps to CommCare HQ as drafts, or re-upload an edit onto the existing HQ app. Needs the Nova app ids and a project space.
SKILL.mdskills/app-deploy/SKILL.md
GitHubApp Deploy
Upload the Nova-generated apps to CommCare HQ and capture the resulting
HQ app IDs and URLs. The actual upload is performed by Nova
(/nova:upload_to_hq); this skill orchestrates the inputs, the
pre-flight, and the artifact writeback.
Scope: this skill uploads apps as draft builds. Nova does not
release apps by design. Connect's deliver-unit sync only reads released
builds, so app-release must run after this skill before any Connect
payment-unit configuration. See skills/app-release/SKILL.md for the
release flow + the App Editor permission prerequisite.
Inputs
| Parameter | Used for | Resolver |
|---|---|---|
learn_nova_app_id / deliver_nova_app_id |
the Nova apps to upload (either or both) | explicit argument; default: 3-commcare/pdd-to-{learn,deliver}-app_summary.md frontmatter nova_app_id |
hq_domain |
target project space | explicit argument; default: run_workspace.hq_domain, else ACE_HQ_DOMAIN (Step 2) |
expected_hq_app_id |
re-upload only: the HQ app this Nova app already backs, used to prove the id did not change | explicit argument; default: run_state phases.commcare-setup.products.apps.<app>.hq_app_id |
Steps 1–6 are the Phase 3 build path. To push an edit of an app that is already live, use § Re-uploading to an existing HQ app. It needs no run.
Existing objects
Re-uploading changes an HQ app that may already back a live opportunity. Nova updates the HQ app in place, so the id normally holds. Three things are still shared or one-way. Each is handled in § Re-uploading to an existing HQ app:
- The HQ app id is wired into the Connect opportunity at create time, and Connect's edit form cannot repoint it. If the id changes, the opportunity can only be fixed by delete-and-recreate.
- A Nova re-upload wipes
appearance="acquire"and per-moduledisplay_style(ace#1643).app-hq-settingsmust be re-applied before the next build. - An upload only changes the HQ draft. Workers see nothing until
app-releasebuilds and releases it.
Products
3-commcare/app-deploy_summary.md— HQ app IDs + URLs for both apps (run-bound; a standalone re-upload records into the program context doc instead)run_state.yaml.phases.commcare-setup.products.apps— run-bound only: consolidated Learn + Deliver app handoff (name, nova_app_id, nova_url, hq_app_id, hq_url, build_status) written as a single atomic block at the end of Phase 3. This skill is the sole writer ofproducts.apps; readers (ace-web summary, downstream phases) see the block populated once both apps are deployed.<!-- 0.13.116: legacy `3-commcare/app-deploy_gate-brief.md` removed. Pause-time summary at the Phase 3→4 Pause Point is composed by the orchestrator from per-skill QA + eval verdicts. -->
Process
- Resolve the Nova app ids per § Inputs: explicit arguments first,
else the app summaries' frontmatter
nova_app_idin GDrive. These are the inputs to/nova:upload_to_hq.
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 Step 4.6's commcare_delete_app passes allow_foreign_domain: <hq_domain> for a partner space — and a partner workspace never falls back to .env. Contract: skills/_run-workspace.md.
Pre-flight check. Read
ACE_HQ_DOMAIN(andACE_HQ_BASE_URL, defaulthttps://www.commcarehq.org) from the loaded environment. That's the HQ project space ACE uploads each app to. IfACE_HQ_DOMAINis unset or empty, default toconnect-ace-prod(the canonical ACE project space) with an[INFO]note inauto_surfaced— do not pause to ask. Only halt if the env loader returned an explicit non-empty value that looks malformed.Nova's
/nova:upload_to_hqtakes the target project space as an explicit trailing argument (Nova plugin ≥ the voidcraft-labs/nova-plugin#12 release). ACE always passesACE_HQ_DOMAIN— naming the space IS the upload confirmation, so Nova goes straight to the upload with no interactive prompt and no need to pre-verify Nova's bound domain. There is no longer a "watch the confirmation line / abort on mismatch" step; correctness is enforced at upload time by thedomain_not_authorizedhandling in Steps 3–4 below.
2.5. XML-escape lint. Before uploading, walk every form on the Learn
and Deliver Nova apps and verify no field has unescaped XML
metacharacters in label, hint, or option text. Specifically,
look for raw < or > (not part of a valid </> entity,
and not part of a markdown-fenced code block). XForm XML is the
upload target; an unescaped < will pass Nova's own validate_app
({"success":true}) but get rejected by CCHQ's build with
Error on line N column M: not well-formed.
& is NOT in scope — do not scan for it and never rewrite it.
Nova entity-encodes & (and non-ASCII such as an en-dash, as
–) at compile: the blueprint stores the bare &, the
compiled form XML carries & (18 escaped / 0 bare across a
compiled spark-facilitator/20260907-1120 Deliver CCZ; re-observed
on spark-facilitator/20261004-1706 Learn; verified against a
released build in ace#2150). Rewriting a blueprint & to &
therefore DOUBLE-escapes — the worker sees a literal & — and
on a multilingual app the edit_field also demotes that unit's
translations to out-of-date, silently falling back to English
(ace#2207). A partner's published name ships verbatim, & included.
Procedure:
- Load every form in both apps with
get_form({app_id, moduleUuid, formUuid})— one call per(moduleUuid, formUuid). Nova is uuid-addressed since 2026-07-31 and no tool accepts an index param (seeplaybook/integrations/nova-integration.md § The 2026-07-31 uuid-addressing migration); resolve the whole map with ONEget_app({app_id})per app — its blueprint prints[uuid …]on every module, form, and field — or read the uuids off the build summary'snova_uuids:frontmatter ifpdd-to-*-appwrote one. - For each field, scan
label,hint, and optionlabels with the regex:
(A simpler heuristic that catches the common cases: any literal/<(?![/!?a-zA-Z])|(?<!\\)>(?!\s)/i<or>. A literal&is never a hit — see above.) - For each hit, fix via
edit_field— replace<with<and>with>(or reword: "under 2kg" reads better than(<2kg)). Never touch&. The fixes are private-work edits (voidcraft-labs/commcare-nova#693): onebegin_work({app_id, request_id})per app, eachedit_fieldwith thatwork_idand a freshrequest_id(noapp_id), thensave_work({work_id, request_id, expected_revision})— which must answersaved: trueBEFORE Step 3, because the upload reads only the saved app. Contract:playbook/integrations/nova-integration.md § The private-work authoring contract. Document each change inACE/<opp-name>/app-summaries/{learn,deliver}-app-summary.mdunder a## XML-escape lint fixessection. - Note: this is a class-level preventer, not a one-time
workaround. The turmeric e2e hit it on q10 with
(<2kg)/(>10kg)in the field label. Filed asdocs/issues/nova-validate-app-misses-xml-escapes.md. Until Nova auto-escapes onadd_field/edit_field(orvalidate_apprejects), every Phase 3 run does this lint.
If the lint is skipped (e.g. Nova MCP unauthed at this point), log
app-deploy-xml-lint: skipped-nova-unauthed in run_state.yaml and add
a [WARN] to auto_surfaced.
2.9. No unsaved ACE work on either app. upload_app_to_hq reads the
SAVED app; pending private-work edits are silently excluded, with no
error anywhere. Every earlier ACE edit pass on these apps (Step 2.5,
app-connect-coverage, app-media-coverage, pdd-to-*-app post-build
steps) must have ended in a save_work that answered saved: true. Confirm
with one list_work({app_id}) per app: any entry with pending_changes > 0
is an edit that will NOT ship. Halt with [BLOCKER] unsaved-nova-work
naming the work_id — do not save it blind (it may be stale or
half-finished; the skill that opened it owns it) and do not upload past it.
Upload Learn app. Run (always pass the target project space as the trailing argument):
/nova:upload_to_hq <learn_app_id> <ACE_HQ_DOMAIN>Capture from the response:
- HQ application ID
- HQ application URL (typically
https://www.commcarehq.org/a/<domain>/apps/view/<app_id>/) - Build status (
success/errored/pending) - Any warnings
Handle
domain_not_authorized. If Nova returnserror_type: domain_not_authorized, the HQ API key saved in Nova's settings can't reach<ACE_HQ_DOMAIN>. Nova'smessagelists every space the key CAN reach — surface that as a[BLOCKER](do not silently upload to a different space) with the reachable-spaces list and a pointer to either fixACE_HQ_DOMAINor re-mint/re-paste an HQ key that reaches it. Other error types (hq_not_configured,hq_upload_failed) are also[BLOCKER]s — surface Nova'smessage.Handle
project_space_incompatible. The upload runs Nova's compatibility probe itself and refuses before any remote write when a required capability ismissingorunverified— "Couldn't confirm … Nothing has been sent" is a refusal, not a warning (Nova's owncontent/docs/project-space-compatibility.mdx). Branch on the blocker'sstate:missing→ the Step 4.5missingremedies (operator/support, or a BUILD DEFECT).unverified→ Nova could not complete its live HQ check; it is not evidence the space lacks the capability (ace#2704). Nova probes with the HQ API key stored in its own Settings —GET /api/user_domains/v1/unfiltered, then?feature_flag=<slug>— and any non-200, malformed body, or 5 s timeout settles tounverified(lib/commcare/client.tsprobeHqProjectSpaceCompatibility).get_hq_connectionansweringconfigured: trueproves nothing here: it returns STORED settings, makes no HQ call. Discriminate with ACE's own key:npx tsx "$CLAUDE_PLUGIN_ROOT/scripts/probe-hq-connect-flag.ts" <ACE_HQ_DOMAIN>. If ACE's key sees the space in both lists, the space is fine and Nova's stored-key path is what fails →[BLOCKER] nova-hq-probe-unverified; operator re-saves the HQ API key in Nova Settings (https://commcare.app → Settings → CommCare HQ), then retry. If ACE's key also fails, it is an HQ outage/auth problem — surface that instead. Do not upload to another space, and do not strip Connect blocks. Interim for a text-only change to an already-deployed app: patch the changed XForm on HQ directly (commcare_get_form_source→ edit →commcare_patch_xformwith its sha1 token) and build/release, as long as the same change is already saved in Nova (so the next successful Nova upload is a content no-op). Anything structural waits for the upload.
Handle
hq_confirmation_needed— verify against HQ, then confirm (ace#3164). Some things an app needs from the space cannot be read with any API key, so the upload refuses BEFORE any remote write and lists them inconfirmations_needed[](each{kind, subject, label, consequence, reasons}). Every Connect app hits this once per app — the confirmation is then stored on the deployment (deployment.confirmations). Seen live onbednet-check-2-visit/20261018-0554:plan-feature: data-forwardingandconnect-forwarder, on both apps. Never confirm blind — the consequence of a false "yes" is that HQ forwards no forms to Connect and no Learn or Deliver progress is ever recorded. Verify each item with ACE's own HQ session first:kind/subjectVerify with Confirm when plan-feature/ any subject (data-forwarding,lookup-tables,child-cases,advanced-case-actions, …)commcare_get_subscription({domain})(one read covers every plan feature)the returned editionis named in THAT entry'splansstring —planIncludesEdition(plans, edition)inlib/plan-feature-edition.ts(case-insensitive, whole-item; Nova's shape is e.g."Pro, Advanced and Enterprise")connect-forwardercommcare_list_connections({domain})a connection's urlishttps://connect.dimagi.com/api/receiver/andused_byincludes Data Forwardinganything else ( flat_location_fixture, any kind with no ACE probe)no ACE probe yet do NOT confirm — [BLOCKER]naming the item for the operatorA plan feature whose
plansstring does NOT name the space'seditionis a failed verification —[BLOCKER]naming the feature, itsplansand the edition — never a confirm. Do not hard-code which editions a feature needs: Nova'splansstring on the entry is the authority (ace#3206).Then re-call the upload with
confirm_plan_features: [<subject>, …]and/orconfirm_connect_forwarder: true, and record each confirmation with its evidence (edition; connection id + url) undernova_confirmationsin the Step 5 summary. A failed verification is a[BLOCKER]naming what the space lacks — do not confirm to get past it.Upload Deliver app. Same shape —
/nova:upload_to_hq <deliver_app_id> <ACE_HQ_DOMAIN>— including thedomain_not_authorized,project_space_incompatibleandhq_confirmation_neededhandling.
4.5. Verify the target space can run each app (ace#1048). Some CommCare capabilities only work when the project space supports them (a domain feature flag, most often), and an app that works in a space where a flag happens to be on silently does not in one where it isn't. Ask Nova, per app, about the space we just uploaded to:
check_project_space_compatibility({ app_id: <nova_app_id>, domain: <ACE_HQ_DOMAIN> })
Use the Nova app_id (not the HQ app id). Run it for both apps.
The tool is read-only; it never enables anything. (It replaced the
retired get_app_hq_feature_flags, which is absent from the 127-tool
surface verified 2026-09-28. upload_app_to_hq now also runs this check
itself before any remote write and refuses a blocked app with
project_space_incompatible — so reaching this step means the upload
passed it; this re-read records the result and its advisories.)
Branch on project_space_compatibility.status and each entry's state:
| result | disposition |
|---|---|
not_needed, or ready with no unverified advisories |
[PASS] — record and move on |
blocked with a missing blocker |
[BLOCKER] — the app needs a capability the target space does not have |
blocked with an unverified blocker |
unreachable after a successful upload — the upload itself refuses on it (Step 3 § project_space_incompatible). If this re-read alone returns it (the probe flaked between calls), [WARN] — record as explicitly UNVERIFIED |
any unverified advisory |
[WARN] — record as explicitly UNVERIFIED |
Advisories never block (e.g. slower large Search results); record them.
On a missing blocker: surface its friendly label, description, and
the app-specific reasons naming the configuration that caused the
requirement (Nova deliberately hides flag slugs here). Then
branch on WHERE the requirement came from — the two cases have opposite
remedies, and emitting the wrong one costs a redeploy (ace#1195):
- The capability traces to something the PDD asked for → operator
action. Tell the operator to contact
support@dimagi.comnaming the project space (Nova returnssupport_email+docs_urlfor exactly this). Do NOT strip, downgrade, or rebuild the app to dodge the flag — Nova's contract states this result "must never cause an agent to remove, undo, or avoid requested app functionality." The app stays as built; a human enables the flag. - The capability does NOT trace to the PDD — it came from default
module authoring rather than a stated requirement →
[BLOCKER]BUILD DEFECT. Do not send the operator to support. Name the capability to remove and halt for remediation. Per_app-component-library.md § connect-supported-capabilities-only, the only flag an ACE app may depend on iscommcare_connect; anything else is outside the capability budget. Many HQ flags are frozen or deprecated, meaning HQ's own source instructs staff not to enable them for new projects — so "email support" is not merely slower, it asks a human to override a stated policy for a capability the app never needed.
The canonical instance: case-search inputs on a Deliver menu require
search_claim (+ case_search_advanced for fuzzy matching), both frozen.
A case LIST needs no flag and is what the worker actually uses. If no PDD
line asked for case search, the fix is remove_search_input, not a support
ticket.
On unverified: say so plainly rather than substituting a guess. An unverified result is not a pass — it is an open question that Phase 9 must close before partner handoff.
If the tool is unavailable (older Nova plugin — see Step 0a's
nova_plugin_current), log app-deploy-flag-check: skipped-tool-unavailable
and add a [WARN]. Do not halt.
4.6. Record hq_app_action, and clean up anything in deployment.left_behind.
Nova updates the HQ app in place. The first upload to a project space
CREATES the HQ application; every upload after that UPDATES that same HQ app
document, keeping its id. hq_app_action in the result says which happened
(created | updated), and deployment.remote_revision advances on each
update. So a mid-run re-upload — an XForm escape fix, a Connect-marker patch,
a Step 4g screen split, any build-rejection iteration — does not mint a new
HQ app id and does not strand an orphan.
Verified live 2026-08-18 against connect-ace-prod: Nova app
4dd0325b… re-uploaded twice returned hq_app_action: "updated" both
times, held hq_app_id: c0d7027316bc46f8b4fdf4b47fd8d90b constant, advanced
remote_revision 6 → 8, and returned left_behind: [] each time. (This
supersedes the pre-2026-08-18 belief that CCHQ has no atomic app-update API
and that every upload mints a fresh application document — see
playbook/integrations/nova-integration.md § Uploading to HQ updates in place.)
Record hq_app_action per app in the Step 5 summary. left_behind is
normally []; treat a non-empty one as the exception it now is. It can still
happen when the linked HQ app was deleted on HQ — the call then refuses with
remote_app_missing, and the next upload creates a fresh one, superseding
the dead link.
If you need a FRESH cc_app_id, COPY the app — do not delete it
Uploading in place is the right default, but a second opportunity on the same
released Deliver app cannot get a payment unit: DeliverUnit.payment_unit is
a single FK, so one DeliverUnit backs exactly one PaymentUnit ever (ace#573).
The route previously documented for this — soft-delete the HQ app so the next
upload hits remote_app_missing — works, but it is destructive: it breaks
the app links of whatever opportunity is currently live on those apps, and on
a run carrying a per-opp OCS TaskType it takes that down too.
Copy instead. Same domain, unlinked:
commcare_linked_app_copy({ upstream_domain: <ACE_HQ_DOMAIN>,
downstream_domain: <ACE_HQ_DOMAIN>, // SAME
upstream_app_id: <existing hq app id>,
name: <UNIQUE name>, linked: false })
commcare_make_build -> commcare_release_build
That configuration is HQ's plain "Copy Application"; the linked-domain-pair +
Pro Edition (LITE_RELEASE_MANAGEMENT) requirement binds only linked: true.
Live-validated 2026-09-01 on connect-ace-prod — one Learn and one Deliver
copy, both built and released. Three reasons it beats delete-and-re-upload:
- Nothing is destroyed. The prior opportunity keeps working apps, and any per-opp OCS TaskType survives.
- It sidesteps ace#1643. A document copy PRESERVES
appearance="acquire"and per-moduledisplay_style— the two settings a Nova re-upload wipes — so theapp-hq-settingsre-apply becomes unnecessary. Verified in the built CCZ and on-device (assertNotVisible "CHOOSE IMAGE"still passed). - Fresh
cc_app_id⇒ newCommCareApprow ⇒ the postedpassing_scoreis honoured instead of silently inherited, and the new DeliverUnits are unbound so a payment unit attaches.
Two traps, both hit live: the name must be unique in the domain (the new
id is recovered by re-listing and matching on name), and a timeout on that
recovery re-list does NOT mean the POST failed — re-list before retrying, or
you create a duplicate and break name-based recovery for both copies.
So: for each id in deployment.left_behind, call
commcare_delete_app({ domain: <ACE_HQ_DOMAIN>, app_id: <left-behind id> })
This is HQ's own soft delete (it flips doc_type to <original>-Deleted
and writes a DeleteApplicationRecord), so the app stays restorable from
HQ's deleted-applications list — the operation is reversible, which is why it
is safe to do automatically. Record every deleted id in the Step 5 summary
under <app>_superseded_hq_app_id.
Guard rails. Only delete ids that came back in THIS call's
left_behind — never an id read from an artifact, and never the id you just
uploaded to. If a delete fails, log [WARN] orphan-cleanup-failed: <id>,
name it in the summary so /ace:sweep hq can finish the job, and continue
— a failed cleanup must never halt a successful deploy.
When the id DOES change, surface it loudly. Phase 4 wires the HQ app ids
into the Connect opportunity at create time (connect_create_opportunity),
and Connect's edit form does not expose those fields — so an opportunity
created against a stale id needs delete-and-recreate to repoint. Under
update-in-place this is no longer the ordinary re-upload case, but it is
still reachable: whenever left_behind is non-empty, or hq_app_action came
back created for an app this run had already uploaded, the Step 5 summary
MUST carry an explicit "this app's HQ id CHANGED — use the id above" callout,
not just the changed frontmatter value.
Write the deployment summary to
ACE/<opp-name>/runs/<run-id>/3-commcare/app-deploy_summary.md:--- hq_base_url: <ACE_HQ_BASE_URL> hq_domain: <ACE_HQ_DOMAIN> learn_app_id: <hq-app-id> learn_app_url: <hq-app-url> learn_build_status: <success|errored|pending> learn_nova_app_id: <nova-app-id> deliver_app_id: <hq-app-id> deliver_app_url: <hq-app-url> deliver_build_status: <success|errored|pending> deliver_nova_app_id: <nova-app-id> uploaded_at: <ISO-8601> # `created` on the first upload of this app to the space, `updated` # thereafter — Nova updates the HQ app in place (Step 4.6). learn_hq_app_action: <created|updated> deliver_hq_app_action: <created|updated> # Normally absent. Present only if `deployment.left_behind` came back # non-empty — one entry per id soft-deleted in Step 4.6. See the # id-CHANGED callout that step requires. learn_superseded_hq_app_id: [<hq-app-id>, …] deliver_superseded_hq_app_id: [<hq-app-id>, …] hq_feature_flags: domain: <ACE_HQ_DOMAIN> verification: <verified|not_checked> learn_missing: [<slug>, …] # [] when clean learn_unverified: [<slug>, …] deliver_missing: [<slug>, …] deliver_unverified: [<slug>, …] checked_at: <ISO-8601> ---Body: human-readable narrative including any Nova warnings and a link to each HQ app.
Write the
products.appsblock torun_state.yamlas one atomic patch. This skill is the sole writer ofproducts.apps, so thedeepupdate_yaml_filemerge replaces theappsblock cleanly while preserving sibling phase keys (status,started_at,steps).For each app, read the friendly name from the source summary's frontmatter (
title), thenova_app_idfrom the same summary, and the HQ details from this skill's upload response. Construct the Nova preview URL from thenova_app_iddirectly — Nova's working route is/build/<id>, not the legacy/apps/<id>URL that the upstream summaries' frontmatter still carries (which 404s).phases: commcare-setup: products: apps: learn: name: <from learn summary frontmatter `title`> nova_app_id: <from learn summary frontmatter `nova_app_id`> nova_url: https://commcare.app/build/<nova_app_id> hq_app_id: <from Step 3 upload response> hq_url: <from Step 3 upload response> build_status: <success | errored | pending> deliver: name: <from deliver summary frontmatter `title`> nova_app_id: <from deliver summary frontmatter `nova_app_id`> nova_url: https://commcare.app/build/<nova_app_id> hq_app_id: <from Step 4 upload response> hq_url: <from Step 4 upload response> build_status: <success | errored | pending>Build the block with
buildCommcareProducts(lib/commcare-products.ts), not by hand — it derivesnova_urland owns the componentized key. Componentized run (phases.idea-to-design.products.mode === 'componentized'): pass Phase 1'sproducts.components[]ascomponentsand each summary frontmatter'scomponents_built:list as that app'scomponents_built; the builder writes it per app (apps.learn.components_built,apps.deliver.components_built) and THROWS if an app records none or names a component Phase 1 never declared. That is the ONE name for the fact — nevercomponents_covered, never a products-level list, nevermodeon an app entry (ace#2157, operator decision 2026-10-16). Synthesized run: omit both; the builder refuses a straycomponents_built.Apply via
mcp__plugin_ace_ace-gdrive__update_yaml_filewithmerge: 'deep'on the current run'srun_state.yaml. This patch is rooted atphases.commcare-setup.products— a partial patch of thecommcare-setupphase child.two-levelwould replace the entirecommcare-setupchild wholesale, silently dropping any sibling keys already set on it (status,started_at,halt_reason,steps— e.g. when the orchestrator setstatus: in_progresson resume before this write).deeprecursively mergesappsunderproductswhile preserving every sibling at every depth. This skill is still the sole writer ofproducts.apps, but it does NOT own the rest of the phase block, so it must not clobber it. See the CLAUDE.md gotcha (update_yaml_file two-level merge replaces a phase child WHOLESALE) and jjackson/ace#572 / #587.
HQ-id stability — EVERY re-upload rewrites the artifacts (dimagi-internal/ace#1239)
The summary's ids are only trustworthy if the re-upload path owns the
bookkeeping. The obligation below binds to the ACT of re-uploading a
Phase 3 app, wherever it happens — Step 4.6's in-skill path, a Step
4g split, or a mid-Phase-3 repair re-upload performed while debugging
app-release / app-release-qa, long after Step 5 first wrote the
summary. Whoever re-uploads MUST, in the same breath:
- Rewrite
app-deploy_summary.mdfrontmatter: the new id into<app>_app_id/<app>_app_url, the old id APPENDED to<app>_superseded_hq_app_id, anduploaded_atrefreshed. If the body claims "each app was uploaded exactly once", delete that claim — it is now false and nothing else marks it stale. - Patch
run_state.yamlproducts.apps.<app>.hq_app_id(merge:deep, per Step 6). - Never leave a prose note as the only guard. A hand-written
FINAL_IDS:warning in run_state is a manual guard doing a machine's job — the live case (spark-facilitator/20260812-1635, Deliver uploaded four times) left the summary pointing at the superseded original while every downstream mutation atom would have returned 200 against the wrong app (the ace#1046 silent-wrong-target class, arriving through a stale artifact).
Consumers: run_state.yaml is the run's source of truth (CLAUDE.md);
downstream skills treat products.apps.*.hq_app_id as PRIMARY and this
summary as the cross-check — and may assert their target id is absent
from <app>_superseded_hq_app_id before mutating. See
app-hq-settings Step 1 for the consumer-side contract.
Re-uploading to an existing HQ app
This section is the standalone path for pushing a Nova edit onto an HQ app that already exists,
whether it is a finished run's app or a live program's (skills/partner-on-connect-program).
It needs no run: pass nova_app_id, hq_domain and expected_hq_app_id explicitly.
- Save the Nova edit first. Nova stages every edit in private work (
begin_work→ edits →save_work), andupload_app_to_hquploads only the SAVED app. An edit whose save did not answersaved: trueis not in the upload, and nothing will error. - Upload. Run
/nova:upload_to_hq <nova_app_id> <hq_domain>, naming the space explicitly as in Step 2. - Prove the id held. Expect
hq_app_action: updated,hq_app_id == expected_hq_app_id, and an emptydeployment.left_behind. If the id CHANGED (created, or a non-emptyleft_behind), stop and tell the owner. Every opportunity wired to the old id keeps the old app and cannot be repointed (Connect wires HQ app ids at create time). Clean upleft_behindper Step 4.6's guard rails. - Re-apply HQ settings. Run
app-hq-settingswith explicit--hq-app-id/--domain. The re-upload wiped camera-only capture and grid display (ace#1643). - Release. Run
app-releasewith explicit ids, thenapp-release-qawith--opportunity-idfor each live opportunity on the app. Its Step 4.7 re-release invariants catch a change that silently breaks Connect's form-field rules or deliver-unit ids. - Record it. In a run, rewrite the artifacts per § HQ-id stability. For a live program, append the change to the program context doc's Decisions: what changed, the before and after build versions, and the opportunities affected.
MCP Tools Used
- Google Drive MCP:
drive_read_file,drive_create_file - Nova plugin slash commands:
/nova:upload_to_hq,/nova:show - Nova MCP (private work):
list_work(Step 2.9), and for Step 2.5 fixesbegin_work→edit_field→save_work - Nova MCP:
check_project_space_compatibility(Step 4.5 — read-only; never enables anything; replacedget_app_hq_feature_flags) - ace-connect MCP:
commcare_delete_app(Step 4.6 — soft-deletes a superseded HQ app returned indeployment.left_behind; reversible from HQ's deleted-applications list)
Mode Behavior
- Auto: Pre-flight, upload, write summary, notify admin, proceed.
- Review: Same, but pause at the Phase 3→4 Pause Point (per
agents/ace-orchestrator.md § Pause Points); orchestrator presents the per-skill verdicts.
Dry-Run Behavior
When --dry-run is active:
- Run the pre-flight (it's read-only) and report the result.
- Do NOT call
/nova:upload_to_hq(this writes to a live HQ project space). - Write the intended Nova invocations (including the resolved
<ACE_HQ_DOMAIN>trailing argument) and thenova_app_idvalues resolved from the summaries tocomms-log/dry-run-app-deploy.md. - State tracks as
dry-run-success.
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-04-17 | Emit gate brief at ACE/<opp-name>/runs/<run-id>/3-commcare/app-deploy_gate-brief.md covering build status, Connectify flags, and workaround-path warnings for the Phase 3→4 gate |
ACE team (PM scout, internal-admin lens) |
| 2026-04-27 | Switch from manual HQ-UI upload to /nova:upload_to_hq via the Nova plugin. Inputs are now nova_app_id values read from the app summaries. New pre-flight check compares Nova's bound HQ project space against ACE_HQ_DOMAIN. Gate brief drops the workaround-path WARN and adds a domain-mismatch BLOCKER. |
ACE team |
| 2026-04-29 | Carve out app release into the new app-release skill (Step 2.5 of Phase 3). This skill now ends at "draft uploaded" — release is a separate, permission-sensitive step. Reason: Connect's Sync Deliver Units only enumerates units from released builds, so unreleased apps silently break Phase 4's payment-unit config. (0.10.1) |
ACE team |
| 2026-05-29 | Pass the target project space explicitly: /nova:upload_to_hq <app_id> <ACE_HQ_DOMAIN> (Nova plugin voidcraft-labs/nova-plugin#12). Naming the space skips Nova's interactive confirmation, so hands-off runs go straight to upload. Pre-flight no longer watches the confirmation line; the domain-mismatch BLOCKER is now driven by Nova's domain_not_authorized error at upload time (which enumerates the reachable spaces). |
ACE team |
| 2026-08-01 | Migrated the Step 2.5 XML-escape lint's form load to uuid addressing (ace#1132). It read "one call per (moduleIndex, formIndex)"; Nova's 2026-07-31 redeploy accepts no index param on any of its 63 tools, so the lint named an uncallable operation and would have skipped silently. Now get_form({app_id, moduleUuid, formUuid}), with the uuid map resolved from ONE get_app({app_id}) per app (or off the build summary's nova_uuids: frontmatter). Enforced by test/skills/nova-uuid-addressing.test.ts. |
ACE team |
| 2026-08-13 | Step 4.6 — clean up deployment.left_behind instead of only naming it. HQ has no atomic app-update API, so every upload_app_to_hq mints a fresh application document; Nova returns the superseded id(s) in deployment.left_behind. Nova's own guidance stops at naming them, which is the right boundary for Nova (it will not delete a user's app) and the wrong one for ACE — the superseded draft is ours, seconds old, unreferenced, and leaving it behind means every re-upload silently adds an orphan for /ace:sweep hq. Observed on hh-poverty-targeting/20260812-2034, where a screen-split re-upload stranded 07f9b7c8…. Now soft-deleted via commcare_delete_app (HQ's own reversible delete — restorable from the deleted-applications list, which is why it is safe automatically), with guard rails: only ids from THIS call's left_behind, never one read from an artifact, never the id just uploaded to; a failed delete is a [WARN] named in the summary for sweep to finish, never a halt. Adds {learn,deliver}_superseded_hq_app_id to the summary frontmatter and REQUIRES an explicit re-upload callout in the summary body whenever left_behind is non-empty — the new HQ id is what Phase 4 wires into the Connect opportunity at create time, and Connect's edit form does not expose those fields, so a stale id costs a delete-and-recreate. |
ACE team |
| 2026-08-18 | Step 4.6 — HQ uploads UPDATE IN PLACE; the fresh-app-id premise is retired. The 2026-08-13 entry above rested on "CCHQ has no atomic app-update API, so every upload_app_to_hq mints a fresh application document." That is no longer true, and the whole orphan-per-re-upload model went with it. Verified live against connect-ace-prod on 2026-08-18: Nova app 4dd0325b… re-uploaded twice returned hq_app_action: "updated" both times, held hq_app_id: c0d7027316bc46f8b4fdf4b47fd8d90b constant, advanced deployment.remote_revision 6 → 8, and returned left_behind: [] each time. So a mid-run re-upload no longer changes the id Phase 4 wires into the Connect opportunity, and no longer strands an orphan for /ace:sweep hq. The left_behind cleanup and its guard rails STAY — Nova still returns the field, and the id can still change via remote_app_missing (the linked HQ app was deleted there, so the next upload creates a fresh one). Adds {learn,deliver}_hq_app_action to the summary frontmatter; the loud callout now fires on an id that actually CHANGED (non-empty left_behind, or hq_app_action: created for an app already uploaded this run) rather than on every re-upload. |
ACE team |
| 2026-09-06 | Stop routing concerns to a gate brief that does not exist (dimagi-internal/ace#1884). 0.13.116 removed the per-skill gate-brief file class and the ace#1880 sweep removed the remaining *.md PATHS, but prose directives naming the gate brief as a DESTINATION survived in 15 files — a concern "surfaced in the gate brief" is surfaced nowhere. Repointed at the verdict YAML's auto_surfaced block, which is what the orchestrator actually renders the pause summary from. Gated by the new destination check in test/skills/gate-brief-removal-complete.test.ts. |
ACE team |
| 2026-09-28 | Private-work edits and an unsaved-work gate (voidcraft-labs/commcare-nova#693). Nova now stages every mutation until save_work, and upload_app_to_hq reads only the SAVED app — so an unsaved ACE edit ships nowhere with no error. Step 2.5's edit_field fixes run in begin_work / save_work and must answer saved: true before upload; new Step 2.9 checks list_work({app_id}) for pending changes and halts unsaved-nova-work rather than uploading past them. |
ACE team |
| 2026-10-18 | Handle hq_confirmation_needed (ace#3164). Nova's upload now refuses before any remote write until the caller confirms what no API key can read — on bednet-check-2-visit/20261018-0554 the Data Forwarding plan feature and a Connect forwarder, on both apps. The skill had no branch for it, so an unattended run had to halt or guess. Steps 3/4 now verify each item against HQ (commcare_get_subscription edition; commcare_list_connections for the connect.dimagi.com receiver) before re-calling with confirm_plan_features / confirm_connect_forwarder, record the evidence in the summary, and BLOCK on anything unverifiable. |
ACE team |
| 2026-10-19 | Verify EVERY plan-feature confirmation by edition, not just data-forwarding (ace#3206). The ace#3164 table accepted the subscription edition as evidence for data-forwarding only and BLOCKed every other plan feature, so any Deliver app with a lookup-backed select or child cases halted Phase 3. On connect-fi/20261018-1440 the Deliver app needed lookup-tables ("Standard, Pro, Advanced and Enterprise"), child-cases and data-forwarding ("Pro, Advanced and Enterprise") and advanced-case-actions ("Advanced and Enterprise") on edition Advanced — the same evidence answers all four. The row is now general: confirm a plan feature when commcare_get_subscription's edition is named in that entry's own plans string (planIncludesEdition in lib/plan-feature-edition.ts, tested in test/lib/plan-feature-edition.test.ts). connect-forwarder is unchanged; flat_location_fixture and any kind with no ACE probe stay a do-not-confirm [BLOCKER]. |
ACE team |