app-release-qa
Pass/fail structural and install-time checks on released Learn and Deliver CCZs. Use after a release, before Connect reads the apps.
SKILL.mdskills/app-release-qa/SKILL.md
GitHubApp Release QA
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.
Structural + install-time QA on the released CCZ artifacts at the end of Phase 3. Catches CCZ-marker drops, form-count drift vs. Nova blueprint, XForm parse errors, and install-time runtime binding failures at the source. No AVD, no Connect opp dependency — runs against CommCare HQ's REST API only.
Structural QA partner for app-release (no LLM-as-Judge; deterministic
pass/fail on the released artifact). A validate_app PASS + a successful
make_build / release_build is necessary but not sufficient — none of
those verify that the released CCZ artifact carries the right
structural markers. For the naming history and the three incident classes
this catches, see reference.md.
Inputs
The skill QAs any released Learn/Deliver build: this run's, or a live program's after a
re-release (app-deploy § Re-uploading to an existing HQ app). Each value is an explicit
argument first; inside /ace:run it defaults to the run's artifacts.
| Parameter | Used for | Resolver |
|---|---|---|
hq_domain |
the project space the apps released to | explicit --domain; default: run_workspace.hq_domain, else ACE_HQ_DOMAIN env (connect-ace-prod) |
learn_app_id / deliver_app_id |
HQ app ids (Learn + Deliver; either or both) | explicit --learn-app-id / --deliver-app-id; default: Phase 3 § Step 2 3-commcare/app-deploy_summary.md |
released build_id per app |
the build to QA | explicit --learn-build-id / --deliver-build-id; default: Phase 3 § Step 2.7 3-commcare/app-release_summary.md; else the app's latest released build (commcare_list_apps / the HQ application API) |
nova_app_id per app (optional) |
Blueprint structure (modules, forms, fields per form, Connect marker presence) — the canonical structural truth for cross-reference, read live with get_app({app_id}) |
explicit argument; default: the deploy summary's nova_app_id. Absent for a non-Nova app: the blueprint cross-checks record unavailable, never pass |
| programme rules | Payable-visit rules — whether camera-only photo capture is demanded (the appearance="acquire" check in Step 4) — and the stated per-module training durations (the time_estimate plausibility check in Step 4) |
default: Phase 1 1-design/idea-to-pdd.md; outside a run: the program context doc, else `--camera-only=yes |
[FIXED] instruments (optional) |
Step 4.8 question-text + option-set diff (ace#2366) | default: the markdown the PDD cites in Phase 1 inputs/ (via inputs-manifest.yaml); skipped with not-applicable when there is none |
opportunity_id + organization_slug |
Step 4.7 re-release invariants against an EXISTING opportunity | explicit --opportunity-id / --org; default: run_state phases.connect-setup.products.connect.opportunity.id |
form_field_rules |
Step 4.7a rule-path check | explicit --form-field-rules=<json> (an override); default: read live with connect_get_verification_flags({organization_slug: <program org>, opportunity_id}) (read-only, ace#3161); else run_state phases.connect-setup.products.connect.opportunity.verification.form_field_rules. Only when none of the three resolves is the rule-path check UNEVALUATED, never a pass |
externally_written_case_values (optional) |
Step 4 check 3: case-list enum values a DECLARED out-of-form writer sets (e.g. a Phase 10 close-out bulk case update), which pass as [INFO] instead of case-list-enum-drift (ace#3220) |
explicit --externally-written=<json> (outside a run: from the program context doc, quoting the PDD); default: the externally_written_case_values frontmatter of Phase 3 3-commcare/pdd-to-deliver-app_summary.md (written by its Step 4l sub-step 8). Absent ⇒ []: nothing is excused. Never derived here from residual prose |
output_folder |
where the result YAML goes | default: the run's 3-commcare/; outside a run, the program project's Drive folder |
Existing objects
This skill reads only, but on a live program its verdict is the gate before working FLWs get a
build. A Step 4.7 [BLOCKER] on a live opportunity means do NOT leave that build released.
A disappeared or added <deliver id> breaks payment (the visit errors Payment unit is not configured, and DeliverUnits are shared per app within the holding org, ace#573). A disappeared
Learn module caps every new worker below 100%. Re-release the previous build, or fix and
re-release, and tell the owner which opportunities were exposed and for how long.
Products
3-commcare/app-release-qa_result.yaml— structural verdict (see § Output schema below). Outside a run, the same file goes intooutput_folder.
No screenshots, no per-app summaries — the structural deltas live in the verdict YAML.
Process
Step 1: Read upstream artifacts
Explicit arguments (§ Inputs) win over every read below. Run-free, only the
HQ ids and build ids are required; each other input that is absent marks its
dependent checks unavailable rather than halting.
- Read
3-commcare/app-deploy_summary.md→ extract Learn HQ app id + Deliver HQ app id. - Read
3-commcare/app-release_summary.md→ extract Learn released build id + Deliver released build id. - Read both Nova blueprints via
get_app({app_id: <nova_id>})for cross-reference. (The Nova app ids are referenced in the deploy summary asnova_app_id.) - Read
3-commcare/pdd-to-learn-app_summary.md+3-commcare/pdd-to-deliver-app_summary.md→ extract each app's DECLARED module names. This is the only artifact that says what the brief asked for; the Nova blueprint says what was built, and the difference is what Step 4's module-identity check exists to see. A summary that names no modules is not a halt — Step 4 recordsdeclared_structure: unavailableand relies on the starter-module signature alone.
Inside a run, if any of these inputs are missing, halt with a structured error naming the missing artifact + the upstream skill that should have produced it.
Step 2: Download released CCZs
For each app (Learn, Deliver), call:
commcare_download_ccz({
domain: <ACE_HQ_DOMAIN>,
app_id: <hq_app_id>,
build_id: <released_build_id>,
include_multimedia: false,
})
The atom returns the CCZ as base64-encoded zip bytes inside a JSON
envelope. Decode the base64. Verify the resulting bytes start with
the zip magic PK\x03\x04. If not, halt with download-failed.
Step 3: Parse zip + suite.xml + form XMLs
For each downloaded CCZ:
- Read the zip into an in-memory file tree (
readCczTextFilesinlib/ccz-language-tables.ts). If a tool needs files on disk, extract into a NEW, EMPTY directory per CCZ (mktemp -d) — neverunzip -ointo a directory an earlier build was extracted into.unzip -odeletes nothing, so the previous build's files survive and read as this build's: that is how ace#2292 reported another opportunity's language tables in a clean CCZ. - Read
suite.xmlfrom the zip root. Parse as XML. - For each
<menu>/<entry>in suite.xml, identify the per-form XForm path (typicallymodules-N/forms-M.xml). - For each form XForm path, read + parse the XForm XML.
If any parse fails (zip malformed, suite.xml missing, XForm XML malformed), halt with the specific class:
cczunzip-failedsuite-xml-missing-or-malformedxform-parse-failed(with the form path + the parser error message)
Step 4: Structural verification per app
For each app, compute:
Form count. Count of XForm XMLs in the zip vs. count from Nova
get_app blueprint. Mismatch → halt with form-count-mismatch
(expected N, got M).
Module identity — the starter module (dimagi-internal/ace#1787).
Form count equality cannot catch this one, and that is not a near miss — it is structural. Until 2026-09-27 Nova's app-creation call seeded every new app with a placeholder module: a top-level menu "Survey" holding one form "Survey" holding one text field
question_1labelled "Question 1". (voidcraft-labs/commcare-nova#693retired that call;begin_work({new_app})creates no starter. The check stays: apps created before then can still carry the seed, and a regression would be invisible to every other gate.) That module is present in the released CCZ and in the Nova blueprint, so the two totals above are equal on both sides and the count check passes on a dirty app by construction. The Connect-marker checks below are keyed on forms that DECLAREconnect.*blocks, and the starter form declares none, so it is invisible there too. A count is blind to an extra module whose count matches; only a name/identity comparison can see it.Live on
bednet-check-2-visit/20260828-0629: the Deliver app reached release carrying the seed with every Phase 3 gate green. On a Deliver app that is worse than cosmetic — an FLW tapping that menu lands in a dead form that writes nothing and is not a payable unit.
Build the released module list (module name → its forms → each form's fields,
from suite.xml + the parsed XForms, cross-checked against the get_app
blueprint), read the declared module names out of
3-commcare/pdd-to-<learn|deliver>-app_summary.md, and classify with
auditReleasedModules from lib/starter-module.ts — a pure function,
unit-tested in test/lib/starter-module.test.ts, so the decision is not left
to prose:
npx tsx -e "import {auditReleasedModules} from './lib/starter-module.js'; \
console.log(JSON.stringify(auditReleasedModules({released, declared}), null, 2))"
It returns a CheckOutcome (lib/check-outcome.ts), so status: 'unable'
is not a pass — narrow on status before reading ok:
| Outcome | Meaning | Do |
|---|---|---|
status: 'unable' |
No module list was built — the check inspected nothing | Halt. This is a caller bug, not a clean app; a released CommCare app always has modules |
ok: true |
Ran, found nothing | Continue |
any finding with tier: 'blocker' |
The seed survived, or the app carries a module the brief never declared | Halt with starter-module-present, naming every offending module and form |
findings, all tier: 'suspect' |
Named like the seed, content drifted | Record in the verdict and continue |
Four properties of the classifier are deliberate and must not be "simplified" away:
- The signature is the seeded FIELD, not the module name. "No module named
Survey" is defeated by one rename and false-fails a PDD that legitimately
specifies a survey module.
question_1/ "Question 1" is a Nova placeholder; no PDD-derived brief names a field for its own ordinal. So the BLOCKER tier keys on a module holding exactly one form holding exactly one placeholder field, and ignores names entirely. - A name-only match is
suspect, notblocker. Module "Survey" → form "Survey" with real fields is reported for a human and does not halt. A gate that halts a clean run earns its own deletion. - A missing declared-module list SKIPS the undeclared half, it does not
fail it. The signature check still runs, and the verdict records
declared_structure: unavailableso a clean result cannot hide a comparison that never happened. - An empty module list is
unable, neverok. "Found no starter module" read off a check that inspected nothing is the blind-gate class this repo has paid for four times (ace#1332 → #1538 → #1576 → #1634).
The halt is a BLOCKER, not a [WARN]: the whole finding behind ace#1787 is
that every structural gate reported green while a dead menu shipped, and a
non-blocking note reproduces exactly that. Record the classifier's output under
starter_module in the per-app verdict block in every branch, including
unable.
Match Connect markers by NAMESPACE, never by a
<learn:prefix. Nova emits these markers 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 fires a spuriouslearn-marker-missing/deliver-marker-missing[BLOCKER] on every release. The wire format is:<module xmlns="http://commcareconnect.com/data/v1/learn" id="m0_pretest"> <assessment xmlns="http://commcareconnect.com/data/v1/learn" id="m5_posttest"> <deliver xmlns="http://commcareconnect.com/data/v1/learn" id="du_meeting">Match on local name + the
http://commcareconnect.com/data/v1/learnnamespace URI, which is exactly how Connect's ownsync_learn_modules_and_deliver_unitsmatches them. Preferred: don't re-grep at all — readprojected_connect_statefromcommcare_download_ccz, which already matches by namespace URI and is the same projection Connect's sync consumes. See jjackson/ace#680.
Learn-specific (only for the Learn app):
- Each Nova form whose blueprint declares
connect.learn_moduleMUST have amoduleelement in the…/data/v1/learnnamespace in its XForm XML (equivalently: alearn_modules[]entry inprojected_connect_state). - Each Nova form whose blueprint declares
connect.assessmentMUST have anassessmentelement in that same namespace (equivalently: anassessments[]entry). - Per voidcraft-labs/nova-plugin#7 closure (2026-05-22): these wrappers are required for Connect's HQ→Connect sync to register learn modules. Their absence is a structural defect.
- CARDINALITY, not just presence (dimagi-internal/ace#1131). Count the
forms whose blueprint declares
connect.assessment. There must be exactly one. Two or more → halt withlearn-assessment-cardinality, naming every form that carries the marker. Connect stores a singlepassing_scoreperCommCareAppandprocess_assessmentssetspassed = score >= passing_scorefor every submitted form block carryinguser_score, with any-passed semantics on every downstream surface — so a second marked form (in practice the baseline pre-test, which has the sameuser_score+ selects shape as the post-test) lets a worker who opened no module be recordedpassed=True. Zero → halt too: a PDD-specified readiness gate with no gating instrument is not releasable. This check exists here because it is the only place it can live:app_xml.pydoes not extract assessments from the CCZ, so the behaviour is submission-time and marker-presence QA is blind to it — the blueprint is the only pre-release surface that sees the count.
Mismatch → halt with learn-marker-missing (with the form path +
which marker is absent). Before halting, re-check by namespace — a
prefix-grep false negative is the single most likely cause of this
halt firing on a clean build.
time_estimate plausibility-as-hours (dimagi-internal/ace#1077).
The unit of connect.learn_module.time_estimate is HOURS — resolved
against Connect source 2026-07-30: the model help_text says "Estimated
hours" (commcare_connect/opportunity/models.py:297), the PM dashboard
renders the raw value as f"{value}hr"
(commcare_connect/opportunity/tables.py:1677-1678), ingest is a
straight int() passthrough with no conversion
(commcare_connect/opportunity/app_xml.py:107-108), and the FLW-facing
mobile app sums the raw values into "Estimated time: %d hours"
(commcare-android ConnectJobIntroFragment.kt:64-77). Nova's tool-schema
description says "Estimated minutes" — stale, filed as
voidcraft-labs/nova-plugin#36 — so an architect that obeyed the schema
description ships a 20-minute module that renders as "20 hours" on
every FLW's onboarding screen. Marker presence checks cannot see this;
the VALUE must be read.
Run the pure helper over the Learn app:
import { checkLearnModuleTimeEstimates, formatTimeEstimateReport }
from '../../lib/time-estimate-check';
const report = checkLearnModuleTimeEstimates(
projected.learn_modules.map((m) => ({
moduleId: m.slug,
timeEstimate: m.time_estimate,
budgetedMinutes: pddBudgets[m.slug], // minutes; omit when the PDD is silent
})),
);
projected is projected_connect_state from commcare_download_ccz
(its learn_modules[].time_estimate carries the CCZ marker value —
exactly what Connect's sync will store). pddBudgets comes from the
PDD: read each module's stated duration in MINUTES from the training
design (e.g. "Modules budgeted 10–20 minutes each"); when the PDD states
no duration for a module, omit the key and the helper falls back to
absolute plausibility bounds. The helper flags, per module:
missing/non-positive/non-integer—[BLOCKER].minutes-not-hours— the value tracks the module's MINUTE budget (or is an unbudgeted two-digit count): the ace#1077 signature.[BLOCKER].out-of-range— wrong vs. the budget's hour conversion (max(1, ceil(minutes/60))):[BLOCKER]when >2x expected,[WARN]otherwise.
severity: 'blocker' → halt with [BLOCKER]
learn-module-time-estimate-implausible (include
formatTimeEstimateReport(report)). [WARN]-only → record and
continue. Record per-app (Learn only) under time_estimates in the
verdict: pass | { modules_checked, violations: [...] }.
Every learn_module form reachable by every worker (dimagi-internal/ace#3036).
Connect computes Learn completion over EVERY learn_module in the app. A
marked form behind a display condition the worker fails can never be
finalized, so that worker's Learn tops out below 100% and Deliver never
unlocks. Live on kaduna-graduation/20261015-1411: 14 marked modules, 9
gated to kgrad_role = 'FLW' and 5 to 'enumerator'; an FLW finished her 9,
passed the assessment, and Connect read modules_completed_pct: 64.3 (9/14),
learn_complete: false. This skill reported 31/31 green on that build,
because every check above reads the form and only suite.xml says who can
open it. Nova compiles a module display condition onto the suite <menu>
(relevant="instance('commcaresession')/session/user/data/<prop> = '…'")
and a form display condition onto its <command>.
import { checkLearnModuleDisplayGating, formatLearnModuleDisplayGating }
from '../../lib/learn-module-display-gating';
const gating = checkLearnModuleDisplayGating({
suiteXml, // the Learn CCZ's suite.xml (Step 3)
learnModules: projected.learn_modules.map((m) => ({ slug: m.slug, firstSeenIn: m.first_seen_in })),
});
Narrow on status first — unable (no markers, no menus, or a marker's form
not found in the suite) is NOT a pass; halt and say why. A finding with
severity: 'blocker' (the condition reads a user property — role gating) →
halt with [BLOCKER] learn-module-display-gated (include
formatLearnModuleDisplayGating(gating)). warn findings (some other
condition) → record and continue. Record under learn_module_display_gating
in the Learn verdict block in every branch. Run over the kaduna CCZ above, the
helper reports 14 of 14 marked forms as blockers.
Deliver-specific (only for the Deliver app):
- Each Nova form whose blueprint declares
connect.deliver_unitMUST have adeliverelement in the…/data/v1/learnnamespace (equivalently: adeliver_units[]entry inprojected_connect_state). - Each Nova form whose blueprint declares
connect.taskMUST have ataskelement in that same namespace (equivalently: atask_units[]entry).
Mismatch → halt with deliver-marker-missing (with the form path +
which marker is absent). The same namespace re-check applies before
halting.
Case reachability, case-transaction presence, and case-list enum fidelity
(dimagi-internal/ace#977, #1688). Three assertions over artifacts this step
already holds — the unzipped CCZ's suite.xml, <lang>/app_strings.txt, and
the form XMLs. All are pure functions (lib/commcare-cli-validate.ts for the
first two, lib/ccz-enum-fidelity.ts for the third); no live Nova build, no
device, no Connect.
findUnreachableCaseLists(suiteXml)→case-list-unreachable. Returns one finding per entry that configuresdetailblocks it can never show (a module whose only session datum isfunction="uuid()"pushes no entity-selection screen). A caseless survey module (no case type, no case datum: every Learn app) is never a finding, even though HQ emits a defaultmN_case_shortdetail for it (dimagi-internal/ace#3167). An empty result on a Learn app is the expected pass. Branch on each finding'sseverity— this check is app-wide, not per-module (dimagi-internal/ace#1281):severity: 'blocker'→ halt with[BLOCKER]case-list-unreachable, naming the command id (mN-fM), thecaseType, and the inertdetailids. No entry anywhere in the app puts a selectable list on screen for that case type, so it is dead app-wide — the ace#977 "dead configuration authored to satisfy a validator" case, and Jon's 2026-07-28 invariant ("a module that declares a case type + case list should have some form that actually opens a case") is violated.severity: 'info'→ record[INFO]naming the module, its inertdetailids, andreachableVia(the sibling command that DOES render a list over the same case type), then continue — do not halt. The case type is reachable; only this module's copy of the detail is inert.
Why
infoand not a blocker: Nova makes the shape unavoidable. Asked to remove the last case-list column from exactly this module (live, on spark-facilitator/20260813-2126), Nova refused, verbatim: "Module … shows "community" cases but its Results screen has no visible fields. Every case list needs at least one visible Results field so users can tell rows apart and pick which case to open. Add or restore an identifying field such as "case_name" on Results. Nothing was changed." Nova REQUIRES ≥1 visible Results column on any module declaring a case type, so the inert detail on a registration-only module cannot be removed producer-side. Blocking on it halted Phase 3 on essentially every ACE Deliver app. Do NOT "fix" an[INFO]finding by editing the app.When it IS a blocker, remediation points at
pdd-to-deliver-appStep 4d, not at the symptom. That step callsadd_case_list_columnson every case-CREATE module with an emptycaseListConfigpurely to clear a Novavalidate_apperror — so on an app with no followup form at all, ACE authors a list over a case type nothing ever opens. The fix is to add the followup form behind an entity datum (which is also what makes the case type payable), not to paper over the validator.hasCaseTransaction(formXml)→[BLOCKER]case-transaction-missing. If the Nova blueprint declares ≥1caseWrite/case_property_onbinding on a case type, at least one form must carry a case transaction in thehttp://commcarehq.org/case/transaction/v2namespace. Name the case type and the count of declared bindings with no writer.This assertion passes today — a post-#989 released CCZ was verified on 2026-08-14 to carry
<create>plus 34<update>properties, one per declared binding, no drops. It is a regression preventer, and it exists because the alternative — periodically re-grepping a build by hand — is what left ace#977 undecidable across three separate investigations for a month.checkCczCaseListEnumFidelity({suiteXml, appStrings, forms})→[BLOCKER]case-list-enum-drift(dimagi-internal/ace#1688). For every id-mapping case-list column whose case property a formselect1also writes, the column'svalue → labelmap MUST be a subset of that select's. Pure and structural over artifacts this step already holds — the unzippedsuite.xml,<lang>/app_strings.txt, and the form XMLs. No device, no live Nova, no Connect.import { checkCczCaseListEnumFidelity, describeCczEnumFidelity, } from '../../lib/ccz-enum-fidelity'; const res = checkCczCaseListEnumFidelity({ suiteXml, appStrings, forms, // ace#3220 — see "Values set outside the forms" below. [] when undeclared. externallyWritten: externally_written_case_values ?? [], });Branch on
res.status—unableis not a pass (lib/check-outcome.ts). Record itsreasonin the verdict'schecks[]and treat the enums as UNEVALUATED. Onchecked+!ok, halt with[BLOCKER]case-list-enum-driftand emitdescribeCczEnumFidelity(res.findings)— one line per drifted value, naming both labels and the authoring form. On success recordcolumnsCompared/valuesCompared, so the memo says what was compared rather than only that it passed.Why a BLOCKER and not a warn. On
spark-facilitator/20260820-0817thefcap_communitytile renderedphaseandcurrent_step_idthrough enums carrying an ACE-invented FCAP taxonomy while the meeting form offered Spark's real one, so stored1read as1. Introductionbefore the visit and1. Planningduring it — off by one, on the surface the Learn app explicitly teaches the worker to read (m3_start, and quizq9's scored-correct answer). Both surfaces are well-formed in isolation, which is why nothing else catches it: this gate is the only place the two are compared. Remediation points atpdd-to-deliver-appStep 4l, which repairs the form's option labels and must also derive the case-list enum from that same itemset — not at the enum in front of you.Subset, not equality — a tile may legitimately label fewer values than the form offers (one scoped to a pilot window). Those come back as
res.unlabelledInCaseList: report them, never halt on them. Demanding equality would fire on healthy apps, and a gate that cries wolf gets skipped. Enforced:test/lib/ccz-enum-fidelity.test.ts— the negative control is the drift above and must FAIL; the positive control is a real released CCZ and must pass.The writer is often a hidden
calculate, not a select (ace#2188). Anentity-state-taxonomystate (skills/_app-component-library.md) is one the worker never picks — it is computed from other answers — so pairing resolves the case-write bind through to the calculate that determines it and recovers the value set from that expression's string literals, unioned across every form (on the repro builddeclinedis written only by Register andcheckedonly by Follow-Up, so a per-form compare would false-fail both). Such a column is compared on VALUES ONLY: a calculate carries no labels, so a wrong LABEL on a calculate-written enum is invisible to this gate BY CONSTRUCTION. That is not a hole to paper over here — the authority for a state taxonomy's words is the PDD'sprogram_parameters.entity_state_taxonomy, gated at build time bypdd-to-deliver-app § Step 4l/pdd-to-deliver-app-eval § entity_state_fidelity. Where the literals cannot be enumerated (aconcat(), a value copied from a free-text answer) the result staysunablewith a reason naming the expression — record it and treat the column as UNEVALUATED.res.calculateWrittenreports the value set that was compared, per property, so the memo can show it.Values set outside the forms are accepted only when DECLARED (ace#3220). Some tile values are written by no form on purpose — connect-fi's Deliver app labels
stage = handed_over"Handed over at close-out", and its PDD's build residual reads "No form sets stage Handed over at close-out; every adult still in progress on 28 Feb 2027 needs a bulk case update" (Phase 10 closeout, bulk case update on HQ). The CCZ cannot tell that apart from a stale enum, so the declaration comes from outside it: theexternally_written_case_valuesinput (§ Inputs), each entry{property, values, writer, source}. A declared value passes and is listed inres.externallyWritten— record each as[INFO]withdescribeExternallyWritten(res.externallyWritten)(it names the writer and source), never as a pass-by-silence. Scope is exact: only the named values, only on the named property. An undeclared value no form writes still fails asmissing-from-form; an entry missing itswriterorsourceis refused (res.externalDeclarationProblems) and excuses nothing; a declared value a form also writes, or no tile renders, comes back inres.unusedExternalDeclarations— report it as a stale declaration. Do not add a declaration here to clear a BLOCKER: it is a build record, written atpdd-to-deliver-app § Step 4lsub-step 8 from a PDD statement, and a value with no such statement is the ace#1688 defect this gate exists for.Every writer, or none (ace#2940). Case writes are read in both shapes — bare
/data/case/(create|update)/<prop>and Nova's/data/__nova_operations/<op>/case/(create|update)/<prop>— and a column's value set is the union of EVERY writer across every form, selects and calculates together. If any one writer's values cannot be read, the column isunable, never compared on the rest: comparing a partial set reports the missing writer's values as "never written", a false BLOCKER. Do not discount such a finding by hand — if one appears, the writer set is the bug.
Why play cannot substitute for either. play verdict: 'skipped' /
empty-case-list only fires when a case-list screen is actually pushed.
With no entity datum, play walks straight to form entry and returns a clean
pass, so the app is verified green while carrying a list no worker can reach.
An EMPTY <entity_id/> / <entity_name/> element is NOT a defect —
read the bind (dimagi-internal/ace#1192). In a correct released CCZ
these render as empty elements, with the value carried by a calculate
bind that targets them:
<deliver xmlns="http://commcareconnect.com/data/v1/learn" id="meeting_record">
<name>Community meeting record</name>
<entity_id/>
<entity_name/>
</deliver>
Judging "empty" from element text therefore fires a false [BLOCKER]
on every standard ACE Deliver app. The real defect is a marker whose
calculate bind is absent or empty. Check the bind; never the
element text. This is load-bearing on the case-UPDATE path, where
pdd-to-deliver-app's preload-hidden-field pattern (ace#1180) produces
precisely this shape.
Field fidelity per form — compare field-ID SETS, not raw counts
(ace#2285). For each form in the Nova blueprint, collect the set of
field ids the blueprint declares, collect the set the released form
XML carries (from <bind nodeset> leaf names plus the
<input> / <select1> / <select> / <upload> control refs), and
compare the two SETS. The canonical signal is the silent-omission class
("Nova declared hh_size, the CCZ has no such node"), and a set
difference names the missing field instead of leaving you a number to
guess at. Halt with field-count-mismatch (per form:
missing from CCZ: [...], unexpected in CCZ: [...]) only when a
named field is absent.
Do NOT halt on a raw count difference. The two sides do not share a
counting basis, so a bare expected N, got M comparison has no
defensible zero point and it fires on structurally perfect apps.
Measured on bednet-check-2-visit/20260908-1544, Learn form "How This
Work Pays", which is 8 Nova section containers wrapping 12 labels:
| Basis | Count |
|---|---|
Nova blueprint (get_app) |
20 |
| Literal control + bind elements in the released XForm | 16 |
| Bind-only | 13 |
All three describe the same correct form. A Nova section compiles to a
<group> carrying no bind and no input, and a label compiles to a
bind-less body element, so the blueprint legitimately counts rows the
XForm never emits as either. The field-ID set comparison matched 5/5
forms exactly on that same app.
Note the direction: the retired instruction hedged only on count inflation ("false positives on auto-generated binds"), which is the opposite of what actually happens on a section-using Learn app — the literal count comes in under the blueprint, so the hedge did not apply and a literal reading halted a clean app. That is the ace#1238 class (a guard predicting a failure that isn't there, deadlocking Phase 3), and section-heavy Learn apps are the standard ACE shape, not an edge case.
This is the third instance of the counting-basis class — hidden leaves
(ace#1789), unlabelled and unrecursed containers
(ace#1807 / ace#2281), and this one. countNovaVisibleFields in
lib/app-release-drift.ts solves it for app-release's drift check but
is basis-matched to the --draft-only form walk, NOT to released-CCZ
XML, so do not reach for it here. Record the counts on every basis you
computed in the verdict as context; just never gate on them.
Geopoint bind-type fidelity (cross-cutting — Learn + Deliver).
A kind: geopoint field MUST compile to an XForm bind of
type="geopoint". A type="xsd:string" bind means the released build
is a stale / downgraded compilation, invisible to every other gate
(validate_app, make_build, the structural counts above, AND
commcare-cli play — the selected-at(<gps>, 0|1|3) calcs are
init-guarded, so they don't fire at form-init). For the failure
mechanism + reproducer, see reference.md § Geopoint bind-type fidelity.
Detect it two ways (run both; either firing is a [BLOCKER]):
- Nova cross-reference (primary). For each form, call
get_form({app_id: <nova_id>, moduleUuid, formUuid})and collect every field whosekindisgeopoint. Nova is uuid-addressed since 2026-07-31 — no tool acceptsmoduleIndex/formIndex(seeplaybook/integrations/nova-integration.md § The 2026-07-31 uuid-addressing migration); readmoduleUuid/formUuidoff theget_app({app_id})blueprint already fetched in Step 1, which prints[uuid …]on every module, form, and field. Assert the released CCZ's form XML has<bind nodeset="…/<field-id>" type="geopoint">for each. - CCZ-internal fingerprint (no Nova dependency). In each form XML,
find every hidden
calculateof the shapeselected-at(<X>, 0|1|3)(the lat/lon/accuracy split). The referenced node<X>is a geopoint by construction; assert<X>'s own bind istype="geopoint", notxsd:string.
Mismatch → halt with [BLOCKER] geopoint-bind-downgrade
(naming the field path + the observed bind type + "re-build & re-release
the app from the current Nova blueprint, which compiles geopoint
correctly"). Record per-app under geopoint_binds in the verdict.
Camera-only photo capture (appearance="acquire") — PDD-conditional
(dimagi-internal/ace#867). When the PDD / payable-visit rules /
journeys require live-camera-only photo capture, every image <upload>
node in the Deliver form XML MUST carry an appearance attribute
containing acquire. Contract truth (verified 2026-07-13 against
commcare-android source: QuestionWidget.ACQUIREFIELD = "acquire";
ImageWidget hides the CHOOSE IMAGE gallery button when the appearance
hint contains it — and verified live on connect-ace-prod app
d36493197a2749d49335e02678eed2ff build v4, where the flip produced
exactly <upload ref="/data/dwelling_photo" mediatype="image/*" appearance="acquire">). A missing attribute when the PDD demands
camera-only means the released app permits gallery uploads — breaking
the verification story and any training material asserting camera-only
(hh-poverty-targeting/20260702-1456 shipped a deck claiming "no gallery
option, on purpose" over a widget showing CHOOSE IMAGE).
Mismatch → halt with [BLOCKER] camera-only-appearance-missing
(naming the form path + the <upload> ref + "apply the camera-only
appearance flip (HQ app builder or Nova) and re-release, then re-run
app-release-qa"). Record per-app under camera_only_uploads in the
verdict. When the PDD does NOT demand camera-only capture, skip the
check and record camera_only_uploads: not-required-by-pdd.
Casedb preload on a visible question — always, the Deliver app
(dimagi-internal/ace#2006). A per-encounter form asks a question because
the answer is specific to THIS encounter. If Nova also emits a casedb
<setvalue event="xforms-ready"> for that same node, the worker meets the
previous encounter's answer already filled in, and a worker who taps Next
through the form re-files it verbatim — every value internally consistent,
so no constraint fires and nothing warns.
import { auditCasedbPreloads, formatPreloadAudit }
from '../../lib/casedb-preload-audit';
const audit = auditCasedbPreloads(formXml, declaredCarryForward);
Any violation → halt with [BLOCKER] casedb-preload-on-visible-question,
emitting formatPreloadAudit(...). Record per-app under casedb_preloads.
This is not hypothetical and it is not rare. The released Deliver CCZ of
spark-facilitator/20260828-0703 carries one for 35 of its 36 answerable
questions — every field except the photo upload, and including
meeting_conducted and meeting_type, the two fields Connect's payability
predicate reads. That one mechanism is the root cause of FOUR separate
BLOCKERs in that run's deep app-UX verdict, none of which named it: the
case's "last meeting" never advancing (the date preloaded, and
last_meeting_date is calculated from it), the geopoint self-certifying,
attendance and participation arriving pre-filled, and a not-held meeting
advancing the case. Three were investigated as independent product defects
and one was investigated three times as a case-write bug that never existed.
Note this check exists because the OPPOSITE was believed and written down:
pdd-to-deliver-app carried "a visible case-bound field does NOT emit a
preload either — proven against a compiled CCZ (ace#1232)". That was true
when measured and is false now. The lesson worth keeping is that a refuted
claim is not refuted forever, which is exactly why this is a check against
the artifact rather than a sentence in a skill.
Count-bound repeat snapshots an unanswered question — always, every form of
both apps (dimagi-internal/ace#2517). Nova compiles a root count_bound
repeat's count into a reserved __nova_count_<id> node written by ONE
<setvalue event="xforms-ready"> and no calculate — by design: Nova's
compiler notes say root counts "initialize on xforms-ready … They do not
later track answer changes". So a count that reads a question in the same
form is read at form open, while that question is still empty: jr:count is
"", CommCare renders zero instances, and the worker is carried straight
past the repeat. commcare-cli play (Step 4.5) cannot see it — it exercises
form init, not a repeat entered after an answer.
import { auditRepeatCounts, formatRepeatCountAudit }
from '../../lib/repeat-count-audit';
const audit = auditRepeatCounts(formXml);
Any violation → halt with [BLOCKER] dead-repeat-count, emitting
formatRepeatCountAudit(audit, formPath). Record per-app under
repeat_counts. A count with a calculate, an xforms-value-changed
writer, a jr-insert (nested-row) snapshot, or a snapshot of a case
property / session value / constant passes — those are live or legitimate.
Measured, not hypothetical: the released Deliver CCZ of
spark-facilitator/20260926-1413 (HQ app 3b7ec74d9883452a8747796e2f777262,
build a556b0725f4d465cbe3377fa51d6e382) flags 5 of 5 count-bound
repeats across three forms — the meeting activities and independent-project
rows on both meeting records, and Participant Feedback's participants, so
every feedback round submitted with no participants. It was found by a
Phase 6 device walk; this gate moves it to Phase 3.
Grid menu display — always, both apps (dimagi-internal/ace#1009).
app-hq-settings sets display_style = 'grid' on every module of both
apps and then declares this skill its downstream backstop. Until ace#1009
that backstop did not exist, so the setting shipped with zero
verification — the "applied but never verified" shape that #867 / #971 /
#994 exist to prevent.
Read it from the raw app doc, NOT from the CCZ and NOT from the REST API. Neither intuitive surface can carry the full assertion:
suite.xmlonly carriesstyle="grid"on its<menu>elements when the app-levelgrid_form_menus == 'some'(HQ's suite generator ignores every per-moduledisplay_styleotherwise —suite_xml/sections/menus.py:86-92). So it reads BARE on exactly the half-applied app ace#1082 describes, and even on a fully-gridded app it cannot showuse_grid_menus/grid_form_menusthemselves — corroboration at best, never the authoritative read. (Post-#1082 correctly-gridded CCZs DO emitstyle="grid"; the pre-#1082 claim here that a gridded CCZ "returns nothing" for the substringgriddescribed builds whosegrid_form_menuswas still'none'— see dimagi-internal/ace#1246.)GET /api/v0.5/application/<app_id>/serializes only['case_properties','case_type','forms','name','unique_id']per module.display_styleis absent, so this surface readsNonefor a module that IS grid — a false negative waiting for whoever reaches for the obvious API first.
(Cheap secondary signal, optional: because suite.xml is bare exactly
when grid_form_menus != 'some', a style="grid" absence in the CCZ
already in hand corroborates the half-applied class with no extra HTTP
call — but the raw app doc remains the assertion surface.)
The authoritative surface is the raw app doc, which works against both the draft app id and a released build id:
GET /a/<ACE_HQ_DOMAIN>/apps/source/<released_build_id>/
(authenticated with the ~/.ace/connect-session.json cookie jar, same as
Step 2's download — the endpoint 401s on ApiKey auth). For each app,
assert all three grid fields (dimagi-internal/ace#1082):
use_grid_menus === true— the app-ROOT menu of modules;grid_form_menus === 'some'— without it the suite generator ignores every per-moduledisplay_style(HQsuite_xml/sections/menus.py:86-92), so asserting only the per-module half is what let ace#1082 sit unnoticed;- every entry in
modules[]hasdisplay_style == 'grid'.
Any miss → halt with [BLOCKER] grid-menu-display-missing (naming the
app, the field — and for a module miss, the module index + unique_id —
plus the observed value and "re-run app-hq-settings, re-release, then
re-run app-release-qa"). Record per-app under grid_menu_display in the
verdict: { use_grid_menus, grid_form_menus, modules_checked, modules_gridded, non_grid: [...] }.
(Until ace#1082 the app-level flags were carried here as an INFO-only
caveat — hh-poverty-targeting/20260728-0705 read use_grid_menus: False on both apps while all 9 module menus were "grid", and
spark-facilitator/20260730-1718 additionally proved grid_form_menus: 'none' was rendering those module grids inert. app-hq-settings Step 4b
now sets both app-level flags via commcare_set_app_menu_display, so all
three fields are asserted as blockers.)
entity_id grain — always, every released Deliver form carrying a
deliver_unit (dimagi-internal/ace#1285). entity_id is Connect's dedup and
payment grain, and a wrong one silently UNDER-pays: on
bednet-check-2-visit/20260814-0357 the released key was
(FLW username, visit date, consent answer) against a PDD mandating a
per-household business key, so an FLW who legitimately followed up 5 households
in one day accrued 1 payable unit.
Every existing gate passed it. This skill passed, pdd-to-deliver-app-eval
scored 9.2, and the CCZ projection reported collision_count: 0 — of course it
did; a key that collapses five units into one has no collisions. It surfaced
only in connect-program-setup-eval, which runs AFTER Phase 4 has wired a
payment unit around the wrong grain, and Phase 4 cannot repair it: Connect
consumes entity_id from the form and has no override.
import { checkEntityIdGrain } from '../../lib/entity-id-grain';
const report = checkEntityIdGrain(formXml, declaredBusinessKeyNodes, {
// ace#1441 — REQUIRED whenever the PDD declares a non-payable branch.
hasNonPayableBranch, // true when the PDD marks any submission non-payable
payabilityDiscriminator, // the field that discriminates (e.g. consent_confirmed)
});
Pass the nodes the PDD declares
(products.pdd.program_parameters.entity_id_components, else the nodes named
in § Deliver App Specification).
Pass the payability inputs too, and a disclosed override is a PASS
(ace#1441). _app-component-library § payability-scoped-key REQUIRES the
discriminator inside entity_id when a non-payable branch exists, and ace#1434
ruled that requirement wins over a PDD-pinned identity-only grain. A
discriminator is an ANSWER by construction, so without these inputs this
halt-loud gate rejects exactly the key ACE mandates — and any opportunity
declaring a non-payable branch cannot clear Phase 3. Observed on
bednet-check-2-visit/20260814-2019, where a correct build disclosing the
override as deviation D-9 tripped answer-in-grain + no-entity-component
with every other gate green.
With the inputs supplied, the check suppresses those two findings for the ONE
component resolveEntityIdGrain() mandates — never for any other answer field,
and never when the residual key is not the declared grain. A build that
deviates from a pinned grain and DISCLOSES it as a named deviation is a pass
here; an undisclosed deviation is still the builder's problem, not this gate's.
Any remaining finding is a [BLOCKER] entity-id-grain:
missing-declared-node— the key omits a node the PDD names, neither literally nor through thecalculateof any node it concatenates (ace#1810). A declared component reached through an INTERMEDIATE node is present, not missing: whenever the key term needs arithmetic — and a per-entity cap likemin(<x>, 3)always does — Nova computes it in a named hidden node and puts that node in theconcat. Before ace#1810 the plain substring test read that as absent and hard-halted Phase 3 on a build that obeyed the PDD (spark-facilitator/20260828-0703, Deliver buildb08533bdf26a48a295a362ff204fb88d);answer-in-grain— a worker-chosen answer (consent, eligibility, outcome) is inside the dedup grain and is not the mandated discriminator. This is the ace#969 over-correction: moving a payability predicate INTO the key fixes slot consumption and breaks the grain. The mandated discriminator is exempt (ace#1441); a SECOND answer field is not;no-entity-component— fires even with nothing declared, because a key of worker + date + answers is worker-and-day scoped by construction. On a CONDITIONAL key —if(<predicate>, concat(A), concat(B)), which is what a PDD's non-payable branch needs when the fallback changes the IDENTITY source rather than just adding a suffix — this is judged per branch, and every branch must pass (ace#2417). The finding names which branch failed. Theif()predicate is not a component: it selects a key, it is not part of one.report.componentsis the UNION across branches, which is the right list for the declared-node test (a declared business key legitimately appears on the payable branch only) and the WRONG one for entity identity — readreport.componentsfor display, never re-derive the entity test from it. A conditional nested inside aconcat()is deliberately NOT decomposed: that is the released ace#1285 shape, and splitting it would let a clean sub-branch launder a worker-and-day-scoped one.
report.resolvedThroughIntermediate is NOT a finding — carry it forward
(ace#1810). Each entry names a declared component that is in the key only
through an intermediate node. Presence is all this gate can establish: an
intermediate node carrying min(x, 3) and one carrying an UNCLAMPED running
index produce a byte-identical composite. Copy the list into the verdict and
into 3-commcare/app-release-qa_*.md so Phase 4's connect-opp-setup § Archetypes -> longitudinal-visits reads each entry's own bind and records
whether the PDD's clamp semantics are implemented.
report.status === 'unable' (no readable entity_id) means the check did
not run — NOT a pass. Render it with formatGrainReport(report), record it
in the verdict's checks[] with its reason, and treat the dedup grain as
UNVERIFIED rather than clean. report.detail and report.ok are unreachable
on that branch by construction (lib/check-outcome.ts); if you expected the
form to set entity_id, the extractor is the bug.
Payable-cap arithmetic — always, every released Deliver form whose
entity_id carries a clamped counter OR that computes a payable_slot-style
field (dimagi-internal/ace#2148, ace#2649). The
clamped index GROUPS encounters onto CompletedWork rows; it is not what stops
an over-cap payment. With the duplicate flag off — always, on ACE
opportunities — Connect resets a repeat key to pending and auto-approves it,
so a repeat is paid again (ace#2512; playbook/integrations/connect-api.md § A repeated entity_id is PAID AGAIN). The Connect-side stop is the
payable_slot field plus the form_field_rules row Phase 4 adds on it
(_app-component-library § payability-scoped-key). The index arithmetic still
matters — an off-by-one there puts an in-cap and an over-cap encounter on
separate keys and makes the grouping, and every per-entity report built on it,
wrong — so the check below stays. The helper grades BOTH mechanisms and
reports which one carries the cap as report.mechanism (clamped-key or
payable-slot) with the slot itself in report.payableSlot; copy both into
the verdict, since the key alone does not bind the cap. An UNCLAMPED key is
a correct grain when the slot holds the cap — e.g. concat(case_id, '-', step, '-', step_meeting_index, '-', payable_slot) with payable_slot = if(step_meeting_index <= 3, 'yes', 'no') (spark-facilitator/20261004-1706,
whose PDD §14 pinned exactly that). The slot is simulated like the clamp,
because the counter's timing decides what <= 3 means: over a
<casedb read> + 1 counter it says yes to meetings 1-3; over a bare
casedb read it says yes to FOUR, the ace#2148 off-by-one in slot form.
The clamp constant and the cap are not the same number, and which way they
differ depends on when the counter is read. A casedb read is the state
BEFORE this submission, because the case property is written on submit. On
spark-facilitator/20260906-2233 the per-step cap was 3 and the build shipped
capped_index = if(payable_count_this_step >= 3, 3, payable_count_this_step)
over such a read: the 4th meeting reads 3, clamps to 3, and mints an index that
has never existed — Connect creates a CompletedWork for it and pays it. 4 keys
x 7 steps = 28 against a declared total_cap_per_flw of 21. validate_app,
compile_app and make_build all passed; the CCZ was structurally perfect and
the app internally consistent with its own wrong key. Only
pdd-to-deliver-app-eval re-deriving the arithmetic by hand caught it.
import { checkPayableCapArithmetic, formatPayableCapReport }
from '../../lib/payable-cap-arithmetic';
const report = checkPayableCapArithmetic(formXml, {
// The PDD's per-entity cap, when it declares one. OMIT it and the check
// reads the app's own `is_payable` guard instead — which is what caught
// ace#2148, where the guard was right and the key was wrong.
declaredCap,
});
Do not adjudicate this by comparing the clamp to the cap. Both correct
constructions are live in ACE and they share no constant: build
b08533bdf26a48a295a362ff204fb88d clamps min(<casedb count> + 1, 3)
(indices 1..3) and build 0cb63a78fd9949b696876ee7a642b685 clamps
if(pcts >= 3, 2, pcts) (indices 0..2). The helper SIMULATES the first
submissions and counts distinct keys; report.indices is that trace, and it
belongs in the verdict because it is what a reviewer can check by eye.
Any finding is a [BLOCKER] payable-cap-arithmetic — name node,
capacity vs cap, the capSource, firstOvercapped, and the remedy
expression, and route the fix to pdd-to-deliver-app Step 4j.6. kind is
payable-cap-off-by-one (the ace#2148 class), payable-cap-mismatch (the two
disagree by more than one), payable-cap-not-expressible (a cap below 1 is
a payability question, not a dedup one), or payable-slot-off-by-one /
payable-slot-mismatch (the payable_slot field Phase 4's form_field_rules
row approves on says yes to the wrong number of encounters, or to a set that
is not the first N — a finding even when a clamped key is also present and
correct, because the rule pays what the slot marks).
The slot's name and shape vary; the helper reads both (ace#2861). The
library calls the node payable_slot, but builds also ship paid_slot (after
a PDD's "paid-slot answer"); both are read, and when the Phase 4
form_field_rules row names the node, pass it as slotNode so any name is
read. A ONCE-ONLY cap (cap 1) is usually a history predicate rather than a
counter — if(#participant/baseline_done = 'yes', 'no', 'yes'), or
if(<payable> and #participant/x_ever != 'yes', 'yes', 'no') over a flag the
form writes back, or if(#form/earlier_versions = 0, 'yes', 'no') over a
casedb count. The helper grades all three as a slot admitting 1
(report.payableSlot.shape is once-only-flag for the flag form, with
flagProperty and flagWrite; copy them into the verdict).
report.status === 'unable' means the check did not run — NOT a pass.
Record it in checks[] with its reason and treat the cap as UNVERIFIED.
Exactly ONE reason is a [BLOCKER] on its own: no clamped counter in
entity_id and no payable_slot-style calculate on a form whose PDD DOES
declare a per-entity cap — the cap is enforced by NEITHER mechanism (raise it
yourself, naming the declared cap). Every other unable is a shape or input
the helper could not grade, and is never mapped to a [BLOCKER] by
itself: trace the slot by hand over the first cap + 1 payable encounters,
record the trace in the verdict, and block only on what the trace shows. That
covers an unclamped key with a slot present (ace#2649), a slot shape the
helper does not recognise ("… a slot shape this check does not recognise"),
a once-only slot whose flag this form never writes back (check the other
forms — a flag nothing writes IS a missing cap, but that is a finding of the
trace, not of the unable), and the counter never resolves to a casedb
read — for the clamp, or for the slot — where the timing is undecidable;
never assume one, because a guessed timing is an off-by-one in the other
direction. Blocking on an unrecognised shape halts a correct build: on
smallholder-seasonal/20261012-1549 12 of 12 paid forms came back unable
while every once-only cap was enforced.
Scoring arithmetic — always, every Learn form carrying item scores
(dimagi-internal/ace#1035). CommCare has no "mark this option correct"
primitive — Vellum's Assessment Score mug takes a hand-written XPath
(dimagi/Vellum@8a1ef02 src/commcareConnect.js:180-198), and Connect reads
user_score straight off the submission
(commcare_connect/form_receiver/processor.py:230). Nova's maintainer
confirmed that is the PLATFORM's shape, not a Nova gap (nova#372). So the score
is arithmetic the architect writes, and until this check nothing verified it:
no -eval grades it (they grade the app against the PDD, which carries no
per-item score expressions) and the rest of this skill is structural. The
Household Poverty Targeting Learn app spends 36+ hidden fields on scoring
alone; one omitted rollup term produces a plausible score that is quietly
wrong, and a worker's pass/fail against passing_score depends on it — a wrong
score gates the wrong people out of PAID WORK.
import { checkScoringArithmetic, formatScoringReport }
from '../../lib/scoring-arithmetic';
const report = checkScoringArithmetic(formXml);
report.status === 'unable' means the check did not run — no bind matched
an item-score node. That is NOT a pass: record it in the verdict's checks[]
with its reason and treat the scoring as UNVERIFIED. If the form visibly
DOES carry item scores, the ITEM_SCORE regex is the bug, not the form —
that exact failure has now shipped four times (#1332 → #1538 → #1576 →
#1634), most recently sending both Learn scoring forms on
bednet-check-2-visit/20260825-1310, gating assessment included, down this
path while the gate reported fine. Any finding is a [BLOCKER]
assessment-scoring-arithmetic; name
the kind and the nodeset, and route the fix to pdd-to-learn-app. The five
mechanical classes: missing-term (rollup omits an item), extra-term
(rollup names a phantom), denominator-mismatch (* 100 div N where N is not
the item count), self-reference-missing (an item scores a different
question), unreachable-max (an item awards 0 on every branch), plus
no-rollup. On a form WITHOUT a Connect <assessment> block (an ungraded
baseline) the rollup is whatever non-item calculate sums the items — e.g.
baseline_score — and gets the same checks; no-rollup there means nothing
sums them, never "add a user_score", which #1131 forbids on such a form
(dimagi-internal/ace#2958).
It deliberately does NOT judge whether the correct answer is correct — that is
content, and assessment_rule_coverage owns it.
Assessment retry leak — always, every Learn form carrying a score gate (dimagi-internal/ace#1041). A fail/retry result label must not restate the correct answer. Run the pure helper over each form XML:
import { checkAssessmentRetryLeak, formatRetryLeakReport }
from '../../lib/assessment-retry-leak';
const report = checkAssessmentRetryLeak(formXml);
report.status === 'unable' means the check did not run — the form carries
no score-gated result labels, so there was nothing to leak into. That is NOT a
pass: record it in the verdict's checks[] with its reason. If the form DOES
gate a retry message on the score, the recovery matchers are the bug. Any entry
in report.findings is a [BLOCKER] assessment-retry-answer-leak: name the label
nodeset and the leaked option, and route the fix to pdd-to-learn-app Step 4c
(replace the retry text with a pointer to the module content).
report.info is [INFO], never a BLOCKER (ace#2208). It holds hits the
check cannot stand behind as a leak: an answer token of ≤ 4 letters (No.,
Tumbuka Yayi. — also an ordinary clause-final negator in Bantu-language
layers), or any hit on a form with Connect learn markers but no <assessment>
block (report.gating === 'non-gating', a baseline/pre-test Connect never
scores, ace#1131). Record each in the verdict so a reviewer can read it; a
genuine restatement in a pre-test still teaches the answer the gate later
asks, so say so when the text plainly restates one. Matching is per item (a
label is compared only with the answers of the items its own score depends
on) and on Unicode word boundaries.
report.blind is a [BLOCKER] too — a blind check is not a clean one. It
lists fail-branch labels whose text could not be resolved and answer literals
with nothing to match against; report.ok is false whenever it is non-empty,
and formatRetryLeakReport prints a BLIND block. Emit
assessment-retry-leak-blind and fix the form, not the gate. This exists
because the check reported the benign old checked: false on every released CCZ
until 2026-08-14 (#1332): it matched only </<= while the compiler emits
not(score >= N), and it read inline <label> text while the compiler moves
all label text into <itext>. Both are properties of a released CCZ — the only
artifact this skill ever holds.
Why it is a released-CCZ check and not only a build-time one: the live instance
(bednet-spot-check/20260729-0002) happened because the brief was hand-composed
at L0 and the assessment-gate component paragraph was skipped, so a build-time
assertion inside the producer is exactly the thing that was absent. The correct
answer is recoverable here because ACE's scoring calculates compare the question
against the correct answer's literal value — CommCare has no correct-option
primitive, so that literal is the only answer key that exists.
Effect if missed: the Connect Deliver-unlock gate is decorative. With no attempt
limit, a worker who fails once is shown the answer and passes on attempt 2, while
user_score >= 80 remains wired and inert — and every structural check passes.
Constraint locality — always, every form with constraints
(dimagi-internal/ace#980). A constraint must be satisfiable on the
screen where it fires. Run the pure helper over each form XML:
import { checkConstraintLocality, formatConstraintLocalityReport }
from '../../lib/constraint-locality';
const report = checkConstraintLocality(formXml);
It flags any constraint referencing a question or repeat the user cannot
edit from that screen, resolving hidden calculates transitively (so
indirection can't launder a foreign reference) and allowing the two
legitimate shapes: a self-reference (.) and a cardinality gate placed
immediately after the repeat it guards — including one spelled
sum(<repeat>/<flag>) >= N over a per-row if(<predicate>, 1, 0) indicator,
which is the same gate written a different way (ace#2416).
This check is mechanical on purpose. The class is 100% detectable
from the binds, and the LLM rubric missed it twice in one form:
pdd-to-deliver-app-eval scored hh-poverty-targeting/20260722-1341
8.5/10 while it shipped a GPS-accuracy rule on a later yes/no question
(message: "recapture the location", on a screen with no location widget)
and a roster-minimum rule on an unrelated zone question. A domain expert
found both on first read. Judgment is the wrong tool for a property this
crisp — hence a parser, and lib/constraint-locality.test.ts pins the
real defective binds as its negative control.
The checker is screen-aware, not question-aware: a group carrying
appearance="field-list" renders all its questions on ONE scrollable
screen, so a constraint referencing a sibling inside the same field-list
is local — the FLW scrolls up and fixes it in place. That is the
shape skills/_app-component-library.md § data-quality-constraints
mandates (under_5 <= household_size), and treating it as a violation
meant a correctly-authored app could not clear this step
(dimagi-internal/ace#1019).
Each violation carries a severity:
blocker— the constraint never references the node it is bound to, so no answer the user can give on that screen clears it. The ace#980 class: "recapture the location" on a screen with no location widget.warn— the reference crosses a screen boundary, but the constraint also references its own node, so lowering/changing the answer in front of the user satisfies it. Annoying, not a dead end.
Severity is decided structurally (does the expression mention its own nodeset?), never by scanning the message text — validation messages are free prose written per build, so a phrase list mis-grades any form whose author phrased it differently (and mis-grades every language-stacked form built before 2026-08-14 outright).
Each violation also carries a kind, because two different defects halt
here and their remedies differ:
kind: 'non-local'— the ace#980 class above.kind: 'dead-repeat-cardinality-gate'— a minimum-rows gate bound INSIDE the repeat it counts (dimagi-internal/ace#1560). A constraint on a node inside a repeat is evaluated per repeat INSTANCE, so at zero repetitions it never evaluates at all — the one input the gate exists to reject. This shape satisfies locality exactly (acount()over your own repeat is a same-repeat reference), which is why it needed its own check: locality and reachability are different properties. The violation carriesdeadGate: { repeat, countArg, comparison, minimumRows, fn, indicatorNode? }.fnis'count'or'sum';sumis recognised ONLY over a repeat child whose owncalculateis aif(<predicate>, 1, 0)indicator, andindicatorNodenames it (ace#2416). Quotefn, not a hard-codedcount(, when you render the offending expression.Read
kindon every violation. A report whose violations you bucket byseverityalone loses the remedy — both kinds areblocker, and "move the constraint onto the node it is about" is the WRONG instruction for a dead gate (it is already on a node it is about).
severity: 'blocker' + kind: 'non-local' → halt with [BLOCKER]
non-local-constraint (naming each field, the foreign node, and "move the
constraint onto the node it is about"). kind: 'dead-repeat-cardinality-gate' → halt with [BLOCKER]
dead-repeat-cardinality-gate, naming the field, the repeat, and the
remedy the checker already whitelists: move the gate to a question
immediately after the repeat, where it evaluates at zero rows. (Nova
cannot carry it on the repeat container itself — edit_field on a repeat
answers kind "repeat" carries no 'validate' slot, ace#1560.)
severity: 'warn' → emit [WARN] cross-screen-constraint and continue.
Record per-app under constraint_locality in the verdict:
{ constraints_checked, violations: [...] } (each violation carrying its
severity AND its kind). Zero violations records
constraint_locality: pass.
Relevance reachability — always, every form with relevance conditions
(dimagi-internal/ace#996). The temporal sibling of the check above: a
relevant clause must be decidable by the time the form walks past the
field it gates. Same module:
import { checkRelevanceReachability, formatRelevanceReachabilityReport }
from '../../lib/constraint-locality';
It flags any relevant referencing a field answered LATER, resolving
calculates transitively so a hidden calculate over a later answer inherits
that answer's position and can't launder it.
Read the report's REAL fields (dimagi-internal/ace#1539).
RelevanceReachabilityReport is
{ relevancesChecked: number, violations: RelevanceViolation[] }, and each
violation carries whollyUnreachable: boolean. There is no checked, no
unreachable, and no partial field — reaching for those names yields
undefined on every form, which renders as "nothing found" and reports a
BLIND PASS on the one check whose whole job is catching a field that can never
display. Derive the two buckets explicitly:
const wholly = report.violations.filter((v) => v.whollyUnreachable);
const partial = report.violations.filter((v) => !v.whollyUnreachable);
relevancesChecked === 0 means the form carries no relevance conditions —
"not applicable", NOT a pass, the same rule every other checker here follows.
Two severities:
whollyUnreachable: true— every reference is later, so the field can never display.[BLOCKER].whollyUnreachable: false— some clauses resolve in time, others never contribute.[WARN]— the field shows on some paths and silently not on others, which is howoutcome_notesubmitted empty on exactly the two outcomes it existed to capture.
Record per-app under relevance_reachability. Same rationale as constraint
locality: mechanically detectable from bind order, so it is a parser rather
than a rubric line.
Language tables vs. declared locales — always, every CCZ
(dimagi-internal/ace#2292). Every <lang>/app_strings.txt in the zip must be
declared as a <locale language="…"> in that CCZ's own suite.xml. Build the
file map FROM THE ARCHIVE, in memory, and run the pure helper over it:
import { auditCczLanguageTables, formatCczLanguageTables, readCczTextFiles }
from '../../lib/ccz-language-tables';
const files = readCczTextFiles(cczPath); // in-zip path -> text, no extraction dir
const audit = auditCczLanguageTables({
suiteXml: files['suite.xml'],
files,
appName, // optional — the app's name from the deploy summary
});
Never feed it a directory you extracted into — that is how ace#2292 was
manufactured. The issue reported that the released Deliver CCZ for
bednet-check-2-visit/20260908-1544 (HQ app a3359eaf446f498a80366e000a677a4a,
build 171996636b714bb09dee8779e7e83927) "unzipped to default/ en/ modules-0/ modules-1/ nya/ tum/", the last two being spark-facilitator's Chichewa/Tumbuka
tables. Re-downloading that build on 2026-10-16 and listing its ZIP ENTRIES
gave 7 files — default/ and en/ app strings, profile.ccpr,
media_suite.xml, two forms and suite.xml — and no nya/ or tum/. The
reproduction (unzip -o -q deliver.ccz && ls -d */) had run in a scratch
directory still holding an earlier spark extraction: unzip -o overwrites the
files a zip carries and deletes nothing else, so spark's tables survived beside
bednet's fresh suite.xml, which is exactly why they looked undeclared. The
captured build is test/fixtures/ccz/bednet-check-2-visit-20260908-1544-deliver-v7.ccz,
and test/lib/ccz-language-tables.test.ts reproduces the residue mechanism. The
same rule binds Step 3 and every other gate that reads an unzipped tree.
This is a [FINDING], not a halt. The device is only ever offered the
locales suite.xml declares, so an orphan table is unreachable to a worker and
the English device walk is unaffected. Emit [FINDING]
undeclared-language-table naming each orphan language and its
app.display.name (carried as a foreign-display-name finding on the same
audit). A foreign-display-name on a DECLARED language is severity: 'signal'
and does not flip ok: a genuinely translated app translates its own name, so
grading that would fire on every correctly multilingual build. audit.missing
(declared with no table in the zip) is the INVERSE case, reported and
deliberately not counted as an orphan. Before recording a finding, confirm the
orphan is in the archive (unzip -l) — a finding that only exists on disk is a
measurement error, not a residual. Record a real one as a Phase 3 residual and
do not hand-edit the zip.
audit.status === 'unable' means the check did not run — either the file
map carried no app_strings.txt at all, or suite.xml yielded zero locale
declarations (every released CCZ declares at least default, so zero means the
suite was not read, and reporting "every table is an orphan" on a failed read is
the loudest possible false finding). That is NOT a pass: render it with
formatCczLanguageTables(audit, '<Learn|Deliver>'), record it in the verdict's
checks[] with its reason, and treat the language tables as UNVERIFIED.
Record per-app under language_tables.
Step 4.5: Runtime install validation via commcare-cli.jar
Steps 3–4 are structural and never bind any XPath expression,
leaving an install-time class uncovered: a CCZ whose XPath references
resolve to nothing at form-init (CommCare rejects it on-device with "A
part of your application is invalid"). dimagi/commcare-core's
commcare-cli.jar ships two subcommands; use both in series:
validate (2s, parser-class) and 5–10s, runtime form-init
defects — play (XPathTypeMismatchException from
FormDef.initAllTriggerables). For what each mode catches + the bednet
reproducer, see reference.md § Runtime install validation.
Procedure — for each app (Learn, Deliver):
validate(parser-class pre-screen, fast):commcare_validate_ccz({ ccz_path: <local path to the released CCZ on disk>, // preferred — no 10KB base64 round-trip through model context // OR ccz_base64: <if not on disk yet> — exactly one of the two mode: "validate", })play(the authoritative install-time gate, slower):commcare_validate_ccz({ ccz_path: <same path>, mode: "play", entry_path: [0, 0], // first module → first form (default) })entry_pathis the menu position as displayed, not the HQ module index (ace#2784). Child modules nest inside their parent menu, so on a Deliver app with nested child modules the root shows fewer items than there are modules. On group-payment-test the root listsm0 / m3 / m4as0 / 1 / 2, so the paid formm3-f0is[1,0]. Read the root and submenus off suite.xml's<menu>blocks: a menu with noroot=is at the root, androot="mN"places it insidemNafter that menu's own forms. Invokeplayonce per form you need covered. Each result echoescommand_id, so check that it names the form you meant. Fire all per-formplaycalls in parallel (one assistant turn, multiplecommcare_validate_ccztool calls) — they read the same on-disk CCZ read-only and differ only byentry_path, so there's no ordering dependency. Likewise, run the Learn and Delivervalidate/playchecks concurrently rather than one app fully before the other. Await all results, then branch on the worst verdict. (Each call is its own short-lived JVM; serial execution just adds ~8s/module of dead wall time.)Role-gated apps (
#user/<field>, ace#2989).playseeds everysession/user/data/<key>the CCZ reads with an empty string, so arelevant=on it evaluates instead of throwing. A menu shown only for#user/role = 'FLW'is therefore hidden by default: passuser_data: {role: 'FLW'}(and one call per other role value) to walk that branch, countingentry_pathas THAT role sees the menu. Read the role values off therelevant=attributes in suite.xml. Checkcommand_idagainst the form you meant.Response shape (both modes):
{ verdict: 'pass' | 'fail' | 'skipped', // 'skipped' is play-only exit_code: <int>, // play-mode only: command_id?: 'm3-f0', // the suite command entry_path resolved to skip_reason?: 'empty-case-list' | 'form-entry-not-reached' | 'cli-case-index-unsupported' | 'entry-path-unresolved' | 'cli-fixture-unseeded' | 'cli-user-data-unseeded', nav_unresolved?: <why entry_path landed on no command>, seeded_case_types?: [...], seeded_case_properties?: [{caseType, properties: {prop: literal}}], // from case-list filters (ace#2286) unseeded_case_predicates?: [{datum_id, case_type, predicate, reason}], // with empty-case-list only (ace#2286) seeded_fixture_tags?: [...], // item-list lookup tables seeded empty (ace#2860) seeded_user_data?: {key: value}, // custom user data in the restore (ace#2989) killed_reason?: 'restart-loop' | 'output-ceiling', // helper killed a runaway JVM (ace#2988) failing_binding?: '/data/du_bednet_visit/deliver', unresolved_xpath?: 'instance(commcaresession)/session/data/case_id', // both modes: parser_message?: 'XPathTypeMismatchException: Calculation Error: …', failed_resource?: 'jr://resource/modules-0/forms-0.xml', // validate only stdout: <truncated to 4KB>, stderr: <truncated to 4KB>, timed_out: <bool>, // present only on input errors: input_error?: 'jar_not_found' | 'ccz_not_found' | 'ccz_empty' | 'usage', input_error_path?: <path>, }Branch:
- Both modes
verdict: 'pass'→ record per-appcli_validate: {validate: pass, play: pass}and continue. input_error: 'jar_not_found'(either mode) → emit[WARN]cli-validator-unavailablewith the setup remediation below; continue. Structural Steps 3–4 still authoritative.validate verdict: 'fail'→ halt with[BLOCKER]cli-validate-parser-errornamingparser_message+failed_resource. Don't bother runningplay—validatealready proved the CCZ is structurally broken.validate: pass+play: fail→ halt with[BLOCKER]cli-form-init-errornamingfailing_binding+unresolved_xpath+parser_message(see § Failure modes for the bednet class + fix).play verdict: 'skipped'→ do NOT halt, and never record it as a pass: form-init was not observed, so the form is UNEVALUATED. Recordcli_validate.play: {verdict: skipped, skip_reason, command_id, seeded_case_types}so the coverage gap is legible. The reasons are:empty-case-list→[INFO]. The module's case list rendered zero rows, so the walk died in case-list rendering before form entry.playseeds one open case per case type declared in the CCZ'ssuite.xml, plus (ace#2286) one case per case list that filters on a conjunction of<prop> = '<literal>'equalities, carrying exactly those properties (echoed asseeded_case_properties). So this now only fires when a case-list filter is something else —or,not(),!=, a comparison,selected(), a date function, anindex/…parent join.unseeded_case_predicatesnames each such predicate verbatim with its reason; copy it into the verdict.cli-case-index-unsupported→[INFO](ace#2784). Form entry WAS reached, but form-init ran a casedb index query (case[index/parent = …], e.g. ajoin()over a group's member cases, or acase-choice-attendancechecklist'scasedbitemset and roster — the case-sourced choice kind Nova shipped in PR #730, closed 2026-10-08 asvoidcraft-labs/commcare-nova#728and adopted inplaybook/integrations/nova-integration.md § The case choices channel), and the CLI's in-memory sandbox has noCaseIndexTable("this.caseIndexTable" is null). A device has one, so this is the harness's limit, not a defect. Observed on both group-payment-test session forms.cli-fixture-unseeded→[INFO](ace#2860). Form-init asked the synthetic restore for a fixture it does not carry (FixtureInitializationException: Unable to find lookup table).playseeds an emptyitem-list:<tag>fixture root for everyjr://fixture/item-list:<tag>the CCZ's XML declares (echoed asseeded_fixture_tags), so this fires for a fixture kind that seed does not cover — and (ace#2941) when a binding or case-list column reads a ROW column of an empty-seeded table (Logic references instance(<id>)/… which is not a valid question or value, every unresolved ref an item-list instance;unresolved_xpathnames the read). Do not reclassify such afailby hand; the gate does it. A device receives the table on sync, so this is the harness's limit. Before ace#2860 the restore seeded no fixtures and every lookup-backed form gradedfail→cli-form-init-erroron a clean build. To check the device side, confirm the table exists on HQ withcommcare_get_lookup_table.cli-user-data-unseeded→[INFO](ace#2989). Something read custom user data (instance(commcaresession)/session/user/data/<key>) the restore does not carry — typically a menurelevant=thrown fromMenuLoader.menuIsRelevantwhile the root menu renders, before any form opens.playseeds every key the CCZ's XML names (echoed asseeded_user_data), so this fires for a key the scan cannot see. Fires only when EVERY unresolved ref is user data (or an empty-seeded fixture); a real XPath defect in the same stream still fails. HQ sends custom user data on sync, so this is the harness's limit; confirm the field exists on HQ withcommcare_list_user_fields.form-entry-not-reached→[WARN]. The walk ended without the CLI printingStarting form entry/Form Start:. This means the walk is wrong for this app shape (a datum screen it did not answer), so nameentry_path+command_idand file it. Before ace#2784 this case was gradedpass.entry-path-unresolved→[WARN]. Yourentry_pathlands on no command, and the CLI was not run.nav_unresolvedlists what that menu actually shows, so re-issue with the display position.
Case seeding (dimagi-internal/ace#1088). Most ACE Deliver apps' payable form is a
followupon a case type — but not all: the 2026-08-13 hh-poverty-targeting build is registration-only, which is exactly the shape ace#977'scase-list-unreachablecheck exists for. Do not read the sentence below as a universal. The restore used to seed zero cases — soplaycould not reach any of them and this gate had never once exercisedinitAllTriggerableson an ACE Deliver followup form, while reportingfailon clean builds.playnow derives the case types fromsuite.xmland seeds one open case each, and derives the menu walk from the same file. A case datum inserts a case-list screen, anddetail-confirmadds a confirmation screen. They come BEFORE the form choice when every entry in the menu needs that datum, and AFTER it otherwise. The menu tree comes from the<menu root=…>blocks, not from module indices (ace#2784). Nothing is sent after form entry — stray keystrokes are typed as answers, and a0on a date question raisesIllegalArgumentException: Invalid cast of data [0] to type Date, which reads as a CCZ defect.What seeding does not cover — say so rather than implying the gate is now complete:
- Case lists that filter on a case property with anything other
than a conjunction of literal equalities.
[consent_given = 'yes' and household_state = 'registered_awaiting_followup'](thelongitudinal-visitspayable list, bednet-check-2-visit/20260908-1544) IS seeded — suite.xml states the values a row needs, so they are not guessed (ace#2286).or,not(),!=, comparisons,selected(), dates andindex/…joins are not: such a list is still empty →skipped, andunseeded_case_predicatesnames the predicate. - Case search / registry entries (
<query>screens) and any datum screen other than a plain entity list. - Form-init that queries casedb by index (parent/child) →
skipped/cli-case-index-unsupported. The CLI has no case index table. That form's init is not exercised by this gate at all. relevant=conditions on menus/commands ARE evaluated by the CLI (MenuLoader.menuIsRelevant, ace#2989), so a hidden item shifts every later display position. The walk mirrors that only for arelevant=reading custom user data and string literals (#user/role = 'FLW',and/or); any other relevance is assumed true, andcommand_idis how you catch a walk that landed elsewhere.- The child-menu listing (parent's forms first, then its child menus)
and the descent into a child menu were observed live on
group-payment-test (
0 / 2listsRegister group / Add member / Group members / Session records (B), then opens the group case list). That app's child menus hold case lists, not forms, so walking all the way to a FORM inside a child menu has not been observed live. Non-zero form indices on a case-managed menu ([0,1]on group-payment-testm0) were walked live on 2026-10-07 and reach the form.
- Both modes
Operator one-time setup (only when input_error: 'jar_not_found' fires):
/ace:setup
/ace:setup auto-downloads the latest tagged commcare-cli.jar from
dimagi/commcare-core releases and caches it at
$CLAUDE_PLUGIN_DATA/commcare-cli.jar. Refresh with /ace:setup --force-install; pin a specific build with export ACE_COMMCARE_CLI_JAR=/absolute/path/to/commcare-cli.jar. Java 17+
required. /ace:doctor reports jar presence + cached version.
Step 4.6: Minimum CommCare version vs the pinned APK (dimagi-internal/ace#1997)
Step 4.5's validate + play run a JVM runtime with no
minimum-version gate, so they cannot see the one property that
decides whether any ACE-provisioned device can open the CCZ at all: the
minimum CommCare version stamped into profile.ccpr at the zip root.
<profile version="10" requiredMajor="2" requiredMinor="64"
requiredMinimal="0" uniqueid="cb980f9c…" name="… Learn app"/>
requiredMinimal is the patch component — that profile requires
2.64.0, not a four-part version. HQ/Nova stamps HQ's current release
into it at build time, so it moves whenever HQ ships and nothing in ACE
chooses it. ACE's mobile stack meanwhile pins the CommCare APK
(ACE_CONNECT_APK_VERSION, else DEFAULT_APK_VERSION in
mcp/mobile/client.ts) because each APK version needs a calibrated
selector map. When the CCZ's minimum exceeds the pinned APK, CommCare
renders a version-gate screen instead of launching the app and Phase 6's
device walk dies at the Connect→CommCare app-launch handoff — after an
AVD boot, a PersonalID login and a claim, and under the misleading
claim-START-HANDOFF-WEDGED-issue629 label. Phase 3 is the last point
at which the app can still be rebuilt against a lower target.
Read profile.ccpr out of each unzipped CCZ (Step 3 already holds it)
and classify with checkCczMinVersion from lib/ccz-min-version.ts
— a pure function, unit-tested in test/lib/ccz-min-version.test.ts, so
the version arithmetic is not left to prose:
import { checkCczMinVersion } from '../../lib/ccz-min-version';
const res = checkCczMinVersion({
app: 'learn', // or 'deliver'
profileXml, // text of profile.ccpr from the CCZ root
apkVersion, // ACE_CONNECT_APK_VERSION, else DEFAULT_APK_VERSION
selectorMapVersions, // versions with a map on disk — see below
devicePhasePlanned: true, // false ONLY when this run has no device leg
});
It returns a CheckOutcome (lib/check-outcome.ts), so status: 'unable'
is not a pass — narrow on status before reading anything else. On
unable the comparison never ran (unreadable profile.ccpr, or a pin that is
not a version triple): emit res.reason as a [WARN] and record the check as
UNEVALUATED, never as clean.
Resolve the two runtime inputs rather than hardcoding them:
# the APK Phase 6 will actually run
grep -m1 '^ACE_CONNECT_APK_VERSION=' "${CLAUDE_PLUGIN_DATA}/.env" 2>/dev/null \
|| grep -m1 "DEFAULT_APK_VERSION = " mcp/mobile/client.ts
# versions with a calibrated selector map
ls mcp/mobile/selectors/ | sed -n 's/^connect-\(.*\)\.yaml$/\1/p'
Severity is conditional, and that is the point. The condition is
true on every run today, so a flat [BLOCKER] would trade one Phase-6
failure for a Phase-3 failure on every /ace:run — earlier and cheaper,
but blocking every run behind a remedy no operator can apply mid-run,
which is how a gate gets switched off. A flat [WARN] on an
always-true condition is a line everyone learns to scroll past. So the
severity the helper returns keys on whether a remedy is actually
reachable:
| Outcome | State | Do |
|---|---|---|
status: 'unable' |
profile.ccpr absent/malformed, or the pin is not a version triple |
[WARN] ccz-profile-unreadable / apk-version-unreadable, emitting res.reason. A check that could not run is never a silent pass |
checked, severity: 'ok' |
required ≤ pinned | Record and continue |
checked, severity: 'blocker' |
required > pinned and a calibrated selector map already covers the required version | Halt [BLOCKER] ccz-min-version-gate. The remedy is in-run: repin ACE_CONNECT_APK_VERSION to res.satisfyingSelectorMap |
checked, severity: 'warn' |
required > pinned, no map covers it | [WARN] ccz-min-version-gate, do NOT halt. The remedy is a new APK plus a live-calibrated selector map — human-owned, out of reach mid-run. Continue, and treat every Phase 6 device result from this run as unobtained, not as a pass |
checked, severity: 'info' |
required > pinned but this run walks no device | [INFO]. Nothing breaks here; recorded so the mismatch stays legible |
res.findings[0].message already names the CCZ's required version, the
pinned APK version, and the exact consequence; res.findings[0].remedy
names the fix. Emit both verbatim into auto_surfaced_concerns rather
than paraphrasing — a message that omits either version cannot be acted
on from the log alone. Note severity: 'info' still carries
ok: false: the mismatch is real, it just breaks nothing on a run with
no device leg.
Class: static parsing + version comparison, not device truth. Nothing here is sent to, or matched against, a device — no selector string, no tap coordinate, no recipe step order. Both inputs are text Phase 3 already holds. Per CLAUDE.md § "The trigger is the CLAIM, not the directory", unit tests are complete evidence for this check.
Step 4.7: Re-release invariants against an existing opportunity (dimagi-internal/ace#2691, #2705)
Two checks, one per app — 4.7a Deliver and 4.7b Learn — each run only when that app is being re-released. Both share the same gate below.
4.7a Deliver
Runs only on a Deliver RE-release: when there is an existing
opportunity and the build being QA'd is a newer release of the same HQ app.
The opportunity is an explicit --opportunity-id (a live program, or any
opportunity outside this run), or this run's run_state.yaml
phases.connect-setup.products.connect.opportunity.id (Phase 4
configured an opportunity against an earlier Deliver build). On the first Phase 3 pass
there is no opportunity yet: record rerelease_invariants: not-applicable (no opportunity) and skip.
Run-free, confirm the opportunity is wired to this HQ app before comparing:
connect_get_opportunity({organization_slug, opportunity_id}), whose
dashboard half names the deliver_app / learn_app wiring. A mismatch means
the build is not this opportunity's app. Record not-applicable (opportunity not wired to this app) and name both apps.
Connect matches a forwarded submission by HQ domain + HQ app id, picks the
DeliverUnit — and so the payment unit — by the <deliver id> @id, and
evaluates the opportunity's form_field_rules as JSONPath over the form
JSON by question path (commcare-connect form_receiver/processor.py:78,
:605-613, :428, :395-406; opportunity/models.py:1275-1282). An @id
Connect has not seen creates a DeliverUnit with no payment unit, and the
visit errors Payment unit is not configured (processor.py:429-433). The
form xmlns is never read by Connect. A Nova re-upload re-mints every
form's xmlns but keeps the HQ app id, so the invariants a re-release must hold
are the @ids and the rule paths — not the xmlns.
Baseline build. Use the Deliver build Phase 4 configured the opportunity against — the Deliver
build_idin theapp-release_summary.mdthat preceded Phase 4 (Drive revision history, or the run'sphases.commcare-setuprecord). If that cannot be determined (always the case run-free, unless the caller passes--baseline-build-id), use the previously released build of the same HQ app. Record which one you used.Download the baseline with
commcare_download_ccz({domain, app_id, build_id: <baseline>, write_to_path})and unzip it next to the candidate (Step 2/3 already hold the candidate's forms).Resolve
form_field_rulesper § Inputs, in this order:--form-field-rules, when the caller passed it (an override);- otherwise the LIVE rules, from
connect_get_verification_flags({organization_slug: <program org>, opportunity_id}). That atom is read-only and never POSTs (ace#3161). Use itsform_field_rules(each rule'squestion_path,question_valueanddeliver_unit_ids), and recordrules_source: livewith itssource_path. The page is PM-only, so pass the PROGRAM org, never the holding org. Averification_page_pm_onlyerror means the wrong org: re-resolve it and do not fall through; - otherwise
phases.connect-setup.products.connect.opportunity.verification.form_field_rules(rules_source: run_state).
A live read that returns
form_field_rules: []means the opportunity really has no rules; the@idcheck still runs. When the live read cannot run (no Connect session, or the opportunity is unreachable) and no other source exists, the@idcheck still runs and the rule-path check is recordedunevaluated (no rules source). Never pass an empty list as if the opportunity had no rules.Classify:
import { checkDeliverRerelease, formatDeliverRerelease }
from '../../lib/deliver-rerelease-invariants';
const res = checkDeliverRerelease({
baseline: { buildId: baselineBuildId, forms: baselineForms }, // [{ path, xml }] every modules-*/forms-*.xml
candidate: { buildId: candidateBuildId, forms: candidateForms },
rules: formFieldRules,
});
| Outcome | Do |
|---|---|
status: 'unable' |
[WARN] rerelease-invariants-unable, emitting formatDeliverRerelease(res). Record as UNEVALUATED — never as a pass |
checked, blocking: true |
Halt [BLOCKER] — one entry per blocker finding (deliver-id-disappeared, deliver-id-added, rule-path-missing), message verbatim. Do not let Connect read this build |
checked, form-xmlns-changed finding |
[WARN] form-xmlns-changed, message verbatim, do NOT halt. It names the consumers it splits: HQ exports/reports/UCR keyed on xmlns, and connect-labs CCHQ-form pipelines (cchq_fetcher.py:160-192 fetches only the CURRENT app's xmlns, so pre-release submissions drop out). Connect payment is unaffected, and ACE cannot preserve xmlns across a Nova upload, so a halt would have no in-run remedy |
checked, ok: true |
Record and continue |
Record under per_app.deliver.rerelease_invariants. Enforced:
test/lib/deliver-rerelease-invariants.test.ts — positive control is the real
v9 → v23 Deliver meeting form of spark-facilitator/20261004-1706 (same
@id community_meeting, same three rule paths, new xmlns → pass + one WARN);
negative controls are a renamed @id and a removed rule question (both BLOCK).
Static parsing of two released CCZs — unit tests are complete evidence.
4.7b Learn (dimagi-internal/ace#2705)
Runs only on a Learn RE-release against an existing opportunity — same
gate as 4.7a (--opportunity-id given, or
phases.connect-setup.products.connect.opportunity.id is set, and the Learn
build being QA'd is a newer release of the same HQ app).
Otherwise record rerelease_invariants: not-applicable (no opportunity)
under per_app.learn and skip.
Connect creates one LearnModule row per Learn <module id> at opportunity
creation and never deletes one (commcare-connect opportunity/tasks.py:86-95);
a submission's module block is matched by @id, and an unseen @id
creates a new row lazily (form_receiver/processor.py:107-116). Learn
progress divides by the row count (opportunity/models.py:385-392) and Learn
completes only at 100%. So a module @id that disappears caps every new-build
worker below 100%, and one that appears drops every worker who already
finished. The assessment is different: process_assessments
(processor.py:208-241) reads only user_score and never the assessment
@id — what matters is that an <assessment> block still exists.
- Baseline build — the Learn
build_idPhase 4 configured the opportunity against (same sourcing rule as 4.7a step 1), else the previously released Learn build. Record which. - Download it with
commcare_download_ccz({domain, app_id, build_id: <baseline>, write_to_path})and unzip it next to the candidate. - Classify:
import { checkLearnRerelease, formatLearnRerelease }
from '../../lib/learn-rerelease-invariants';
const res = checkLearnRerelease({
baseline: { buildId: baselineBuildId, forms: baselineForms }, // [{ path, xml }] every modules-*/forms-*.xml
candidate: { buildId: candidateBuildId, forms: candidateForms },
});
| Outcome | Do |
|---|---|
status: 'unable' |
[WARN] rerelease-invariants-unable, emitting formatLearnRerelease(res). Record as UNEVALUATED — never as a pass |
checked, blocking: true |
Halt [BLOCKER] — one entry per blocker finding (learn-module-id-disappeared, learn-module-id-added, assessment-disappeared), message verbatim. Do not let Connect read this build |
checked, assessment-id-changed / form-xmlns-changed |
[WARN], message verbatim, do NOT halt — Connect reads neither |
checked, ok: true |
Record and continue |
Record under per_app.learn.rerelease_invariants. Enforced:
test/lib/learn-rerelease-invariants.test.ts — positive control is the real
v11 → v12 Learn re-release of spark-facilitator/20261004-1706
(connect-ace-prod app dcd4889a…, builds 4cafbd1b… → 6dbbbb2f…: modules
m0_pretest…m6_payment and assessment final_quiz unchanged → clean pass);
negative controls are a renamed module @id and a removed assessment block
(both BLOCK), plus a renamed assessment @id and a re-minted xmlns (WARN only).
Step 4.8: [FIXED] instrument verbatim fidelity (dimagi-internal/ace#2366)
Deliver app only, and only when the PDD marks an instrument [FIXED].
pdd-to-deliver-app § Step 4k diffs the instrument's POINT VALUES against the
source; it cannot see an option that scores 0. On
poverty-graduation/20260908-0510 the released Deliver app added
"Declined or does not know" to one of the Nigeria PPI's ten questions — a
verbatim-only instrument whose licence permits digitising it only unmodified —
and this skill and app-release-eval both passed it. This step reads the
RELEASED form, not the blueprint.
Resolve the instrument file exactly as Step 4k did (its
## Build notesrecord thefile_id); read it withdrive_read_file(a markdown table) and parse withparseInstrumentMarkdown. A published.xlsx/.pdfsource has no question-and-option table for this helper — use theinputs/markdown instrument the PDD cites; if there is none, recordunablewith that reason.Run the check over every Deliver form XML Step 3 parsed:
import { parseInstrumentMarkdown, checkFixedInstrumentVerbatim, } from '../../lib/fixed-instrument-verbatim'; const res = checkFixedInstrumentVerbatim({ instrument: parseInstrumentMarkdown(instrumentMd), forms: deliverFormXmls, // ONLY where the instrument itself says the question is not asked as-is, // quoting it — e.g. the PPI's household-size indicator, derived from a roster. exempt, });
| Outcome | Do |
|---|---|
No [FIXED] instrument in the PDD |
fixed_instrument_verbatim: not-applicable |
| Instrument file unreadable / no question table | [WARN] fixed-instrument-unable, record the reason — never a pass |
ok: true |
Record matched + exempted and continue |
any option-added / option-dropped / option-order-changed / question-text-changed / question-not-found |
Halt [BLOCKER] fixed-instrument-not-verbatim, one entry per finding, message verbatim. Repair goes back through pdd-to-deliver-app (Nova edit → re-release), not a CCZ patch |
Record under per_app.deliver.fixed_instrument_verbatim. Enforced:
test/lib/fixed-instrument-verbatim.test.ts — the instrument fixture is the
real poverty-graduation inputs file and the positive control is the released
targeting survey of poverty-graduation/20260915-1518; the negative control
re-adds the observed "Declined or does not know" fan option.
Step 4.9: Every paid form records the followed entity's id (dimagi-internal/ace#2889)
Deliver app only, and only when the PDD's
program_parameters.followed_entity_case_type names a case type. A paid
form that updates a child case (a crop cycle) can submit nothing that names
the person the programme follows: on smallholder-seasonal/20261012-1549
(HQ 6137de98 v23) the paid "Harvest visit" wrote only
update_crop_cycle/case/@case_id and a non-crop-only index/parent, so no
report could attach a crop-cycle harvest to the woman.
semantic-registry-author-qa's entity-key check saw it at Phase 7; this
step stops the build shipping it. The released form XML is the authority.
import { parseFollowedEntityCaseType, checkFollowedEntityId } from '../../lib/followed-entity-id';
const declared = parseFollowedEntityCaseType(programParameters.followed_entity_case_type);
const res = checkFollowedEntityId({
caseType: declared.caseType!,
forms: deliverFormXmls, // { path: 'modules-N/forms-M.xml', xml } from Step 3
// ONLY the paid forms pdd-to-deliver-app's summary records under
// `followed_entity_id.exempt`, each with its reason — never added here.
exempt,
});
| Outcome | Do |
|---|---|
Row absent, none (<reason>), or archetype neither longitudinal-visits nor multi-stage |
followed_entity_id: not-applicable |
| Row present but unparseable | [WARN] followed-entity-unable, record the parser's problems — never a pass |
ok: true |
Record field, paid_forms, exempted, and any warn findings (field written only under a relevant) and continue |
any fail finding |
Halt [BLOCKER] followed-entity-id-missing, one entry per form, detail verbatim. Repair goes back through pdd-to-deliver-app (Nova edit adding the hidden derived field → re-release), not a CCZ patch |
Record under per_app.deliver.followed_entity_id. Enforced:
test/lib/followed-entity-id.test.ts — the negative control is the released
smallholder-seasonal Deliver forms (test/fixtures/layer1/); the positive
control is the same Harvest visit with the build rule applied.
Step 5: Write verdict
Write 3-commcare/app-release-qa_result.yaml. Shape:
skill: app-release-qa
target: <opp>
run_id: <run-id>
ran_at: <ISO-8601>
schema_version: 1
verdict: pass | fail
overall_score: <number> # 10.0 on pass, 0 on fail
per_app:
learn:
hq_app_id: <id>
build_id: <id>
form_count_blueprint: <int>
form_count_ccz: <int>
form_count_match: true | false
starter_module: # ace#1787 — auditReleasedModules()
status: checked | unable # `unable` is NOT a pass
reason: <str> # unable branch only
ok: true | false # checked branch only
declared_structure: compared | unavailable
findings: # checked branch only
- kind: starter-module | undeclared-module
tier: blocker | suspect
module: <str>
form: <str> # starter-module findings only
reason: <str>
learn_module_markers:
blueprint_count: <int>
ccz_count: <int>
match: true | false
assessment_markers:
blueprint_count: <int>
ccz_count: <int>
match: true | false
field_fidelity: # ace#2285 — SET comparison, not counts
- form_path: modules-0/forms-0.xml
basis: field-id-set # what was actually compared
match: true | false # false iff `missing_from_ccz` is non-empty
missing_from_ccz: [<field-id>, ...] # the gating signal
unexpected_in_ccz: [<field-id>, ...] # recorded; not gating on its own
counts: # CONTEXT ONLY — never gate on these
blueprint: <int> # Nova counts sections + labels
ccz_controls_and_binds: <int> # reads LOW on section-using forms
ccz_binds_only: <int>
- ...
cli_validate:
validate:
verdict: pass | fail | unavailable
exit_code: <int>
failed_resource: <descriptor when verdict=fail, optional>
parser_message: <exception:msg when verdict=fail, optional>
play:
verdict: pass | fail | unavailable | skipped
exit_code: <int>
entry_path: [0, 0]
failing_binding: </data/...> # when verdict=fail
unresolved_xpath: <xpath> # when verdict=fail
parser_message: <exception:msg>
min_commcare_version: # ace#1997 — checkCczMinVersion()
status: checked | unable # `unable` is NOT a pass
reason: <str> # unable branch only
required: <str> # e.g. "2.64.0", from profile.ccpr
apk_pinned: <str> # ACE_CONNECT_APK_VERSION / DEFAULT_APK_VERSION
severity: ok | info | warn | blocker # checked branch only
satisfying_selector_map: <str|null> # lowest map >= required, if any
findings: # checked + !ok only
- kind: ccz-min-version-gate
message: <str> # verbatim — names both versions
remedy: <str> # verbatim
rerelease_invariants: # ace#2705 — checkLearnRerelease(); re-release only
status: checked | unable | not-applicable # `unable` is NOT a pass
reason: <str> # unable / not-applicable only
baseline_build_id: <id>
baseline_source: phase4-configured | previous-release
blocking: true | false
module_ids: { baseline: [...], candidate: [...] }
assessment_ids: { baseline: [...], candidate: [...] }
xmlns_changes: [{ form, from, to }] # WARN only
findings: [{ kind, severity, message }]
deliver:
hq_app_id: <id>
build_id: <id>
form_count_blueprint: <int>
form_count_ccz: <int>
form_count_match: true | false
starter_module: # ace#1787 — auditReleasedModules()
status: checked | unable # `unable` is NOT a pass
reason: <str> # unable branch only
ok: true | false # checked branch only
declared_structure: compared | unavailable
findings: # checked branch only
- kind: starter-module | undeclared-module
tier: blocker | suspect
module: <str>
form: <str> # starter-module findings only
reason: <str>
deliver_unit_markers:
blueprint_count: <int>
ccz_count: <int>
match: true | false
task_markers:
blueprint_count: <int>
ccz_count: <int>
match: true | false
field_fidelity: [...] # ace#2285 — same SET shape as learn
cli_validate:
validate:
verdict: pass | fail | unavailable
exit_code: <int>
failed_resource: <optional>
parser_message: <optional>
play:
verdict: pass | fail | unavailable | skipped
exit_code: <int>
entry_path: [0, 0]
failing_binding: <optional>
unresolved_xpath: <optional>
parser_message: <optional>
min_commcare_version: # ace#1997 — same shape as learn, above
status: checked | unable
reason: <str>
required: <str>
apk_pinned: <str>
severity: ok | info | warn | blocker
satisfying_selector_map: <str|null>
findings: [...]
rerelease_invariants: # ace#2691 — checkDeliverRerelease(); re-release only
status: checked | unable | not-applicable # `unable` is NOT a pass
reason: <str> # unable / not-applicable only
baseline_build_id: <id>
baseline_source: phase4-configured | previous-release
blocking: true | false
deliver_ids: { baseline: [...], candidate: [...] }
rule_paths_checked: [...]
xmlns_changes: [{ form, from, to }] # WARN only
findings: [{ kind, severity, message }]
# Per-app blocks additionally carry (both apps; see Step 4):
# time_estimates: pass | { modules_checked, violations: [...] } # Learn app only
# camera_only_uploads: pass | not-required-by-pdd | [<offending upload refs>]
# geopoint_binds: pass | [<offending field paths>]
# casedb_preloads: pass | [<visible question refs answered from the case>]
# # BLOCKER-gated (ace#2006); Deliver app
# repeat_counts: pass | { count_bound, violations: [{ repeat, countRef,
# workerSources }] }
# # BLOCKER-gated (ace#2517); both apps, every form
# payable_cap_arithmetic: { status: checked|unable, reason, ok, node, timing,
# clamp: { threshold, clampedValue }, capacity, cap,
# cap_source: declared|is_payable, indices: [...],
# findings: [...] }
# # BLOCKER-gated (ace#2148); Deliver app. `unable` is
# # NOT a pass — record the reason, treat as UNVERIFIED
# constraint_locality: pass | { constraints_checked, violations: [...] }
# relevance_reachability: { relevances_checked, wholly_unreachable: [...], partial: [...] }
# # derived from violations[].whollyUnreachable (ace#1539);
# # the lib exposes NO checked/unreachable/partial fields
# grid_menu_display: { use_grid_menus, grid_form_menus,
# modules_checked, modules_gridded, non_grid: [...] }
# # all three fields are BLOCKER-gated (ace#1082)
# fixed_instrument_verbatim: not-applicable | unable | { matched, exempted, findings: [...] }
# # BLOCKER-gated (ace#2366); Deliver app, [FIXED] instrument only
# followed_entity_id: not-applicable | unable | { field, paid_forms: [...], exempted: [...], findings: [...] }
# # BLOCKER-gated (ace#2889); Deliver app, followed_entity_case_type declared
# language_tables: pass | unable | { present: [...], declared: [...],
# orphans: [...], missing: [...], display_names: {...} }
# # FINDING-gated, never a halt (ace#2292) — an orphan
# # table is unreachable to a worker, so it is reported
# # as a Phase 3 residual, not a release blocker
auto_surfaced_concerns:
- severity: BLOCKER | WARN | INFO
message: "..."
blockers: [...]
Use verdict: unavailable when commcare_validate_ccz returned
input_error: jar_not_found (the operator hasn't built the jar yet).
Pair with a [WARN] auto_surfaced_concerns entry pointing at the
Step 4.5 setup block. Use verdict: fail only for real install-time
defects.
Mode behavior
- Auto: write the verdict, halt on first BLOCKER.
- Review: same — this is a structural check with no human-judgment step.
- Dry-run: do the downloads + parses, write the verdict, but mark
status as
dry-run-success/dry-run-blockedinstead ofpass/fail.
Failure modes
download-failed— CCHQ returned non-zip bytes or an error. Likely a transient CCHQ issue or a wrong build_id. Operator action: verify the build_id inapp-release_summary.mdmatches the released build.cczunzip-failed— zip decode failed. Likely a corrupted release. Re-runapp-releaseto remake + re-release.suite-xml-missing-or-malformed/xform-parse-failed— Nova emitted invalid XML. Halt; re-runpdd-to-{learn,deliver}-app.form-count-mismatch— Nova said N forms, CCZ has M. Likely Nova partial-persistence on form creation. Re-run the build.starter-module-present— Nova's canonical starter module (menu "Survey" → form "Survey" → one text fieldquestion_1labelled "Question 1") survived to release, or the app carries a module the build summary never declared. Form-count equality cannot see this — the module is in the blueprint and the CCZ alike — which is why the check is identity-based (lib/starter-module.ts). Operator action: remove the module in Nova, re-runapp-deploy→app-hq-settings→app-release, then re-run this gate. On a Deliver app treat it as blocking: an FLW tapping that menu lands in a dead form that writes nothing and is not a payable unit. (ace#1787)cli-validate-parser-error—commcare-cli.jar validate(the parser-class pre-screen) surfaced anXFormParseException/InvalidStructureException/InvalidResourceException/UnresolvedResourceException. The CCZ is structurally broken at the XML / suite / profile level. Halt loud; root cause is upstream inpdd-to-{learn,deliver}-app(Nova emitted malformed XML). No need to also runplay— the structural defect is authoritative.cli-form-init-error—commcare-cli.jar playsurfaced anXPathTypeMismatchException/Calculation Error/Logic references … which is not a valid question or valueduringFormDef.initAllTriggerables: at least one form's XPath binding can't resolve at form-init. This IS the bednet bug class (see reference.md § Runtime install validation). Verdict YAML'sper_app.<app>.cli_validate.play.{failing_binding, unresolved_xpath, parser_message}name the exact defect. The most common cause is aconnect.deliver_unit.entity_id(orentity_name) bound to a runtime-unresolvable XPath. Halt loud; the operator's fix is usually apdd-to-{learn,deliver}-appre-build flipping the entity_id substitution perdocs/learnings/2026-05-25-entity-id-misdiagnosis.md.case-list-enum-drift— an id-mapping case-list column renders a case property through a different taxonomy than the formselect1that writes it, so the worker reads one vocabulary off the tile before the visit and picks from another during it (dimagi-internal/ace#1688).[BLOCKER]: both surfaces are well-formed in isolation, and this gate is the only place they are compared. Operator fix: re-runpdd-to-deliver-app— its Step 4l must derive the case-list enum from the SAME itemset it repairs the form's option labels from — then re-release and re-runapp-release-qa. Do not hand-edit the enum: an instance fix leaves the next build free to reinvent it.undeclared-language-table— the CCZ ships a<lang>/app_strings.txtthat its ownsuite.xmlnever declares as a<locale language="…">.[FINDING], not a halt — the device is only offered declared locales, so the table is unreachable to a worker and the run continues. Operator action: FIRST confirm the table is in the archive (unzip -l, or the file map came fromreadCczTextFiles) — the only reported case (dimagi-internal/ace#2292,spark-facilitatortables "in"bednet-check-2-visit's Deliver CCZ) was residue of an earlier extraction in a reused directory, and the build itself was clean. If it is real, record the orphan languages and theirapp.display.nameas a Phase 3 residual and check them against the PDD's declared working language. Do not hand-edit the zip.ccz-min-version-gate— the released CCZ'sprofile.ccprdeclares a minimum CommCare (requiredMajor.requiredMinor.requiredMinimal) ABOVE the APK Phase 6 will run, so CommCare shows a version-gate screen instead of launching the app and the device walk dies at the Connect→CommCare app-launch handoff (dimagi-internal/ace#1997). Step 4.5'svalidate+playcannot see this — the JVM runtime has no minimum-version gate — so this is the only Phase 3 surface where it is observable, and Phase 3 is the last point at which the app can be rebuilt against a lower target. Severity is conditional (Step 4.6):[BLOCKER]when a calibrated selector map already covers the required version (the remedy is in-run — repinACE_CONNECT_APK_VERSION),[WARN]when none does (the remedy is a new APK plus a live-calibrated selector map, which is human-owned and out of reach mid-run; halting there would block every/ace:runbehind work no operator can do, and a gate like that gets switched off). On the WARN branch the run continues, but every Phase 6 device result from it is unobtained, not a pass.deliver-id-disappeared/deliver-id-added/rule-path-missing—[BLOCKER](Step 4.7, re-release only). The new Deliver build no longer carries a<deliver id>the existing opportunity was configured on, carries one Connect has never seen (its visits errorPayment unit is not configured), or dropped/moved a question a configuredform_field_rulesrow reads. Operator action: restore the@id/ question path in Nova and re-release, or reconfigure the opportunity in Connect before releasing (dimagi-internal/ace#2691).learn-module-id-disappeared/learn-module-id-added/assessment-disappeared—[BLOCKER](Step 4.7b, Learn re-release only). The new Learn build dropped or added a<module id>the existing opportunity's learn progress is counted against, or no longer carries an<assessment>block. Operator action: restore the@id/ assessment block in Nova and re-release (dimagi-internal/ace#2705).assessment-id-changed—[WARN](Step 4.7b). Connect never reads the assessment@id; only non-Connect consumers keyed on it are affected.form-xmlns-changed—[WARN](Step 4.7a/4.7b). Nova re-minted form xmlns on re-upload. Harmless for Connect; splits HQ exports/UCR and connect-labs CCHQ-form pipelines keyed on xmlns. Not a halt — ACE has no remedy.ccz-profile-unreadable/apk-version-unreadable—[WARN]. The version-gate comparison could not be made:profile.ccprwas absent or malformed, orACE_CONNECT_APK_VERSIONis not aMAJOR.MINOR.PATCHtriple. A check that could not run is recorded as such, never as a pass.cli-validator-unavailable—commcare-cli.jarnot on the operator's machine (resolved jar path returnedinput_error: jar_not_found). This is[WARN], not[BLOCKER]— Steps 3–4 still gate structural defects; this just means the install-time gate is off. Operator fix: run/ace:setup(auto-downloads the latest jar fromdimagi/commcare-corereleases).learn-module-time-estimate-implausible— aconnect.learn_module.time_estimatein the released Learn CCZ is not plausible as hours against the PDD's stated module duration (the unit is hours end-to-end in Connect; a raw minute count renders as "N hours" on every FLW's onboarding screen — see Step 4 + dimagi-internal/ace#1077 + voidcraft-labs/nova-plugin#36). Operator fix: re-runpdd-to-learn-app(its brief carries the resolved hours instruction with source citations), re-release, then re-runapp-release-qa.learn-module-display-gated— aconnect.learn_moduleform in the released Learn CCZ sits behind a display condition on a user property, so workers who fail it can never complete Learn (Connect counts every marked module; ace#3036). Operator fix: rebuild perpdd-to-learn-app § Step 2a(every marked module visible to every worker; the restructuring choice is thelearn-gap-multi-role-learn-completiondecision row), re-release, then re-runapp-release-qa.learn-marker-missing/deliver-marker-missing— released CCZ doesn't carry the Connect-marker wrappers Connect's sync requires. Halt; investigate Nova-side OR check if any post-build patcher (which should be none —commcare-form-patchwas removed in PR #423) is stripping markers.field-count-mismatch— silent field omission: a field the Nova blueprint declares by id has no corresponding node in the released form XML. Re-run the build (Nova partial-persistence is usually fixed on retry). Fires on a named set difference only — never on a raw count difference, which has no shared basis between blueprint and CCZ and reads LOW on any section-using form (ace#2285; see Step 4 § Field fidelity per form).geopoint-bind-downgrade— akind:geopointfield compiled to an XForm bindtype="xsd:string"instead oftype="geopoint"in the released CCZ (stale / downgraded build; see reference.md § Geopoint bind-type fidelity). Operator fix: re-build & re-release the app from the current Nova blueprint (Nova compiles geopoint correctly today), then re-runapp-release-qato confirmtype="geopoint".camera-only-appearance-missing— the PDD demands live-camera-only photo capture but an image<upload>node in the released Deliver form XML has noappearancecontainingacquire, so the on-device widget shows CHOOSE IMAGE (gallery uploads permitted; see Step 4- dimagi-internal/ace#867). Operator fix: apply the camera-only
appearance flip (HQ app builder or Nova), re-release, then re-run
app-release-qato confirm the attribute is in the released CCZ.
- dimagi-internal/ace#867). Operator fix: apply the camera-only
appearance flip (HQ app builder or Nova), re-release, then re-run
grid-menu-display-missing— the released app's raw doc (/a/<domain>/apps/source/<build_id>/) fails any of the three grid fields:use_grid_menus != true,grid_form_menus != 'some', or a module withdisplay_style != 'grid'— soapp-hq-settingseither didn't run, fail-softed, ran a pre-ace#1082 version that only set the per-module half, or was applied to a draft that was superseded before the release (see Step 4 + dimagi-internal/ace#1009 + #1082). Operator fix: re-runapp-hq-settings, re-release, then re-runapp-release-qa. Do not try to confirm this fromsuite.xmlorGET /api/v0.5/application/— suite.xml only carriesstyle="grid"whengrid_form_menus == 'some'(bare on the half-applied app, and never shows the two app-level flags), and the REST API reads a misleadingNonefor a correctly-gridded module (ace#1246).non-local-constraint— aconstraintin the released form XML references a question or repeat the user cannot edit from the screen where it fires, so the error is unfixable in place and the FLW must navigate backward to a question that gave no sign of a problem (see Step 4 + dimagi-internal/ace#980). Operator fix: move the constraint onto the node it is actually about — a GPS-accuracy rule onto the geopoint itself, a repeat-cardinality rule onto the repeat or a gate immediately after it — then re-release and re-runapp-release-qa. Onlyseverity: 'blocker'violations halt; questions sharing anappearance="field-list"group are one screen and are not violations at all (ace#1019).dead-repeat-cardinality-gate— a minimum-rows gate (`count(<repeat>…)= N
, N >= 1) is bound to a node INSIDE the repeat it counts, so it never evaluates at zero repetitions: the visit submits with an empty repeat and whatever the repeat feeds derives from nothing (see Step 4 + dimagi-internal/ace#1560, where an empty roster forfeited the PPI card's largest single indicator, 31 points, with nothing on screen to betray it). **This one is invisible to a device walk** — the same reason the FLW never sees it — so the released binds are the only place it surfaces. Operator fix: add a gate question **immediately after the repeat** carrying the same rule (the shape this checker already whitelists), and drop the inside-the-repeat copy; then re-release and re-runapp-release-qa. Not fixable on the repeat container: Nova answerskind "repeat" carries no 'validate' slot` (ace#1560).dead-repeat-count— a count-bound repeat'sjr:countnode is written only by anxforms-readysetvalue that reads a question the worker answers in the same form, so the count is empty at form open and the repeat renders zero rows on every submission (see Step 4 + dimagi-internal/ace#2517). Operator fix is in the Nova blueprint, not HQ: re-runpdd-to-deliver-appwith itsrepeat-count-sourcerule (a worker-stated count becomes auser_controlledrepeat whose number is derived ascount(<repeat>)), re-deploy, re-release, re-runapp-release-qa. Do not patch the XForm to add acalculateon the helper — the next Nova upload overwrites it, and the patch has never been validated on a device.cross-screen-constraint—[WARN], not a halt. Aconstraintreaches into another screen, but it also references its own node, so the FLW clears it by changing the answer in front of them. Worth tightening (move the rule onto the screen that owns both numbers) but it does not trap anyone, so it never blocks a release.
MCP tools used
- ace-connect:
commcare_download_ccz,commcare_validate_ccz - nova:
get_app(form/marker counts),get_form(per-fieldkindfor the geopoint bind-type check) - ace-gdrive:
drive_read_file,drive_create_file - CCHQ HTTP probe (no atom yet): authenticated
GET /a/<domain>/apps/source/<build_id>/for the grid menu-display check — the only surface that exposesmodules[].display_style(see Step 4). A dedicated app-source atom onace-connectwould be the natural home (ace#1009 names it as an optional follow-up); until one exists this is a raw session-cookie GET.
Writing the result (shared writer — every -qa skill)
Write the result file through the shared writer, never by hand:
collect one outcome per check you evaluated ({check, type, result: {pass, detail, auto_fix_hint}}, or {check, not_judged: "<why>"} for a check whose
input does not exist yet) into a local JSON file, then
node "$ACE_ROOT/node_modules/tsx/dist/cli.mjs" "$ACE_ROOT/scripts/qa-result.ts" write \
--skill <this skill> --target <opp>/<run-id> --capture-path <artifact path> \
--outcomes <outcomes.json> --out <local result.yaml>
and upload that file. It derives stats from the outcomes and a result that
evaluated nothing is a FAIL (aggregateQAResult, lib/qa-types.ts); ACE's own
ace-gdrive write guard turns a *-qa_result.yaml in any other shape into an
INVALID_QA_RESULT error (lib/qa-result-write-guard.ts). A hand-written shape reads on ace-web as
"Passed (0/0 checks)" — skills/_qa-template.md § QA result YAML contract.