pdd-to-deliver-app
Build the CommCare Deliver (service-delivery) app from an approved PDD via Nova. Needs an approved PDD.
Files2
View on GitHubSKILL.mdskills/pdd-to-deliver-app/SKILL.md
GitHubPDD to Deliver App
Generate the Deliver (service delivery) app from the PDD using the Nova
plugin (voidcraft-labs/nova-marketplace, slash command
/nova:autobuild).
Inputs
| Source | Artifact | Used for |
|---|---|---|
| Phase 1 | 1-design/idea-to-pdd.md |
source PDD; archetype + Deliver App Specification + delivery unit drive the Nova brief |
| Run root | runs/<run-id>/inputs-manifest.yaml |
the resolver from a PDD-named [FIXED] source instrument to the file_id of its published file in inputs/. Step 4k reads that file — the workbook/PDF itself, never the brief — to check every scoring constant against the source (ace#1527). inputs[] lists direct child FILES only, so when the published bundle is a SUBFOLDER of inputs/ 4k walks the manifest's own recorded subfolders_not_listed[].folder_id one level to find it (ace#1648). No [FIXED] instrument → 4k skips and says so; a [FIXED] instrument whose source cannot be resolved → 4k HALTS, never skips. |
Products
3-commcare/pdd-to-deliver-app_summary.md— Deliver-app structure summary (forms, fields,nova_app_id), whose body ends with a## Build notessection for the build team's non-decision notes (Step 7)- decision rows in the run's
decisions.yaml(phase: 3-commcare,skill: pdd-to-deliver-app), one per latitude taken and per ambiguity hit — the run's review artifact (Step 7, § Decisions Log)
Terminology — every string a worker sees
App name, menu (module) names, form names, question labels, hints, option labels, validation
messages and case-list column headers are reader-visible text, and
skills/_terminology.md binds them. In particular US spelling, always
(owner, 2026-10-20, no carve-out for worker-facing labels): "Enroll adults", "Adult Enrollment",
"program", "organization" — never "Enrol", "Enrolment", "programme", "organisation", even when the
PDD, the partner or the country uses UK English (connect-fi/20261018-1440 shipped "Enrol adults" /
"Adult Enrolment" on-device — ace#3232). Translations are the target language's own; the rule binds
the English source. Never respell an identifier — case types, case property names and question
ids (days_to_enrolment) keep their spelling, and the Nova prompt cue "CommCare Connect markers" is a
code-level phrase that stays as written.
Process
Read the PDD from
ACE/<opp-name>/runs/<run-id>/1-design/idea-to-pdd.mdvia Google Drive MCP.Extract the Deliver app spec from the PDD. Pay special attention to the delivery unit — this is the most archetype-sensitive part of the spec and determines the form structure (see
## Archetypesbelow).Compose a Nova brief — a single natural-language description that
/nova:autobuildconsumes as its sole argument. Nova does not accept file paths or markdown attachments. The brief should:Open with the delivery purpose and the verification artifact (1–2 sentences)
State the archetype framing explicitly (atomic-visit vs. focus-group session vs. multi-stage)
Explicitly state this is a CommCare Connect Deliver app and that every form needs the appropriate
connect.deliver_unit(ortask) block per CommCare Connect's rules. This is load-bearing language — without it, autobuild often skips the per-form Connect blocks even though its system prompt knows about them. Theapp-connect-coverageskill in Phase 3 Step 1.5 is the safety net for cases where the brief was vague, but the more robust path is for this brief to be unambiguous up front. Seedocs/learnings/2026-04-29-nova-connect-marker-bugs.md§ Bug 1 for the prompt-quality dependency.State the marker MECHANISM: a Deliver app must carry an APP-LEVEL Connect mode of
deliver— set viaconfigure_connect({work_id, request_id, mode: "deliver", participants})— AND every paid form must carry aconnect.deliver_unitblock. The app-level mode is what makes Nova's compiler emit the<learn:deliver>marker into the released CCZ. Leaving the app with no Connect mode ships a marker-less CCZ even when each form already has aconnect.deliver_unitblock — Connect surfaces zero deliver units → Phase 4 cannot create a payment unit. Per form, setconnect.deliver_unit: {name, id, entity_id, entity_name}— via theparticipants[]entry onconfigure_connect, or viaupdate_form({moduleUuid, formUuid, connect})after build; the form staystype: registration— there is no special deliver form type. This mirrors the Learn app, which compiles itslearn_module/assessmentmarkers because its app-level mode islearn. Namemode: "deliver"explicitly in the brief so the architect sets it as part of the build, not as an afterthought.- Do NOT tell the architect the marker is "module-level" via
module_type/add_module, and do NOT let the app land with no Connect mode. Live Novaupdate_moduleaccepts onlyname(nomodule_type); there is noadd_moduletool (it iscreate_module, which has no Connect parameter). A brief that frames the marker as module-level leads the architect to leave the app-level mode unset and ship a marker-less CCZ — the root cause of themalaria-rdt/20260603-1600Phase 3 halt. update_appno longer carriesconnect_type— it was removed in Nova's 2026-07-31 redeploy (dimagi-internal/ace#1133) andupdate_app({work_id, request_id, name})now sets the display name only.configure_connectis the ONLY path to the app-level mode, and it is REPLACE-ALL: every form omitted fromparticipants[]has its Connect block CLEARED. See Step 4e.- The
get_app/get_form[Connect enabled]flag is a FALSE POSITIVE for compile — it shows whenever a form carries aconnect.deliver_unitblock, even whenconnect_type: ""means the marker will NOT compile. Never treat[Connect enabled]as evidence the marker shipped; verify against the compiled CCZ (connect_markers.deliver ≥ 1, see Step 4e +app-release-qa).For the mechanism rationale + the controlled malaria-rdt disproof, see reference.md § Marker mechanism.
- Do NOT tell the architect the marker is "module-level" via
REQUIRED — every form that needs its own paid deliver_unit MUST live in its own module. Nova's
compile_appemits the module slug as the<learn:deliver id="...">attribute for every form in the module, and Connect's HQ→Connect sync dedups deliver_units by(app, slug). Two forms in one module produce ONE deliver_unit (named after the first form, second form silently unpaid in production). Architect each module with exactly one paid form. Insert this paragraph verbatim into the brief, in its own paragraph, prefixedREQUIRED::REQUIRED: Every form that needs its own paid deliver_unit MUST live in its own module. Connect's HQ→Connect sync dedups DeliverUnit records by
(app, slug)and Nova'scompile_appreuses the module slug as the<learn:deliver id>for every form in that module. The result: two forms in one module collapse into ONE deliver_unit with the first form's name, leaving the second form's submissions silently unpaid because no payment_unit can be wired to a non-existent deliver_unit. The default Nova choice — group related forms into one module — does not transfer to Deliver apps. Use exactly one paid form per module.See
feedback_connect_deliver_unit_per_modulememory for the full mechanism + reproduction history.Describe the delivery form's structure section by section
List the required Connectify fields (Deliver Unit, Entity ID)
Reference the relevant PDD section (Evidence Model, Output Specification, etc.)
REQUIRED — Forbid angle-bracket placeholder notation in label/option/hint text. Insert this paragraph verbatim into the brief, in its own paragraph, prefixed
REQUIRED::REQUIRED: Do NOT use literal
<or>characters in any form label, option label, hint text, constraint message, or itext value. Nova's XForm emitter does not entity-encode</>in label text, so a literal "<placeholder>" or "<expected format>" becomes invalid XML when CCHQ parses the form duringmake_build(CCHQ rejects with "Error parsing XML: StartTag: invalid element name"). Use words ("placeholder text", "the expected format") or backticks (expected format) for placeholder syntax.&is NOT restricted: Nova entity-encodes it to&and HQ builds it, so keep a partner's published name verbatim,&included (verified against a released build, ace#2150). This applies to hint text and constraint messages too, anywhere literal</>would be tempting (e.g. format hints, validator-message templates).Filed upstream as voidcraft-labs/nova-plugin issue #15 ("XForm emitter does not entity-encode
</>in label text"); this skill-side constraint is the workaround. Phase 3'sapp-releaseStep 4a surfaces a typedBuildRejectedError(with form name + line/col) and dispatches a Nova architect repair loop if the architect violates this constraint anyway, so the operator gets clean diagnostic + auto-recovery instead of "Cannot make new version" + a CCHQ UI peek. Seedocs/learnings/2026-04-29-nova-connect-marker-bugs.md§ Bug 4.REQUIRED — Set
connect.deliver_unit.idANDconnect.task.idexplicitly to short stable identifiers, separately from the human- readablename. This is the load-bearing constraint; the ≤40-char name fallback below is just a safety net. Insert this paragraph verbatim into the brief, in its own paragraph, prefixedREQUIRED::REQUIRED: Every
connect.deliver_unitandconnect.taskblock MUST include an explicitidfield. The id is the Connect slug — it MUST be short (8-20 chars), lowercase, snake_case, code-like, and stable across renames of the human-readable name. Examples:shop_registration,sample_prep_initial,wohl_shipment. Do NOT rely on Nova's default derivation (which slugifies the module name) — that conflates the Connect slug with the display name and trips Connect's 50-charDeliverUnit.slugcolumn on any name that slugifies past ~40 chars. Thenamefield is a separate, human- readable string that can be any length and is what shows up in the deliver-unit picker on Connect — terseness is preferred for picker readability but not required for correctness once the id is set explicitly. Vellum-authored apps (the human-driven authoring path in HQ's form designer) separate these into two UI fields ("Delivery Unit ID" / "Task ID" and "Name") and humans naturally pick short identifiers; Nova's API exposes the same two fields but the architect has to set both explicitly because there's no UI to nudge the separation. Seedocs/learnings/2026-05-17-connect-slug-length-50-char-trap.md§ Generalization (Vellum-as-source-of-truth) for the full mechanism- source citations.
REQUIRED — Keep deliver_unit/task names short enough that the derived slug fits Connect's 50-char column (FALLBACK). This is the defense-in-depth fallback for cases where the explicit-id rule above is missed. Insert this paragraph verbatim into the brief, in its own paragraph, prefixed
REQUIRED::REQUIRED: If you have not set
connect.deliver_unit.id/connect.task.idexplicitly per the rule above, thenamefield MUST be ≤ 40 characters as a fallback — Nova's default slug derivation overflows Connect's 50-charDeliverUnit.slug/TaskType.slugcolumn on longer names and triggers an opaque HTTP 500 fromconnect_create_opportunity. Prefer the explicit-id rule above (cleaner; letsnamebe any length); this clause exists only because architects sometimes skip the id field.Reproducer + class-level preventer: see
pdd-to-learn-app/SKILL.md§ REQUIRED — Set id explicitly. The structural backstop isapp-releaseStep 6'sprojected_connect_state.oversized_slugs.deliver_units/oversized_slugs.task_unitsgate. Removal criteria: (a) drop the ≤40-char fallback when the upstream commcare-connect PR widensDeliverUnit.slugtomax_length=255(already=100since a prior fix) ANDTaskType.slugtomax_length=255(dimagi/commcare-connect#1195) andSLUG_LENGTH_LIMITinmcp/connect/backends/commcare.tsis bumped in lock-step. (b) KEEP the explicit-id rule even after the column widens — it's a cleanliness invariant matching Vellum's slug-vs-name separation, not just a workaround for the column width.REQUIRED —
entity_idis Connect's dedup / payment grain; make it a BUSINESS KEY built from form fields, NOT the system case id.entity_idis the value Connect uses to group deliveries onto one CompletedWork row per real-world entity (it does NOT stop a repeat being paid — ace#2512). It must therefore be a human-meaningful key derived from the PDD'sduplicate-detection-key(Evidence Model Layer A) — the natural identifiers that define one unique entity (e.g. beneficiary name + phone; outlet + brand + batch) — built from the form's own fields.Do NOT use the raw case id. Both forms of it are wrong:
/data/case/@case_idis hard-rejected by Novavalidate_app("references/data/casewhich doesn't exist in this form") — the case block is a build-time-emitted XForm node, not a blueprint field the validator's reference oracle can resolve.#case/case_idcompiles to a casedb-lookup XPath that breaks install on a CASE-CREATE form (XPathTypeMismatchExceptionfromFormDef.initAllTriggerables; "A part of your application is invalid" on device) — Connect populatescase_id_new_<type>_<n>notcase_id, and the case isn't incasedbyet.- Even if the validator accepted it, a per-registration case UUID gives zero cross-registration / cross-FLW dedup — two FLWs registering the same entity get two case_ids → two paid entities → the PDD's duplicate-detection is defeated. The case id is the wrong grain, independent of the validator quirk.
A single
/data/...field path asentity_idis install-safe (form fields resolve atxforms-ready) andvalidate_app-clean (it's a real form reference).No component of
entity_idmay be freetextwhere the option set is enumerable. The business key is only as good as the fields it concatenates: an editable free-text component means one typo mints a SECOND payable delivery for the same real-world event, and two distinct entities that share a name collapse into one. Every key component that names a place, facility, community, or roster member MUST be a lookup- or inline-backed select — see_app-component-library.md § structured-capture§ option sources for theget_lookup_tables+set_field_options_sourcerecipe. If a key component genuinely cannot be made a select this run, record it as a decision row (§ Decisions Log) naming theentity_idyou shipped, withreview_ask: recommended-confirmation+confirm_reason; do not ship it silently. (Reproducer:spark-facilitator/20260731-0656— free-textvillageforced a mid-run repoint of the key fromvillage + date_of_meetingtocommunity_id, which was ALSO free text, so only the name-collision mode closed. ace#1136.)For the 6-app deployed-practice audit that grounds this rule, see reference.md § entity_id business key.
Insert the matching paragraph(s) verbatim into the brief, in their own paragraph, prefixed
REQUIRED:.Case-CREATE deliver_units (registration forms — the typical atomic-visit Deliver app):
REQUIRED: For any
connect.deliver_unitblock on a CASE-CREATE form, setentity_idto a BUSINESS KEY built from the form's own fields — NOT the case id. Create a hidden calculate field (e.g.entity_key) whosecalculateis aconcat(...)of the natural-identifier fields that define a unique entity per the PDD's duplicate-detection key, then setentity_idto reference that field andentity_nameto the human-readable label field. Both are expression STRINGS (live schema 2026-09-28:type: ["string","boolean"], "#form/age reads an answer; #case/age reads the current record"):entity_id: "#form/entity_key",entity_name: "#form/beneficiary_name". A bare id (entity_key) is refused as an unknown reference. (#case/…has no case to read on a CASE-CREATE form — case reads belong on followup forms; see the case-UPDATE rule below.) The pre-2026-09-27{ parts: [...] }object shape is gone (voidcraft-labs/commcare-nova#693).It is raw XPath — nothing concatenates or quotes for you. A literal separator written between two references (
#form/a - #form/b) is the XPath minus operator; the key evaluates toNaN— the same constant for every worker — so Connect collapses the whole programme into ONE payable entity and everyone after the first goes unpaid. Nova accepts it with no error and the app builds clean; the only symptom is unpaid work (ace#1232, proven against a compiled CCZ). Any literal separator MUST be quoted inside aconcat(...):concat(#user/username, ' - ', #form/visit_start_time)— or, better, use the hidden-entity_key-field shape below, which builds theconcat(...)in the field's owncalculateand pointsentity_idat that one field.Mind the grain of every component. A date-only component (
#form/visit_date = today()) makes the key per worker per day — a day's submissions pay as ONE. That is only right when the PDD's paid unit IS one per worker per day. For a per-submission key, use a time-bearing timestamp writtenformat-date(now(), '%Y-%m-%d %H:%M:%S')— never a barenow(), which in a hidden field renders date-only and collapses to the same per-day key with no error anywhere (ace#2935;_app-component-library.md § section-timestamps).Example for a malaria RDT outlet visit whose dedup key is (outlet, brand, batch):
entity_key=concat(/data/outlet_name, ' - ', /data/rdt_brand, ' - ', /data/batch_number). Build the composite insideconcat(...), as shown — do NOT express it as alternating reference and bare" - "text. An earlier version of this section offered that shape as an equivalent, on a since-disproven claim that the expression joins its members for you; it does not. (The two statements contradicted each other between ace#1230 and ace#969; the live-derived rule above wins.) The hiddenentity_keycalculate field is preferred for a second reason too — persisting the key as form data is what lets case-UPDATE forms read the same grain back (see below). Do NOT use/data/case/@case_id(rejected byvalidate_app— the case block is not a blueprint field) or#case/case_id(compiles to a casedb lookup that breaks create-form install, and is the wrong dedup grain anyway: a per-registration UUID gives no cross-registration/cross-FLW dedup). Form fields resolve atxforms-ready, so aconcat(...)of them is install-time resolvable and validator-clean.Case-UPDATE / multi-form deliver_units (visit-series and multi-stage apps where the SAME entity is referenced across forms):
REQUIRED: When a
connect.deliver_unitspans multiple forms (a registration form plus later visit forms for the same entity), every form MUST emit the IDENTICALentity_idgrain. On a followup form, read the case with a#case/<property>reference in a hidden field'scalculate— that is the sanctioned case-read path, and it is how alongitudinal-visitskey reaches the entity's history (its current phase/step, its per-step counter). Build the key in a hiddenentity_keycalculate and pointentity_idat ONEfield-refto it:entity_key.calculate = concat(#case/case_id, '-', #case/<state property>, '-', #form/<capped counter>, '-', #form/<payability discriminator>)(Nova renders
#case/as#<case_type>/in its read-back; both mean the current record.)This path is verified against a released CCZ, not just the blueprint.
voidcraft-labs/commcare-nova#458(a followup form'scase-refto its OWN case type rejected by the canonical round trip) closed COMPLETED 2026-08-15. Onspark-facilitator/20260925-1536the Deliver followup form was briefed with#case/reads, the architect built them without a rejection, and the released build5c1eef4323224550b1c36a7da9521e71compiles them as LIVEcalculatebinds over the case database — not as preload-dependentsetvalues:<bind nodeset="/data/entity_key" calculate="concat( instance('casedb')/casedb/case[@case_id = instance('commcaresession')/session/data/case_id]/@case_id, '-', instance('casedb')/casedb/case[@case_id = …]/pilot_fcap_step, '-', /data/capped_index, '-', /data/meeting_kind)"/>(dimagi-internal/ace#2199.)
#case/case_idis correct HERE because the case already exists on a followup form; it stays wrong on a CASE-CREATE form (see the case-create rule above).Two case-READ shapes that are still NOT a read:
caseWriteis write-only — it does not preload. A hidden field carryingcaseWriteplus a literaldefault_valuelooks like a preload and is not one: it holds the literal and then writes that literal back, wiping the case property (ace#1224). Step 4i below halts on this shape. Read with#case/in acalculateinstead.- A visible case-bound field DOES preload (ace#2006). The
released Deliver CCZ of
spark-facilitator/20260828-0703contains<setvalue … value="instance('casedb')/…" event="xforms-ready"/>for 35 of its 36 answerable questions. That is a pre-fill of the previous visit's ANSWER, not a read you can build a key on — see the derived-state rule below. Enforced:lib/casedb-preload-audit.ts, run byapp-release-qa§ Step 2.9.
Do NOT fall back to
concat(username, <date>)for a longitudinal design. That is theatomic-visitgrain: it drops the entity's history out of the key, so a per-entity cap ("3 paid per step") cannot be expressed and the design silently becomes "any visit counts" (ace#1462). It remains a legitimate key only for anatomic-visitfollowup where the worker IS the entity referent and no history is priced.When the FLW does NOT map 1:1 to the entity,
#case/case_idalready distinguishes the entities the worker serves — no re-asked select is needed. If a key component genuinely cannot come from the case or the form, record it as a decision row (§ Decisions Log) naming theentity_idyou shipped.Caveat that still applies: every literal separator and every per-form suffix goes inside
concat(...), never as a bare{ kind: "text", text: " - <form_name>" }part between two references.Case-write DERIVED state only, never the raw answers (dimagi-internal/ace#2006). Nova emits a casedb read-back
<setvalue event="xforms-ready">for every property the form case-writes — measured 1:1 on thespark-facilitator/20260828-0703Deliver CCZ: 42 properties written, the same 42 read, zero asymmetry. There is no write-only case binding from the authoring surface. So case-writing a visible question is the same act as preloading it on the next visit.That build case-wrote all 35 answerable questions plus 7 derived summary fields, so a CBF returning to a community met the whole previous meeting pre-filled — including
meeting_conductedandmeeting_type, the two fields Connect reads to decide payment — and could re-file it by tapping Next and taking one new photo.The split is clean, so take it. Only the DERIVED fields are load-bearing downstream:
- the case list renders
last_meeting_dateandpilot_fcap_step - the payment key reads
meetings_on_current_step - rollups read
Total_Attendance/Total_Participation/Percentage_Participation/cumulative_savings
All seven are hidden calculated nodes, so preloading them is harmless — nothing asks the worker to confirm them. The 35 raw answers were persisted for nothing: M&E reads form submissions, not the case, and the case detail never displayed them (the same verdict separately complained that neither payability field was reviewable after submit — they were on the case, just never shown).
So: put
case_property_onon the derived summary nodes and NOT on the questions. If a raw answer genuinely must live on the case, say why in a decision row (§ Decisions Log) and declare it as an allowed exception toapp-release-qa'scasedb_preloadsgate, so the trade is visible rather than accidental.To make an answer REVIEWABLE after submit, mirror it into a hidden field — do not case-write the question (dimagi-internal/ace#2006). Dropping
case_property_onfrom the questions costs the case detail its record of what the worker filed, and a worker who mis-tapped a payability field then has no way to notice before payday. That is a real requirement, and it does NOT require re-binding the question.Mirror it instead: a hidden field whose
calculatereads the question, carrying thecase_property_on. The mirror is case-written (so the case list and detail can render it) and it preloads (so does every hidden field) — and the preload is harmless precisely because nothing asks the worker to confirm a hidden field.This is not a new pattern — the app already ships one. In the
spark-facilitator/20260828-0703Deliver CCZ:<bind nodeset="/data/meeting_summary/last_meeting_date" calculate="/data/meeting_date/date_of_meeting"/>hidden, mirroring the
date_of_meetingquestion, case-written tovillage.last_meeting_date, and rendered as the case list's "Last meeting" column.Total_Attendanceandmeetings_on_current_stepare the same shape. Reuse it for any answer the design wants visible after submit — a payability field is the obvious candidate, since the worker cannot otherwise catch their own mis-tap.Name mirrors for what they are (
last_meeting_type, notmeeting_type), so the case property that holds "what was last recorded" is never confused with the question that asks "what happened this time".Payability-scoped keys (any form where SOME submissions are not paid work):
REQUIRED: When the PDD marks a SUBSET of submissions to this form non-payable — a did-not-happen branch, a screening-only visit, an ineligible-household record, a committee meeting on a form that also records community meetings — the payability discriminator MUST be a component of
entity_id.Derive the key from the PDD's paid-unit definition, not only from its
duplicate-detection-keyidentity fields. The key must be unique per payable event, so that a non-payable submission occupies a DIFFERENT key space and cannot consume the payable one. An identity-only key on such a form means the FLW's non-payable submission mints the key first, the real payable visit dedups against it, and the worker is structurally blocked from being paid for work they actually did — while the form's own closing text often tells them to record both.Put the discriminator inside the
concat(...), never as an extraparts[]entry (caveat (ii) above). A dedup key of (community, date) on a form that records both committee and community meetings becomesconcat(/data/community_code, '-', /data/meeting_date, '-', /data/meeting_type).The discriminator is now a key component, so the "no free
textwhere the option set is enumerable" rule above applies to it: it MUST be a select. On the CASE-UPDATE path the discriminator must be answered on THIS form — an ordinaryfield-refto the form's own select. Do NOT reach for the entity's stored copy: a followup form cannot read its own case (see the case-UPDATE rule above), so a discriminator that "comes off the case" ships as a constant.If the PDD's non-payable set cannot be expressed as a form field at all, do NOT ship the identity-only key silently — record as a decision row (§ Decisions Log, with
review_ask: recommended-confirmation+confirm_reason) that non-payable submissions share the payable key space, and name the field that would fix it.Scope note: this closes the slot-consumption mode. A non-payable record still mints a CompletedWork on its own key until Layer A verification rejects it — the
deliver_unitmarker itself carries no relevance condition, which is upstream of ACE. (ace#969.)For the upstream-validator note + the history of why the case id was abandoned, see reference.md § entity_id business key.
REQUIRED — Architect must verify-then-retry every
add_fieldscall. Nova'sadd_fieldshas a partial-persistence quirk: a single call with N items often persists only the first few; a skipped verification ships forms that look complete in the build summary but render with missing questions in the actual app. Insert this paragraph verbatim into the brief, in its own paragraph, prefixedREQUIRED::REQUIRED: Nova's
add_fieldshas a partial-persistence quirk. After EVERYadd_fieldscall, count the entries in the result'sfieldsarray (Nova returns one{id, uuid}per field it persisted) against the number you sent. If fewer came back, or the call errored, callget_formon that form, re-issueadd_fieldsfor the missing fields, and re-verify. Repeat until counts match. For forms with >10 fields plan on 2–5add_fieldsinvocations. Do not move on to the next form before counts match — silent partial persistence on form N becomes invisible once you start working on form N+1.The count comes from the
add_fieldsresult, not aget_formread: in a fresh single-agent kaduna-graduation build (ace#3078 arm A, 2026-10-17) all 25 successful calls returned exactly as manyfieldsentries as were sent, while the old "callget_formafter every call" wording contradicted the app-test economy rule below and the architect dropped the check entirely (0get_formcalls, ace#3124). Step 4a's fullget_formread-back after the build is still the backstop.For the full failure analysis, see reference.md § add_fields partial persistence.
Also insert this paragraph verbatim into the brief (dimagi-internal/ace#1181):
REQUIRED: a large
add_fieldscall can fail withInputValidationError: could not be parsed as JSON. This is a HARNESS-side failure, not a Nova one, and not a size limit —voidcraft-labs/commcare-nova#459was closed NOT_PLANNED on 2026-08-16 after Nova's request logs showed zero malformed bodies and payloads up to 23.4 KB returning 200. The error is Claude Code's own client-side error, raised when the model's streamed tool-call arguments failJSON.parselocally, before any request is made; the two known causes are a generation cut mid-call and a deferred tool's schema dropping out of context after compaction. The "cut mid-string" appearance is an artifact of the harness echoing only the first ~200 chars. So: do NOT pre-batch defensively, and do NOT debug the payload's quoting. Send the natural batch. IF you see that error, shrink the batch and retry — as RECOVERY, not as a planned cadence. Nova commits each batch atomically (all-or-nothing), so a retry after this error cannot double-write. The verify-then-retry rule above still applies to every batch.REQUIRED — Deployability (fitness) components. A faithful transcription of the PDD's field list is NOT a deployable instrument.
pdd-to-deliver-app-eval's fitness axis (55% weight) hard-fails the build on each gap below; the build must emit the applicable components so the instrument is field-reliable, not just structurally complete.The canonical, parameterized text for each component lives in
skills/_app-component-library.md— the single source of truth, paired 1:1 with the eval dimension that hard-fails a build omitting it. For each Deliver component whose Trigger fires for this app, open the library and insert that component's Brief paragraph into the brief verbatim, in its own paragraph, prefixedREQUIRED:, substituting any<PARAM>placeholders from the PDD. Emit-checklist (see the library for full text + triggers):gps-accuracy-capture— PDD Evidence Model states a GPS radius.init-safe-calculates— always emit alongside any capture-later calculate (always pairs withgps-accuracy-capture).data-quality-constraints— always, for any data-capture form.case-write-back— any case-UPDATE / follow-up form that captures new observations.structured-capture— any answer with an enumerable option set, any field the PDD spellsselect/lookup/ "choose from" / "from the registered X", and any field that feeds a Connectentity_id. A PDD-declared select that ships as freetextis a DEFECT, not a degradation: the architect must callget_lookup_tablesand bind the option set withset_field_options_source(lookup- or inline-backed) rather than falling through tokind: text, and any degradation that does happen must be recorded as a decision row (§ Decisions Log). Verified ACE-direct by Step 4f (ace#1136). Table values can be read ONLY via choice-list binds and values saved at submit (case writes) — never inside a calculation or label (Nova refuseslookup()there); a build-time setting used mid-form becomes a constant with a hint and a decision row (nova-integration.md § Where a table value can be READ, ace#2974).section-timestamps— PDD success metrics reference visit-time / a cost model.embedded-bc-script— PDD specifies a verbatim behavior-change segment.app-language-layer— PDD names a working language other than English (Deliver variant). The brief tells the architect to build the app in English ONLY and to call no language atom. ACE owns the language layer, ACE-direct, in Step 4l below — after every English-editing step has finished. That split is the fix for ace#1556: the architect's operating prompt forbids it saving self-generated target text, so a brief that asked it to author translations produced a silent no-op (207 units copied, 0 translated, both targets, onspark-facilitator/20260820-0817). It also makes translate-LAST structural — the architect's turn is over before the language exists. Never stack languages inline. (Standing decision 2026-08-17, PR #1463, superseding ace#968/#1391; ownership split 2026-08-23, ace#1556 — see_app-component-library.md § app-language-layerfor the proven contract and the ACE-direct recipe.) Graded bylanguage_conformance.deliver-app-naming— always. App name must contain "Deliver app".nova-authoring-rules— always (Learn + Deliver). One paragraph of the nine refusals and lookups that recur across Nova builds (ace#3300); insert it verbatim, do not restate its clauses here.no-starter-module— always (Learn + Deliver). Apps created before 2026-09-27 were seeded with a placeholder module (top-level menu "Survey" → form "Survey" → one text fieldquestion_1labelled "Question 1"); sincevoidcraft-labs/commcare-nova#693a new app starts empty (begin_work({new_app}), no seed), so the component is now a guard for reused/older apps and a regression tripwire. Emit the component so the brief tells the architect to DELETE it, and to report whether it was present. Removal is currently architect discretion, and discretion is what varies run to run: onbednet-check-2-visit/20260828-0629the Deliver app shipped with the seed while the Learn app, briefed from the same template in the same phase, removed it unprompted. Enforced at release byapp-release-qa § Step 4(ace#1787).live-photo-capture— any image/photo capture question, but do NOT put it in the Nova brief. Nova's authoring surface has no appearance control: animagefield's slots areid/kind/label/hint/help/required/relevant/validate/calculate/default_value/caseWrite/optionsSource, and none of them isappearance(caseWrite.modesaves a link to the attachment — a different thing). The component is real and enforced, just not here: it is applied POST-BUILD byapp-hq-settings§ Step 3, which fetches each Deliver form's draft XForm (commcare_get_form_source), injectsappearance="acquire"onto every image<upload>, and patches it back (commcare_patch_xform) beforeapp-release;app-release-qathen BLOCKER-gates it off the released CCZ form XML ascamera-only-appearance-missing. Briefing it only makes the architect search for an atom that does not exist and report a spurious "unmet requirement" into the run's review record — the one place meant to carry REAL deviations (dimagi-internal/ace#1640; same defect as ace#1632, which hitgrid-menu-displayon bednet-check-2-visit/20260825-1310).no-section-module-language— always. No user-facing "section" or "module" wording anywhere — including lookup-table rows, which render as choices or displayed text when a field is bound to the table (ace#2976).connect-supported-capabilities-only— always. Use only capabilities that work WITHOUT an HQ feature flag;commcare_connectis the sole exception. Most concretely: do NOT add case-search inputs — a case LIST needs no flag, while a case SEARCH requiressearch_claimand fuzzy/advanced matching additionally requirescase_search_advanced, and BOTH areTAG_FROZENin HQ ("do not add new projects to this list"), so the build cannot ship as designed rather than merely waiting on provisioning (ace#1195).grid-menu-display— always (Learn + Deliver), but do NOT put it in the Nova brief. Nova's authoring surface exposes no menu-display-format control at all:update_appsets only the display name,create_module/update_modulecarry no display-format field,set_menu_mediasets icons and audio labels (Menu ICONS are a different thing and ACE does apply them —app-media-coverageat Phase 3 Step 1.7, via this sameset_menu_media, using Nova's built-in topic/action icon slugs.), andset_case_list_tilelays out a case LIST, which is a different thing entirely. The component is real and enforced, just not here: it is applied POST-BUILD byapp-hq-settings(Phase 3 Step 2.65) viacommcare_set_menu_display+commcare_set_app_menu_display, and BLOCKER-gated byapp-release-qaoff the released app's raw doc. Briefing it made every architect build report a spurious "unmet requirement" into the run's review record — the one place meant to carry REAL deviations — and invited the architect to reach for an unrelated atom to satisfy the paragraph (dimagi-internal/ace#1632; live on bednet-check-2-visit/20260825-1310, where Step 2.65 then applied all three fields HQ-side on the first attempt).observable-before-derived— any visit/encounter form with an outcome / disposition / status field. Ask observations in real-world order; COMPUTE the outcome. A user-facing outcome question placed before its own inputs is a defect (ace#979).constraint-locality— always, for any form with constraints. A constraint must be fixable on the screen where it fires; it may reference only.or same-repeat siblings (ace#980). Carve-out (ace#1560): a MINIMUM-rows gate must be bound OUTSIDE the repeat it counts. A constraint on a node inside a repeat is evaluated per repeat INSTANCE, so at zero repetitions it never evaluates — the one case the gate exists to catch.count(/data/roster[…]) >= 1bound on a question INSIDE/data/rosteris a same-repeat sibling reference, satisfies the sentence above exactly, and is dead. Put the minimum on a gate question immediately AFTER the repeat. A cap (count(…) <= 10) is the opposite case and correctly stays inside. Verified at Step 2.8 byapp-release-qaover the released binds.repeat-count-source— any repeat with a fixed row count (Novacount_bound). The count must be known when the form OPENS (case property, session, lookup, constant) — never a question in the same form: Nova snapshots a root count once onxforms-ready, so a same-form "how many?" answer is still blank and the repeat renders ZERO rows (ace#2517 — all 5 onspark-facilitator/20260926-1413). "Worker states N, then fills N rows" →user_controlledrepeat with the number derived ascount(<repeat>). Verified at Step 2.8 byapp-release-qa(dead-repeat-count,lib/repeat-count-audit.ts).case-choice-attendance— a form opened on a group-like case that records which of its existing members took part (attendance, a distribution, a screening). ONEmulti_selectwhose choices are the group's own open member cases (optionsSource: {kind: 'cases'}), plus an unlabelledquery_boundroster that updates each member the FINAL checklist ticks. Not one yes/no screen per member, which was the pre-2026-10-08 fallback (voidcraft-labs/commcare-nova#728), and never an update repeat driven by the checklist answer. Exact shapes:lib/case-choice-attendance.ts. Ifscripts/probe-nova-case-choices.tsexits non-zero, brief the yes/no fallback and record why under## Build notes.screen-grouping— always, for any form that puts more than one question in agroup. A group is a CommCare field-list, so its children share ONE scrollable screen. Multiple questions per screen is GOOD design — this is not a one-question-per-screen rule — but group by a shared rule (one recall period, one answer source), split when that rule changes, never nest arepeatinside a group, and keep a long read-aloud passage on the screen carrying the answer it governs. Verified ACE-direct by Step 4g.consent-script-floor— always-on for capture of identifiable people, on exactly the same condition aslive-photo-capture: any image / photo / audio / video capture question of people fires this component ON ITS OWN. No consent field, no read-aloud passage, and no mention of consent anywhere in the PDD is required — a PDD that is silent about consent fires it just as hard as one that describes consent being sought. A photo question that needsacquireis categorically also a photo question that needs the floor; evaluate the two off the same detection and emit both. (It also fires on the other clauses in the component: a read-aloud announcement to an assembled group, a verbal consent taught in the Learn app, or a photo-consent line inside a behavior-change script.) Do not reason "the PDD doesn't mention consent, so the trigger doesn't fire" — that is exactly the miss that shipped 0-of-6 elements on an app photographing 8+ identifiable people into an AI verification layer plus a human audit sample (spark-facilitator/20260812-1635, ace#1223; the prior run of the same opp shipped 4/6, so this has now missed twice). All six floor elements — and checkconfidential, where the data goes (name any AI verification layer and human audit sample), and that participation does not guarantee selection by name, because those three are the ones builds actually omit (ace#983, ace#1137). This is a build-time authoring rule;pdd-to-deliver-app-eval § consent_flooris the backstop, not the place to discover it. When this fires alongsideembedded-bc-scripton the same read-aloud passage, BOTH are emitted and this one governs the content.threshold-coherence-flag— PDD fixes ≥2 numbers constraining one physical quantity. Check the pairs and record the check in the summary's## Build notes; a conflict the build had to resolve is also a decision row (§ Decisions Log) (ace#984). Carryprogram_parameters.duplicate_gps_ruleinto the brief WHOLE — radius, accuracy condition and fallback — and classify the dedup pair withlib/gps-dedup-coherence.ts: an accuracy-conditioned radius is coherent, and no[FIXED]or author-attributed threshold is ever moved to resolve a conflict (ace#2373).fixed-instrument-transcription— the app digitises a[FIXED]published instrument whose source file the run'sinputs-manifest.yamlnames. Transcribe every constant exactly; verified ACE-direct by Step 4k (ace#1527).payability-scoped-key— the PDD marks a SUBSET of submissions to a form non-payable (a did-not-happen branch, a screening-only visit, an ineligible-entity record, a committee meeting on a form that also records community meetings). Mandates the payability discriminator inentity_idAND inentity_name. Both halves matter and only one of them is stated twice: the skill's own "Payability-scoped keys" paragraph above coversentity_id, so a brief composed from that paragraph alone ships a correct key with an ambiguous DISPLAY name — payable and non-payable entities rendering byte-identically on the worker's completed-work and invoice views, one approved and one rejected with nothing to tell them apart. That is ace#1958, whose fix landed in the component and was then unreachable because this checklist did not name it, so the defect recurred on the next build of the same opp (ace#2279).entity-state-taxonomy— always forarchetype: longitudinal-visits, and for any archetype whose PDD declares a phase / stage / status vocabulary the worker sees. Carry the PDD'sprogram_parameters.entity_state_taxonomyinto the brief verbatim — every state value with its label and its member activity/step range — and where that row names a source document, read THAT document out of the run's frozeninputs/(resolved viainputs-manifest.yaml) rather than enumerating from the PDD's summary table. Parse the row withparseStateTaxonomyfromlib/entity-state-taxonomy.tsBEFORE composing the brief:declared: false, or a non-emptyproblems, is a HALT with a Phase-1 finding, never a licence to invent a vocabulary. Verified ACE-direct by Step 4l (ace#1564).stateless: true(rowstateless [source: …]) is a declaration, not a gap: brief NO state field (ace#2774).
Do NOT inline-paraphrase these — reference the library so the build and
pdd-to-deliver-app-evalstay symmetric. Skip a component whose trigger doesn't fire.Followed-entity id on every paid form (ace#2889). When the PDD's
program_parameters.followed_entity_case_typenames a case type (parseFollowedEntityCaseTypefromlib/followed-entity-id.ts), every paid form — every form carrying a Connectdeliver_unit— that serves that entity must record its id in a hidden field at the form root namedfollowedEntityIdField(caseType)(=<case_type>_case_id, e.g.participant_case_id), whose value is DERIVED from the case model, never worker-entered or hard-coded. Put this in the brief as aREQUIRED:line naming the field and, per paid form, its source:- a form that runs ON the followed case → the session case id
(
#case/@case_id, i.e.instance('commcaresession')/session/data/case_id); - a child-case form (a crop cycle, session or pregnancy under the
followed entity) → the child case's PARENT id, read from casedb
(
instance('casedb')/casedb/case[@case_id = instance('commcaresession')/session/data/case_id]/index/parent); - a form that CREATES the followed entity → the new case block's
@case_id. A paid form that genuinely serves no followed entity is recorded in the summary's## Build notesunderfollowed_entity_id.exemptwith its reason (the PDD's Deliver spec must say so) —app-release-qa § Step 4.9reads that map and fails every other paid form that lacks the field. Onsmallholder-seasonal/20261012-1549the paid Harvest visit updated the crop cycle and named nothing that identified the woman, so no reporting layer could attach the visit to her.followed_entity_case_type: none (<reason>)or an absent row (non-longitudinal, non-multi-stage) → skip.
- a form that runs ON the followed case → the session case id
(
App-test economy (always). Insert this paragraph verbatim into the brief, in its own paragraph, prefixed
REQUIRED::REQUIRED: Keep app-test output small — every app-test result stays in your context for the rest of the build. Batch each journey into as few
continue_app_testcalls as possible: up to 8actionsper call, each with anexpect(screen / form / submitted) so a journey that behaves costs one call and a wrong turn stops at the first miss. Do not re-observe a screen you have already seen; to re-read evidence, useread_app_testwith a smalllimit(andinspectonly the step you need), not a fresh journey. Test each distinct journey once per saved revision, then re-test only the journeys a later edit touched. Do not callget_formon a form you just authored to confirm it — theadd_fieldsand save results already say what was saved;get_formis for a count that came back short.Why: Nova has no compact/delta result mode (
continue_app_testandget_formtake no size control — voidcraft-labs/nova-plugin#71), and eachcontinue_app_testresult measured 35–108 KB, ~30 per build. Onkaduna-graduation/20261015-1411the Deliver architect's context reached ~944k and hit the 250-turn cap before reporting. Batching cuts the call count, which is the half ACE can control. When #71 ships a compact mode, add it to this paragraph (skills/nova-capability-adoption).
Invoke
/nova:autobuild "<brief>". Capture from the response:app_id— durable Nova handle, written to the summary asnova_app_id. Since nova plugin v2 the architect builds in private work and SAVES checkpoints; the app id is the one itssave_workreturned. If the report names awork_idwith pending changes (the build stopped before a final save), the build is INCOMPLETE — the saved app lacks that work, and upload/compile never see it. Treat it as an unfinished build (re-dispatch or/nova:editto finish), never record it as delivered.- Build summary
- Any warnings
Every ACE-direct Nova edit in Steps 4a–4l runs in PRIVATE WORK (
voidcraft-labs/commcare-nova#693, 2026-09-27). Contract and observed behaviour:playbook/integrations/nova-integration.md § The private-work authoring contract. The rules this skill depends on:- Open work after
/nova:autobuildhas returned — never while an architect dispatch is in flight (its saves would make ACE's candidatestale, and a stale candidate can only be discarded):begin_work({request_id, app_id})→ keep thework_id. - Every mutation below (
add_case_list_columns,configure_connect,update_form,set_field_options_source,edit_field,remove_field,update_case_list_column,add_fields,update_translations, …) takeswork_id+ a freshrequest_idand noapp_id. It answerssaved: false: the saved app is unchanged untilsave_work({work_id, request_id, expected_revision}), withexpected_revision= therevisionfrom the latest mutation (orget_work). - Require
saved: trueon the save answer. A stale or refused save comes back as DATA (saved: false,kind: "stale-base"…), not as an error. Onstale,discard_workand redo the edit from current saved state. Scripted edits uselib/nova-work.ts(NovaWork.begin→stage→save), whosesave()throws unless Nova saved. - Save before every read-back that proves an edit, and read back with
app_id(saved state). Awork_idread shows the candidate even when the save failed. Wherever a step below says "re-fetch" / "re-runget_app" after an edit, the save comes first. - A save must leave a buildable app — never empty a form.
- Before Step 5,
get_workmust readpending_changes: 0. An unsaved edit raises no error anywhere; it is simply absent from whatapp-deployuploads.
4a. Post-build field-count verification — runnable recipe (skill-side safety net).
The architect-brief language above puts retry-then-verify
discipline on the architect agent. This step is the skill-side
safety net for cases where the architect finished short — including
the case where the architect ran out of budget mid-form and
silently persisted N-of-M expected fields with no error. (FGD
Deliver apps are the highest-risk surface: the per-section summary
form for focus-group archetypes is ~45-70 fields with 7 section
groups — exactly the kind of long form where partial persistence is
most likely.)
**Always run this recipe before writing the success summary.** Not
a prose contract — a numbered tool-call sequence the L0 LLM
executes verbatim:
1. **Build the expected field-count table** from the brief that was
sent to `/nova:autobuild`. For each `(module, form)` pair the
brief named, extract the field list. Persist as an in-memory
map `expected[module][form] -> [field_id, ...]`. The brief is
the source of truth — not the PDD prose, not the architect's
return string.
**Read the built app ONCE, from the snapshot (ace#3132).** This skill runs
BEFORE Phase 3's Step 1.4 lint, so there is usually no snapshot yet. Get
one with a single server-side call (none of the blueprint enters your
context), or reuse the caller's:
```bash
ACE_ROOT="${CLAUDE_PLUGIN_ROOT:-$(python3 -c "import json,os; d=json.load(open(os.path.expanduser('~/.claude/plugins/installed_plugins.json'))); print(d['plugins']['ace@ace'][0]['installPath'])")}"
TSX="node $ACE_ROOT/node_modules/tsx/dist/cli.mjs"
# caller passed nova_snapshot: it must be FRESH (exit 0), else take your own
$TSX "$ACE_ROOT/scripts/nova-app-snapshot.ts" --snapshot <nova_snapshot> --app <nova_app_id> --check
# no snapshot, or not FRESH:
$TSX "$ACE_ROOT/scripts/nova-app-lint.ts" --app <nova_app_id> --type deliver \
--snapshot-out "<scratch>/nova-app-snapshot_<app>.json"
```
Ignore the lint's verdict here (Phase 3 Step 1.4 owns the gate) and use
only the file it wrote. `FRESH`, or the file you just wrote → take the
uuid map from `nova-app-snapshot.ts --snapshot <file> --outline` (it also
prints the app's `connect` type) and each form from `--form <formUuid>`;
**do not call `get_app` or `get_form`** for steps 2–3 below, nor for
4e step 1–2. Any other result (exit 3/4, no file written) → read live as
written below, never "close enough". **After ANY edit of this app, take a
new snapshot with the same lint command** before re-reading — the old
file predates the edit. Reads that verify this skill's OWN write stay live
(`LIVE_READS`, `lib/nova-app-snapshot.ts`): the `get_form` inside each
`/nova:edit` prompt, and the post-save `get_app` re-fetch in 4e step 3 (it stays live: it proves the save landed and that `configure_connect` cleared no form).
2. **Read the built app** via one `get_app({app_id})` call. Compare
module + form names against the expected map. **Halt** if any
expected `(module, form)` is missing — that's a structural gap
the field-count recipe can't fix.
**This same call is the addressing map.** Nova is uuid-addressed
(2026-07-31 migration — see
`playbook/integrations/nova-integration.md § The 2026-07-31
uuid-addressing migration`); there are no `moduleIndex` /
`formIndex` parameters on any tool. `get_app`'s blueprint prints
`[uuid <rfc-uuid>]` on every module, form, and field:
```
- Module "CBF Registration" [uuid b60055c1-…] (case_type: cbf)
- Form "CBF Registration" [uuid c3deb000-…] (registration, 12 fields)
- community_id [uuid c3df54f7-…] (text)
```
Build `uuids[module][form] -> {moduleUuid, formUuid}` (and the
per-field uuids) from this ONE response and carry it through the
rest of the recipe — Steps 4b–4h all address off this map. One
lookup beats N. If you only hold a semantic name later,
`search_blueprint({query, app_id})` resolves it — but prefer the
map you already have.
3. **For every form in the expected map**, call
`get_form({app_id, moduleUuid, formUuid})` using the uuids from
step 2 (one call per form, batchable in parallel across forms).
Collect:
- `persisted_ids`: the set of `field.id` values present in the
response. Hidden / label / group / repeat fields all count.
- `persisted_count`: `len(persisted_ids)`.
4. **Compute the diff per form.** `missing = expected[m][f] -
persisted_ids`. **Also** compute `referenced_missing`: any field
referenced in another field's `calculate` / `relevant` /
Connect-marker `user_score` / `entity_id` that isn't in
`persisted_ids`. (`validate_app` flags this class as
"X references Y which doesn't exist in this form" — same
shortfall, different detection path. Catching it here means we
don't ship to `validate_app` with a known gap.)
5. **If `missing ∪ referenced_missing` is empty across every form,
proceed to step 4b (one-form-per-module check).** No edit needed.
6. **If non-empty for any form**, dispatch ONE `/nova:edit` call
per affected form. Prompt template:
```
/nova:edit <app_id> "Add the following missing fields to form
<module-name> / <form-name>: <comma-separated field ids and
their kind/calculate spec from the brief>. After each add_fields
call, get_form and verify persistence. Do not return until every
requested field is present."
```
Re-run step 3 + step 4 after the edit returns, from a NEW snapshot
(same lint command, `--snapshot-out`) — the old one predates the edit.
The `get_form` inside the `/nova:edit` prompt is the editor's own
read-back of its write and stays live.
7. **Bounded loop, max 3 iterations.** If any form is still short
after the third iteration, halt with a structured failure
listing `<form-name>: <missing ids>` per offender, and do NOT
write the success summary. The operator decides whether to
/nova:edit manually, re-dispatch autobuild, or escalate.
> For why this runs even though `validate_app` catches some shortfalls downstream, see reference.md § Step 4a safety net.
Same shape as `app-connect-coverage` — verify+fix in a bounded
loop, post-Nova.
4b. Structural pre-flight: one form per module (deliver_unit slug uniqueness). After field counts match, verify the module/form layout is what Connect's sync will consume cleanly. Cheap check; fires before any HQ upload.
1. Call `get_app({app_id})` and enumerate
modules + forms.
2. Count forms tagged with `connect.deliver_unit` (or `connect.task`)
across the app. Call this `intended_paid_form_count`.
3. Count modules whose form set contains ≥ 1 paid form. Call this
`paid_module_count`.
4. **Assert** `paid_module_count === intended_paid_form_count`.
If not, every multi-paid-form module will collapse to one
deliver_unit at Connect's sync (Nova reuses the module slug as
`<learn:deliver id>` per form; Connect dedups by slug). The
collapsed-but-non-first forms reach production silently unpaid.
On mismatch, dispatch:
```
/nova:edit <app_id> "Split module <X> so that each of its paid
forms (<form-Y>, <form-Z>) lives in its own module. Connect dedups
deliver_units by slug and Nova currently emits the module slug as
the <learn:deliver id> for every form in that module, so multi-form
modules collapse to one deliver_unit at sync. After the edit, every
form with connect.deliver_unit set must be the only form in its
module."
```
Re-fetch and re-assert. **Bounded loop, max 3 iterations.** If
still mismatched after 3, surface a clear failure listing each
offending module + the forms that need separating, and do not
write the success summary.
See `feedback_connect_deliver_unit_per_module` memory for the
upstream Nova bug that necessitates this.
4c. Case write-back verification (follow-up forms must persist
observations). The structural preventer for case-update forms that
capture observations but write zero case properties (losing what they
observed). pdd-to-deliver-app-eval § case_persistence hard-gates
this at ≤2; this step catches it at build time. Cheap; runs on the
already-fetched blueprint. Same bounded-loop shape as 4a/4b.
1. From `get_app({app_id})`, identify each **case-UPDATE** form (a
form Nova tagged as updating an existing case type rather than
creating one — it carries `case_property_on` writes against a case
type some other form creates).
2. For each case-update form, list its **user-facing observation
fields** (non-hidden, non-label questions the FLW answers).
3. **Assert** that the form binds **≥1** of those observation fields
to a case property via `case_property_on`. A case-update form
that captures new observations and writes zero case properties
fails this assertion.
4. On failure, dispatch:
```
/nova:edit <app_id> "Form <module>/<form> is a case-update form
that captures observations (<list>) but writes no case
properties. Bind the observation fields that represent durable
state (<list>) to case properties on case type <type> via
case_property_on, so the follow-up visit persists what it
observed. After the edit, get_form and verify each binding."
```
Re-fetch and re-assert. **Bounded loop, max 3 iterations.** If
still failing after 3, surface a clear failure naming the form +
its unpersisted observations, and do not write the success
summary. (Single-form atomic-visit apps with no case-update form
have nothing to check — skip cleanly.)
4d. Case-list column heal — runs ACE-DIRECT (deterministic preventer
for the autonomous-architect allowlist gap). A case-CREATE module
whose caseListConfig.columns is empty (case_list_config: null)
fails Nova's validate_app with a single error against that module.
The autonomous architect dispatched in Step 4 (/nova:autobuild →
Agent(nova:nova-architect-autonomous)) cannot clear this error
on its own: the case-list-config tool family
(add_case_list_columns, set_case_list_filter,
update_case_list_column, remove_case_list_column,
reorder_case_list_columns, set_case_search_display,
set_case_search_advanced, add_search_inputs, …) is not present
in the autonomous architect's tool allowlist. It will try
generate_schema, a fresh create_module, and promoting
case_name to a visible field — none of which auto-seeds the
default column — and report it cannot reach validate-clean.
These case-list-config atoms ARE available to the ACE session
Code session that executes this skill, so the heal is a
deterministic L0 operation: run it here, after the autonomous build
returns, rather than asking the architect to do something its tools
can't.
> For why this step lives ACE-direct (not the architect brief) + the upstream allowlist gap (jjackson/ace#632), see reference.md § Step 4d ACE-direct heal.
Cheap; runs on the already-fetched blueprint. Same bounded-loop
shape as 4a/4b/4c.
Note: Nova's `validate_app` is architect-side only — it is NOT
exposed at the L0/user tool surface (nova@nova-marketplace 1.1.0;
jjackson/ace#821), so this recipe inspects the blueprint directly
instead of asking the validator.
1. From the already-fetched `get_app({app_id})` blueprint, identify
every **case-CREATE** module, and for each one call
`get_module({app_id, moduleUuid})` — the `moduleUuid` comes from
the Step 4a step-2 addressing map — to confirm its case type and
whether its case list is empty (`case_list_config: null` /
missing `caseListConfig.columns`). If every case-create module
already carries a non-empty case list — or the app has no
case-create modules — skip the rest of this step; there is
nothing to heal.
2. For each offending module, call
`add_case_list_columns({work_id, request_id, moduleUuid, ...})` to add ONE
plain column over the case name field (the module's `case_name` /
case-name field). A single default column is sufficient to clear
the architect-side validate error; this is the same one-column
heal an operator applies by hand.
3. `save_work` (require `saved: true`), then re-fetch via
`get_module({app_id, moduleUuid})` and re-assert
the case list is now non-empty. **Bounded loop, max 3
iterations** over steps 1–3. If any case-create module still has
an empty `caseListConfig.columns` after the third iteration,
surface a clear failure naming each module still missing its
case-list column, and do NOT write the success summary.
(Apps with no case-CREATE module, or whose case-create modules
already carry a non-empty case list, skip cleanly at step 1.)
**CAUTION — this heal can author a case list nobody can reach
(dimagi-internal/ace#977).** On a REGISTRATION-ONLY module the entry's
only session datum is `function="uuid()"`, so no entity-selection screen
is ever pushed and the column set added here is dead configuration —
satisfying `validate_app` while producing a list no worker can ever see.
`app-release-qa` Step 4 blocks that shape (`case-list-unreachable`) —
but only when the case type is unreachable **app-wide** (ace#1281). If a
sibling module opens the same case type behind a real entity datum, the
inert detail on the registration module is `[INFO]`, not a blocker, because
Nova **refuses** to remove the last visible Results column from a module
that declares a case type ("Every case list needs at least one visible
Results field… Nothing was changed") — the shape is unavoidable and must
not be healed here.
So when a case-CREATE module has no case list, ask FIRST whether the
module is genuinely registration-only **and whether any other module opens
that case type**. If nothing does, the right resolution is to add the
followup form the PDD implies — **not** to add a column to clear the
validator. This is Jon's blueprint invariant from 2026-07-28: a module that
declares a case type + case list should have some form that actually opens
a case.
4e. Deliver-marker compile pre-check (catch a missing app-level Connect
type before deploy) — runs ACE-DIRECT. Mirror of
pdd-to-learn-app § 4b. The autonomous architect
(Agent(nova:nova-architect-autonomous)) can land a Deliver app with
no app-level Connect mode even though every form already carries
its connect.deliver_unit block. The per-form [Connect enabled]
flag is a FALSE POSITIVE for compile: with no app-level mode the
released CCZ ships with ZERO <learn:deliver> markers, Connect
surfaces zero deliver units, and Phase 4 fails at payment-unit
creation. app-release-qa (Phase 3 Step 2.8) catches it
post-release, but that is a full deploy→build→release cycle too late
— assert it here, cheaply, on the already-built app.
1. Read the app's Connect type and complete form list from the § 4a
snapshot (`--outline`: `connect` + `forms[]` with uuids); no
`get_app`. Live fallback only when 4a left no `FRESH` snapshot: call
`get_app({app_id})` — its summary header prints the Connect type
(e.g. `Connect type: deliver`; a standard app prints none) — and
**keep that response** for its form list and `[uuid …]` markers.
2. **Assert the header reads `Connect type: deliver`.** Do NOT rely
on the per-form `[Connect enabled]` flag — it is a false positive
for compile (see above, and Step 3's marker-mechanism bullet).
3. On a miss, heal ACE-direct with **`configure_connect`**, which is
available to the ACE session that executes this skill.
`update_app` no longer carries `connect_type` — it was removed in
Nova's 2026-07-31 redeploy (dimagi-internal/ace#1133);
`configure_connect` replaced it and sets the app-level mode AND
every form's Connect block in one atomic call.
> **⚠ `configure_connect` is REPLACE-ALL, not a patch.** Upstream:
> *"learn/deliver requires the complete nonempty UUID-addressed
> participant set, and every unlisted form becomes auxiliary."*
> **Every form you omit from `participants[]` has its existing
> Connect block CLEARED.** A partial participant list turns this
> marker-repair into a marker-deletion — strictly worse than the
> problem you came to fix. This is the OPPOSITE of
> `update_form({connect})`, which is per-form and additive.
So: **enumerate the COMPLETE participating set from the step-1
`get_app` response first**, then call once:
```
configure_connect({
work_id, request_id,
mode: "deliver",
participants: [
// EVERY paid form in the app, addressed by formUuid.
{ formUuid: "<paid form uuid>",
connect: { deliver_unit: { name, entity_id, entity_name } } },
…
]
})
```
Note the **expression shape** — since voidcraft-labs/commcare-nova#693
`entity_id` / `entity_name` (like `relevant`, `calculate`,
`default_value`) are expression STRINGS that name answers as
`#form/<path>`; a bare id is refused as an unknown reference.
Omit each block's `id` and let Nova derive it.
Then `save_work` (require `saved: true`), re-run `get_app({app_id})`
and re-assert BOTH the header and that every
form that carried a Connect block before still carries one.
**This re-fetch stays live** (`LIVE_READS`): it verifies this
step's own write, which the § 4a snapshot predates.
**Bounded loop, max 3 iterations.** The per-form
`connect.deliver_unit` blocks the architect already built stay
intact, so the marker compiles on the next deploy with **no
rebuild and no fresh app id**. If the header still does not read
`Connect type: deliver` after the third attempt (or
`configure_connect` is itself unavailable), halt with a clear
`deliver-marker-wont-compile` failure and do NOT write the success
summary.
Use `update_form({moduleUuid, formUuid, connect})` **only** to refine
one sub-config on a form that ALREADY participates — it cannot enable
Connect, switch mode, or add a participant, and it refuses a
whole-slot null. Full division of labour:
`playbook/integrations/nova-integration.md § configure_connect
replaced update_app({connect_type})`.
Reproducer: `malaria-rdt/20260603-1600` — the Deliver app landed with
no app-level Connect mode; both the original and a fresh
re-upload+re-release produced `connect_markers.deliver = 0` (that
session predated the L0 heal and fell back to a rebuild, blocked by a
concurrent Nova `create_app` outage — a tool since removed, #693). See jjackson/ace#694. The L0
heal in step 3 above was confirmed live on
`bednet-spot-check/20260616-0618` — one call flipped the header from
absent to `Connect type: deliver` with the per-form blocks intact, no
rebuild. See jjackson/ace#792. (That heal was
`update_app({connect_type: "deliver"})` at the time; the parameter was
removed 2026-07-31 and `configure_connect` is now the only path —
dimagi-internal/ace#1133.)
4f. Option-source pre-check (PDD-declared selects must not ship as free
text). The structural preventer for ace#1136. structured-capture puts
the rule in the architect brief; this step is the ACE-direct safety net for
when the architect had no option list in front of it and fell through to
kind: text anyway — the exact failure mode of
spark-facilitator/20260731-0656, where four of five PDD-declared
select/lookup fields shipped as free text and only the one with an
inline-enumerable option set survived. Cheap; runs on the already-fetched
blueprint. Same bounded-loop shape as 4a–4e.
1. **Build the declared-select list from the PDD** (not from the brief and
not from the architect's return string). Every field in the Deliver App
Specification whose spec text contains `select`, `lookup`,
`choose from`, `pick from`, or `from the registered …` goes on the list,
plus every field named as a component of a `connect.deliver_unit`
`entity_id`.
2. **Resolve each one in the built app.** Nova is uuid-addressed — one
`search_blueprint({query: '<field id>', app_id})` per field returns
`{moduleUuid, formUuid, fieldUuid}` and the field's context; or read
them off the `get_form` responses already fetched in Step 4a.
3. **Assert** each declared-select field's `kind` is `single_select` or
`multi_select`. Collect the offenders as
`degraded[] = {field_id, declared_as, shipped_kind, feeds_entity_id}`.
4. **If `degraded` is empty, run the whole-app lookup sweep in step 8 and
then proceed to Step 5.** An empty `degraded` list means no field needs
CONVERTING; it does not mean no field needs CHECKING (ace#2143).
5. **For each offender, try to bind a real option source before accepting
the degradation.** Call `get_lookup_tables({app_id})` ONCE — it lists
this app Project's data tables and their columns with the stable ids
`set_field_options_source` needs — then, per offender:
- **A table holds the option set** → convert the field and bind it:
`set_field_options_source({work_id, request_id, moduleUuid, formUuid,
fieldUuid, source: {kind: 'lookup', tableId, valueColumnId, labelColumnId}})`.
The call is an atomic REPLACE of the field's complete choice source
(there is no retained inactive source), so send the whole thing.
Convert a `text` field to a select first via
`edit_field({work_id, request_id, moduleUuid, formUuid, fieldUuid, updates:
{kind: 'single_select', optionsSource: {...}}})` — a kind conversion
that would set saved case values aside returns `needsConfirmation`;
on a fresh build there is nothing to set aside, so re-call with
`confirmConversion: true`. **Every bind made here is a site for the
step-8 sweep** — do not verify it ad hoc.
- **No table, but the set is knowable** from the PDD / inputs / a source
`.ccz` → bind it inline:
`source: {kind: 'inline', options: [{value, label}, …]}` — `label`
is plain text since voidcraft-labs/commcare-nova#693 (≥2 options).
- **Neither** → ship a select over the values you DO have plus an
explicit "Other" with a relevance-gated `_other` free-text follow-up.
**Neither rung is available when the PDD names a partner register for
this field (ace#1621).** Both invent vocabulary — the inline rung by
enumerating a set the architect composed, the Other rung by shipping a
partial set as if it were the register — and "knowable from the PDD /
inputs / a source `.ccz`" is precisely the case where the real values
exist and must be read rather than composed. Go to the register
procedure in step 7, which now **builds and binds** the register
rather than handing it to an operator (ace#1886).
6. **`save_work` (require `saved: true`), then re-run steps 2–3 against
the SAVED app (`app_id` reads). Bounded loop, max 3 iterations.**
7. **Whatever survives is a NAMED gap, never a silent one.** Any field
still on `degraded` after the third iteration MUST appear as a decision
row (§ Decisions Log) and in the Step 7 summary's `option_source_gaps`
list, with the field id, what the PDD declared, what shipped, and the
exact table + value column + label column that needs to exist before
go-live.
**Do not decide halt-vs-record by reading the paragraphs below — call
`classifyOptionSourceGap` in `lib/option-register.ts`, once per
still-degraded field, and do what it returns** (ace#2857). It takes
facts, not conclusions: `{feeds_entity_id, payable,
non_enumerable_by_nature, candidate_values, declares_partner_register}`,
where `candidate_values` is every legitimate member of the option set
you actually FOUND in the PDD / frozen `inputs/` / a source `.ccz`.
`outcome: 'halt'` halts; `outcome: 'record'` records the row and
proceeds, carrying `exemption` and `reason` verbatim into it. The prose
below is the rationale for what that function does, not a second,
hand-applied copy of it.
**Every recorded gap is `review_ask: required-before` +
`needed_by: go-live`** — the ask the function returns, and never
`recommended-confirmation`. An option-source gap IS a go-live
prerequisite by definition, and `recommended-confirmation` is
explicitly the ask that gates nothing (`lib/decisions-schema.ts`:
*"`required-before` is the only one that gates anything"*), so the old
wording asked for the non-blocking ask in the same sentence as "since
it is a go-live prerequisite". `required-before` is an enforced release
blocker (`assessRequiredBeforeAsks`, `lib/release-readiness.ts`), so the
gap now gates the gate it belongs to.
**Halt** — do not write the success summary — if any still-degraded
field `feeds_entity_id` **on a PAYABLE deliver unit**: a free-text
component of Connect's dedup / payment grain is a payment-correctness
defect, not a data-quality one (one typo mints a second payable
delivery; two same-named entities collapse into one). Everything else
records and proceeds.
**A SECOND halt class: a PDD-named partner register (ace#1621).**
The halt above is scoped to payment correctness, so it is silent on the
other way a degraded select does damage — shipping ACE's invented words
as the PARTNER's taxonomy. When the PDD sources a field's options from a
named register (`<field> from <tag> [source: …] [filtered by …]` in
§ Program Parameters), **an inline invented option list is a HALT**,
whatever the field's `feeds_entity_id` / payability status, and it is
never dischargeable as a named gap.
Do not eyeball it and do not re-derive it — run
`lib/option-register.ts`: `parseRegisterDeclaration` over the PDD row,
then `diffOptionRegister({declaration, built, registerRows})` over the
option source read back from the blueprint. Any finding halts; the
`unbound-register` code is the one that fires here. `declared: false`
while the field exists is a **Phase-1 gap** (the PDD owes the register),
reported against Phase 1, not as an architect defect — the build was
supposed to HALT rather than fill it.
Source the rows from the partner's own `.ccz` fixture XML via
`parseFixtureRegister` in preference to a prose structure document: a
production CCZ carries the register's REAL value codes, which are what
the app stores and what the partner's M&E joins on, whereas a
human-readable guide usually carries only labels and forces the build to
mint an identifier scheme the partner has never seen (the #1527
"trust extraction first" rule, one layer over).
**ACE now finishes the job — extract, create, populate, bind, and
PROVE the bind (ace#1886).** Both former blockers are gone.
`create_lookup_table` takes the columns AND the rows in one atomic
write, and binding a select to that table was accepted and read back
live on 2026-09-06 (`voidcraft-labs/commcare-nova#545` closed COMPLETED
2026-09-02). There is no operator handoff and no CSV. The terminal
behaviour is:
1. Extract the register (`parseFixtureRegister` over the partner's
`.ccz` fixture XML, per the paragraph above).
2. `create_lookup_table({work_id, request_id, name, tag, columns, rows})`
— one call, up to 5000 rows, returns durable table and column uuids
in input order. Use the tag the PDD declares. **This write is NOT
staged**: Project data commits at call time, is visible before any
save, and is not undone by `discard_work` (the tag stays taken). Only
the bind in step 3 is staged.
3. Bind the field. On a field that is already a select,
`set_field_options_source({work_id, request_id, moduleUuid, formUuid,
fieldUuid, source: {kind:'lookup', tableId, valueColumnId, labelColumnId}})`.
On a field that shipped as `text`, `edit_field` with
`updates: {kind:'single_select', optionsSource: {...}}` converts and
binds in one call. **`set_field_options_source` refuses a `text`
field outright** (*"is not a single- or multiple-choice field"*), so
convert first — observed live 2026-09-06.
Then **`save_work` and require `saved: true`.** Until the save lands
the bind exists only in the candidate: `upload_app_to_hq` would ship
the unbound field with no error.
4. **Verify by READ-BACK of the SAVED app, never by the write's
response.** Call `get_field({app_id, moduleUuid, formUuid, fieldUuid})`
for the field (with `app_id`, after step 3's save — a `work_id` read
would show the candidate's bind even if the save was refused) AND
`get_lookup_table_rows({app_id, tableId})` for the table, and pass
BOTH to `verifyLookupBind` in `lib/option-register.ts`
(`{requested, readBack: field.optionsSource, rows}`). This is not
ceremony: `add_fields` answers a *correctly bound* lookup field with
`"options": []` and no mention of the source, so its response can
neither confirm nor deny a bind. A `verified: false` of any code is a
HALT.
**`rows` is REQUIRED, and page to the end (ace#2143).** A bind that
lands on a table whose VALUE column repeats a code is refused by
Nova's `upload_app_to_hq` preflight one skill later, in `app-deploy`
— *"A lookup-powered choice list uses activity_id for its saved
values, but malawi_activities repeats the same value in several rows.
Make the values unique or choose another value column."* Uniqueness
**within a filter partition is not enough**: on
`spark-facilitator/20260906-2233` `malawi_activities` carried `other`
on seven rows, one per FCAP step, which no worker ever sees twice at
once, and Nova still refused the whole app. `get_lookup_table_rows`
pages at 100 rows, so concatenate the pages and pass
`complete: true`; a partial read is `verified: false`, because a
duplicate on an unread page is invisible.
**The value column must also be whitespace-free (ace#2984).** Nova's
preflight has a second rule: *"…contain spaces or line breaks there.
Saved values can't contain whitespace. Tidy those rows or choose
another value column."* So a table ACE (or the architect) authors
needs a **slug-shaped value column** — e.g. `code` with
`svc_entry_1` — distinct from the display `name` it uses as the
label; never bind the display name as the saved value. On
`kaduna-graduation/20261015-1411` `kgrad_services` had only
`area, service_type, name, location, contact`, three selects saved
`name` (`"Placeholder: services directory entry 1 …"`), and the whole
Deliver app was refused at upload. `verifyLookupBind` returns
`whitespace-values` for this; the repair is to add the code column
and rebind `valueColumnId` to it, not to strip the labels.
**Repairing a duplicate is not just renaming the rows.** A shared
`other` row usually exists once per partition *because* the select is
filtered — one shared row would be excluded from every partition —
and the follow-up field's `relevant` / `required` are written against
the literal value (`activity = 'other'`). Renaming to `s1_other` …
`s7_other` requires that predicate to change too (e.g.
`ends-with(activity, 'other')`). Decide which of the two shapes you
want before editing rows.
5. Record the table id, the column ids, and the `verifyLookupBind`
verdict in Step 7's summary (`option_register` frontmatter and
`## Build notes`). That verdict is
the run's evidence the register is live; without it the build is
asserting a bind it never observed.
**The halt is now scoped to the cases ACE genuinely cannot finish**, not
to the binding. It fires when:
- the PDD declares **no** register for a field whose options must come
from one (`declared: false`) — a Phase-1 gap, reported against
Phase 1; the build must never fill it;
- the declared source cannot be read from the run's frozen `inputs/`
(`source-unavailable`) — unverifiable is not the same as correct;
- `diffOptionRegister` / `diffRegisterRows` return any finding; or
- **`verifyLookupBind` does not verify.** Treat this as the regression
it is: run `scripts/probe-nova-fixtures.ts` (via `$ACE_ROOT`), which
exits 0 when the bind still works and 2 if it has regressed
upstream — then `skills/upstream-regression-triage`. Contract and
tripwire table in `playbook/integrations/nova-integration.md § The
fixtures (Project data table) channel`.
In every one of those cases, halt rather than degrade. A select
carrying invented values is strictly worse than a halt because it has no
downstream symptom — the app is complete and internally consistent with
its own invention, and every structural gate passes it (ace#1564's
rationale, same class). **An unverified bind is the same defect wearing
a better disguise:** the select renders empty to a field worker while
every ACE artifact says the register shipped.
Why this is not covered by the payment halt: on
`spark-facilitator/20260820-0817` the meeting-activity repeat shipped 11
ACE-authored placeholders (`attendance_register`,
`facilitated_discussion`, `savings_collection`, …) identical on all 24
FCAP steps, while Spark's own 78-activity register sat in the run's
frozen `inputs/`. The field does not feed `entity_id`, so 4f recorded a
gap and proceeded exactly as written — and it took an operator reading
the residual, days later, to stop the release.
8. **Sweep EVERY lookup-backed select in the app — not just the ones this
step bound (ace#2143).** This runs unconditionally, including when
`degraded` was empty at step 4 and when no partner register is declared
at all.
Steps 1–7 only ever reach a field that FAILED an assertion: a
PDD-declared select that shipped as free text, or a register field the
PDD named. A field the architect already shipped as a correctly-shaped
`single_select` bound to a table it authored itself passes step 3 and is
never looked at again — and that is the field that broke
`spark-facilitator/20260906-2233`. `malawi_activities` was created,
populated and bound by the architect, recorded as clean by every ACE
gate, and refused by Nova's `upload_app_to_hq` preflight in `app-deploy`
because its value column repeated `other` on seven rows. Nova's preflight
sweeps every lookup-powered choice list in the app, so the only ACE-side
check that cannot be bypassed is one with the same scope.
**One call does all of sub-steps 1–3:** `scripts/nova-app-lint.ts --app
<nova_app_id> --type deliver` (invocation in `agents/commcare-setup.md §
Step 1.4`) reads every bound table once, server-side, and runs
`auditLookupBinds` over every lookup-backed select — plus the two value
rules added since: **whitespace in the saved value** (`whitespace-values`,
ace#2984 — Nova's preflight refuses "Saved values can't contain
whitespace"; give an authored table a slug-shaped code column distinct
from its display `name` and bind the value to that) and **blank values /
labels and wiped rows** (`blank-values`, `blank-labels`,
`lookup-wiped-rows` — ace#2978: `edit_lookup_rows` `update` REPLACES the
row, so always send every cell). HALT on any `lookup-binds` blocker it
prints. The manual sub-steps below are what it does, kept for when the
script cannot run.
1. Collect every field in the built app whose options source reads back
`kind: 'lookup'`, whoever created it — walk the `get_form` responses
already fetched in Step 4a and read each one's `optionsSource`. For
any form Steps 4b–4f EDITED, re-read `get_form({app_id, …})` after
its save; the Step 4a response predates those binds.
2. For each distinct `tableId` in that set, read the rows:
`get_lookup_table_rows({app_id, tableId})`, paging to the end (100
rows/page) and passing the concatenated rows with `complete: true`.
One read per table, not per field.
3. Call `auditLookupBinds` in `lib/option-register.ts` with one
`{field, requested, readBack, rows}` site per bound field. **HALT on
`ok: false`**, quoting `describeLookupBindAudit(audit)`.
The failures it names are all invisible to every other gate ACE runs:
`duplicate-values` and `whitespace-values` (the upload preflight
refuses the app),
`empty-table` and `not-a-lookup-source` (the select renders EMPTY on the
device), `no-rows-read-back` / `partial-rows-read-back` (the check did
not actually run — not a pass). Fixing one here costs a row edit; the
same fix after `app-deploy` costs the whole of Phase 3 again.
**Three exemptions (ace#1295, ace#2857) — all returned by
`classifyOptionSourceGap`, none applied by hand.** Each comes from the
halt's own rationale: it is payment correctness, so it does not reach a
case where payment cannot be affected, and the escape ladder in step 5
has no rung that fits a set nobody can enumerate:
- **The deliver unit is UNPAID.** A form the PDD declares not payable
carries no payment unit (`app-connect-coverage § Step 2` decides
payability by role — ace#1327), so neither failure mode exists.
Record the gap in `option_source_gaps` and proceed.
- **The component is non-enumerable BY NATURE** — a personal name, a
free-text identifier, anything captured at the moment the entity
first enters the system. On a registration form there is no roster to
select from: no lookup table can exist, inline enumeration is
impossible for an open-ended name, and "the values you DO have plus
Other" is nonsense for a name field. `concat(username,
hh_head_name, visit_date)` is the STANDARD registration-key shape,
not a degradation.
- **No candidate value exists anywhere (`no-candidate-values`,
ace#2857).** Not one legitimate member of the set appears in the PDD,
the frozen `inputs/`, or a source `.ccz`. Then every rung of the
step-5 ladder is empty *by construction* — no table holds a set that
does not exist, there is nothing to enumerate inline, and "the values
you DO have plus Other" is just Other — and **any value shipped would
be invented**, which is `CLAUDE.md § No inferred backstory` on the
partner's own vocabulary.
More to the point, **halting accomplishes nothing here**: re-running
Phase 3 cannot produce the set, because it is the partner's to supply
before go-live. So the protection moves to the gate the risk actually
belongs to. Nothing is paid during Phases 3–8; the typo and the
collapse both happen when real workers submit real deliveries. A
`required-before` / `go-live` row is an enforced release blocker, so
the set must arrive before anyone can be paid either way.
**This is deliberately NOT the exemption ace#2857 proposed.** That one
keyed on the PDD declaring free text as an "advisory mechanism" — but
the PDD is AI-authored, so that lets Phase 1's own prose disarm
Phase 3's payment gate, and the qualifying phrase was one run's house
style. This one keys on a fact about the world, and the caller records
the search it ran.
Live false-halt: `turmeric-market-study-ghana/20261012-1749`, Deliver
app `d3a23546`, `market` — a PDD-declared select, a component of the
payable `turmeric_survey` entity key, with **no market named anywhere
in any input** because the implementing organisation chooses them
after the build (operator ruling `market-list-source-confirmed`).
Phase 2's journeys passed on either shape. The build was correct and
the phase halted; the agent overrode the halt and recorded the
override, which is the right outcome by the wrong mechanism.
Every exemption is **stated in the decision row with the reason**
(§ Decisions Log) — pass `verdict.exemption` and `verdict.reason`
through verbatim — and never taken silently. When the unit is payable
and candidate values DO exist, a degraded component still halts. Second
live false-halt: `bednet-check-2-visit/20260813-2313`, Deliver app
`74a097c6`, where the first two exemptions applied at once.
(Apps whose PDD declares no select/lookup fields skip cleanly at step 1.)
4g. Screen-shape check (a group is a field-list — catch the wall BEFORE
deploy). The structural preventer for the screen-grouping component.
A Nova kind: group compiles to a CommCare field-list, so every child
renders on ONE scrollable screen. Grouping questions that belong together
is correct and expected; the defect is a group that has become a wall, and
until this step existed nothing in Phase 3 looked at screen composition at
all. Cheap; runs on the already-fetched blueprint. Same bounded-loop shape
as 4a–4f.
**Position rationale — this MUST run before `app-deploy`.** On
hh-poverty-targeting/20260812-2034 the shape (all ten PPI indicators plus
the roster repeat on one screen) was not noticed until `app-test-cases`
tried to author the smoke recipe against it — two steps later, with the app
already uploaded. Fixing it there cost a re-upload, a fresh HQ app id and an
orphan app to soft-delete. Had Phase 4 already run it would have cost a
delete-and-recreate of the Connect opportunity
(`connect_create_opportunity` writes HQ app ids at create time and
Connect's edit form does not expose them). Here the same fix is free.
1. From the `get_app` / `get_form` responses already fetched in Step 4a,
build the form's field tree with each field's `id`, `kind`, flattened
`label` text, and `children` for groups/repeats.
2. Feed it to the pure helper — do NOT eyeball the counts:
```ts
import { checkScreenShape, formatScreenShapeReport }
from '../../lib/screen-shape';
const report = checkScreenShape(fields);
```
3. Branch on the findings:
- `severity: 'violation'` on `oversized-screen` → **split that group**
into sets that share a rule, via `add_fields` (new group containers)
+ `move_field`. **Create each new container carrying the SAME
`relevant` the original group had, BEFORE moving any field into it** —
a field moved out of a gated group to an ungated one silently loses
its gating, which is a correctness defect far worse than the scroll
length you came to fix.
- `severity: 'violation'` on `repeat-in-field-list` → `move_field` the
repeat to the form root, or into a group of its own.
- `severity: 'warn'` → keep the grouping only if the questions are one
coherent set, and say so in the summary's `## Build notes`;
otherwise split.
4. Re-fetch and re-run. **Bounded loop, max 3 iterations.** If a violation
remains after the third, surface a clear failure naming each offending
group and do NOT write the success summary.
5. Record the final `formatScreenShapeReport(...)` line in the summary's
`## Build notes`,
plus one sentence per surviving `warn` justifying the grouping.
(Forms with no multi-question group skip cleanly — `screensChecked: 0`.)
4h. Taught-vs-collectable cross-check (dimagi-internal/ace#1259). Runs ONLY when the Learn app for this run already exists (it is built first in Phase 3; if you are building Deliver standalone, skip and say so).
Nothing in ACE cross-reads the two blueprints: `pdd-to-learn-app` and this
skill each build from the PDD independently, and each `-eval` grades its
own app against the PDD in isolation. So a curriculum can instruct a worker
to perform a step this form cannot record, and every Phase 3 gate passes.
Live on hh-poverty-targeting/20260813-1612: Learn M8's padlocked-dwelling
worked example says "you still do all of this… take one photograph… record
the outcome as vacant", while the only `dwelling_photo` sits in a group
gated `relevant: consent = 'yes'` — never reached on a vacant visit. At
their first padlocked dwelling the worker follows the training, takes the
photo, and finds no screen to attach it to. A human found it by reading the
two blueprints side by side.
```ts
import { checkTaughtStepsCollectable, formatTaughtVsCollectableReport }
from '../../lib/taught-vs-collectable';
const report = checkTaughtStepsCollectable(learnBlueprint, deliverBlueprint);
```
**Do NOT auto-fix.** Which artifact is wrong is a judgement — the Learn app
may over-teach or this form may under-collect, and BOTH can be
PDD-conformant at once (they were on that run: §5.1 listed the live
photograph under "a payable visit requires all of", §5.2 gated the photo
screen on Consent = yes). Surface each finding as a decision row (§ Decisions
Log) with both sides quoted in `reasoning` and `review_ask:
recommended-confirmation` + `confirm_reason`, and raise it at the Phase 3
pause so a human decides which side moves. `report.status === 'unable'`
means the check **did not run** — the curriculum states no unconditional
evidence step, so there was nothing to cross-check. That is NOT "the two
apps agree": record it in the summary's `## Build notes` with its `reason` (`formatTaughtVsCollectableReport` renders it), and
if the curriculum visibly DOES teach one, the phrase matchers are the bug.
4i. Fake-preload check (a hidden caseWrite field can never hold the case
value) — runs ACE-DIRECT. The structural preventer for ace#1224. Because
a followup form cannot read its own case on this Nova instance (§ entity_id
case-UPDATE rule), architects reach for a shape that looks like a preload
and is not: a hidden field with caseWrite set whose value comes from a
literal default_value. It holds the literal forever, and then writes that
literal to the case — so it simultaneously collapses whatever it feeds
and wipes the case property it names. Nothing fails at build, validate, or
submit time; every symptom is post-hoc and silent. Cheap; runs on the
already-fetched blueprint. Same bounded-loop shape as 4a–4g.
1. From the `get_app` / `get_form` responses already fetched in Step 4a,
enumerate every field where **all three** hold:
- `kind == "hidden"`, AND
- `caseWrite` is set (any case type / property), AND
- its value comes from a literal `default_value` — i.e. there is no
`calculate` referencing a real answered field on this form. A
`default_value` of `''` (or any constant) with no such `calculate`
is the defect shape.
Collect them as
`fake_preloads[] = {field_id, case_type, property, feeds_entity_id,
guards_relevant[]}`.
2. **HALT if any of them feeds a `connect.deliver_unit` `entity_id` or
`entity_name`** — directly as a part, or transitively via a field
whose `calculate` references it. That is a **payment-correctness**
defect, not a data-quality one: the key evaluates to the same constant
for every entity, so Connect folds every delivery a worker makes onto
ONE CompletedWork row — the per-entity record, per-entity review and
any per-entity cap are gone while every submission succeeds (and each
repeat is still paid, ace#2512). Do not write the success summary; surface the
field, the marker it feeds, and the case property it would wipe.
3. **Also inspect every hidden field referenced by a `relevant`
expression**, whether or not it feeds a marker. This is how the
dead-advisory mode surfaces and it is invisible to an entity_id-only
scan: an advisory gated on `x != ''` where `x` is always `''` can never
fire, so the guardrail the PDD promised is inert while reading as
present in the blueprint, the Work Order and the training materials.
Treat a `relevant` guarding on a fake preload as a violation and fix it
in the same pass.
4. **Repair — add a new visible field, never convert in place.** `hidden`
is a **TERMINAL kind in Nova**: `edit_field` categorically refuses
hidden→visible, and `confirmConversion: true` does NOT unlock it (that
flag only covers kind conversions that set saved case values aside).
So the repair is: add a NEW visible field that captures the value the
form actually needs (a select over the same option source the create
form used, per Step 4f), repoint the `entity_id` / `entity_name` parts
and any `relevant` at the new field, and **neutralise** the old one —
drop its `caseWrite` so it stops overwriting the case property, or
`remove_field` it outright if nothing else references it.
```
/nova:edit <app_id> "Field <id> in <module>/<form> is hidden with a
caseWrite to <case_type>/<property> and a literal default_value, so it
can never hold the case value and overwrites that property with the
literal. Add a new VISIBLE field capturing <value> (select over
<option source>), repoint <entity_id|entity_name|relevant> at it, and
remove the caseWrite from <id>. Do not attempt to convert <id> to
visible — hidden is terminal. After the edit, get_form and verify."
```
5. Re-fetch and re-assert. **Bounded loop, max 3 iterations.** Anything
still on `fake_preloads` after the third iteration goes in a decision
row (§ Decisions Log) and the Step 7 summary's `## Build notes` by field id, case property, and what it
feeds — and if it feeds a marker, the step halts per step 2 rather
than recording.
(Apps with no hidden `caseWrite` fields skip cleanly at step 1.)
4j. Payability-discriminator backstop (ace#1489) — runs ACE-DIRECT. The
entity_id payability rule lives in Step 3's Nova brief, which is
architect prose: nothing downstream of the build re-checks that the key
the architect actually shipped honours it. So an identity-only key on a
form with a non-payable branch escapes Phase 3 silently and is caught a
whole Nova build later by app-release-qa Step 2.8, which raises
no-entity-component as a [BLOCKER] and hard-halts the phase. The
resolution helper already exists and the EVAL side already runs it
(pdd-to-deliver-app-eval § Connectify wiring (b2)); this step closes the
build/eval asymmetry, exactly as 4g pairs with the eval's checkScreenShape.
1. Read the PDD's Program Parameters `entity_id_grain` and whether the PDD
marks any subset of submissions to this form non-payable (a did-not-happen
branch, a screening-only visit, a committee-vs-community meeting type).
2. Feed it to the pure helper — do NOT adjudicate this by reading the key:
```ts
import { resolveEntityIdGrain } from '../../lib/entity-id-precedence';
const grain = resolveEntityIdGrain({
pinnedComponents, // from the PDD's entity_id_grain
payabilityDiscriminator, // the built form field expressing payability, if any
hasNonPayableBranch,
sourcePinned,
});
```
3. Branch on the resolution:
- `deviates: true` → the built key MUST ship `grain.components` in order,
and a decision row (§ Decisions Log) MUST disclose it using
`grain.discloseAs`. Edit the
`entity_id` calculate via Nova if it does not match, then re-fetch.
- `unresolvable: true` → a non-payable branch exists but **no field
expresses payability**. Do NOT ship silently: record `grain.reason` verbatim
in a decision row (§ Decisions Log, with `review_ask:
recommended-confirmation` + `confirm_reason`) and name the field that
would fix it. This is
the case the helper exists to stop from passing quietly.
- otherwise → the PDD-pinned grain stands; record the one-line reason.
4. **Whenever the shipped key is payability-scoped, the residual list MUST
name the Phase-4 verification predicate that rejects the non-payable
value** (ace#1434). The scoped key stops a non-payable submission
consuming the payable slot, but it also mints `<identity> - no` as its
own countable entity — so without that predicate the daily cap decides,
and a worker whose first follow-up was a refusal can still be blocked.
A key shipped without the residual is the ace#969 failure one layer down.
5. Re-fetch and re-assert. **Bounded loop, max 3 iterations.** If the key
still disagrees with `grain.components` after the third, surface a clear
failure naming the built key and the required one, and do NOT write the
success summary — halting here costs one loop; letting it through costs
a Nova build plus a Phase-3 halt.
6. **Capped-index arithmetic — when the cap rides in the key (ace#2148).**
A per-entity cap ("at most N paid meetings per FCAP step") puts a
clamped counter in the key, fresh per payable encounter up to the cap and
constant after it. **The key does not stop the over-cap payment** — with
the `duplicate` flag off (always, on ACE opportunities) Connect resets a
repeat key to `pending`, auto-approves it and pays it again (ace#2512;
`playbook/integrations/connect-api.md § A repeated entity_id is PAID
AGAIN`). So the form MUST ALSO compute a `payable_slot` field (`yes` only
for an in-cap payable encounter) for Phase 4's `form_field_rules` row to
gate on, and the build summary names it. If the PDD names no such field,
build it anyway from the same counter the clamp reads (it is derivable:
`yes` iff the encounter is payable AND the pre-submission count is below
the cap) and list it as a Phase 4 residual — never ship a cap that rides
only in the key. Keep non-payable record kinds on a form with no
`deliver_unit` marker (they would otherwise consume `max_daily` /
`max_total`). The clamp constant is still NOT the cap. **A `casedb` read is the
state BEFORE this submission** — the case property is written on submit —
so `min(<casedb count>, N)` admits `N + 1` distinct keys and the
(N+1)-th mints an index that has never existed, which Connect pays. On
`spark-facilitator/20260906-2233` that turned a cap of 3 into a cap of 4:
28 payable events against a declared `total_cap_per_flw` of 21, with
every build-time gate green and `is_payable` correctly 0 on the fourth
meeting (Connect never reads it).
Two correct spellings, sharing no constant — both have shipped from this
path, five weeks apart:
```
if(<casedb count> >= <cap>, <cap - 1>, <casedb count>) # indices 0..cap-1
min(<casedb count> + 1, <cap>) # indices 1..cap
```
So do NOT adjudicate it by comparing the clamp to the cap. Read the
built `capped_index` back with `get_field`, and either trace the first
`cap + 1` payable submissions by hand — confirming the last lands on an
index an earlier one already used — or run the same trace mechanically:
```ts
import { checkBuiltClampCapacity } from '../../lib/payable-cap-arithmetic';
// binds: every node the counter reads -> its calculate, from get_field
// read-backs (Nova `#form/…` and case-type `#<case_type>/…` spellings accepted as-is).
const r = checkBuiltClampCapacity(builtCalculate, binds, declaredCap);
r.status === 'checked' && r.ok; // must hold
```
The helper resolves the counter's timing from the expression
(`classifyCounterTiming`) — never assume one. `min(<casedb count> + 1,
cap)` is `includes-current` and admits exactly `cap`; simulating it as
pre-increment reads `cap + 1` and HALTs a correct build (ace#2515). A
`status: 'unable'` (timing undecidable — usually a counter node missing
from `binds`) is a halt too, not a default: supply the missing calculate
or trace by hand.
A mismatch is a **HALT**, repaired inside the same bounded loop as step 5.
`app-release-qa § Step 4` runs `checkPayableCapArithmetic` over the
RELEASED form as the backstop; reaching it means a Nova build and a
Phase-3 halt were spent on arithmetic decidable here.
(Forms with no non-payable branch skip cleanly — `deviates:false`, and the
pinned grain stands.)
4k. Fixed-instrument constant fidelity (ace#1527) — runs ACE-DIRECT. When
the PDD marks an instrument [FIXED], its questions AND its arithmetic are
fixed by a published document that ACE only digitises. Every other gate on
this path is structurally blind to a constant's VALUE: validate_app
checks expression structure and references, pdd-to-deliver-app-eval grades
the build against the PDD — which describes the instrument narratively, so a
wrong constant is PDD-conformant prose — and app-release-qa checks form
counts, Connect markers and install-time behaviour. Step 3 hands the
architect the point values as PROSE, so the architect transcribes from a
model-authored brief, and the brief is the thing under test. On
hh-poverty-targeting/20260819-1435 that shipped 9 of 17 point values wrong
and all 101 poverty-likelihood values invented. A wrong scorecard produces a
complete, plausible, fully-verified dataset that ranks the wrong households,
and no downstream check has a symptom to catch. (The PPI licence permits
digitising a scorecard and its lookup tables only UNMODIFIED, so this is a
compliance question as well as a quality one.)
1. **Trigger — two conditions, two DIFFERENT outcomes (ace#1648).** The
trigger used to AND "the PDD marks an instrument `[FIXED]`" with "the
manifest carries a source file for it" into ONE silent skip, so
*nothing to check* and *the thing I must check is unreachable* were
indistinguishable and both reported green. **A skip that disables a
correctness check is worse than one that degrades an output, because
the run still says green.** Split them:
- **No instrument is `[FIXED]`** → **skip cleanly** and say so in the
summary's `## Build notes` (`instrument_constants: skipped —
<reason>`). Legitimate.
- **A `[FIXED]` instrument whose source resolves** (step 2) → run the
check.
- **A `[FIXED]` instrument whose source does NOT resolve** → **HALT.**
Never a skip. Print the resolution detail, record a `[BLOCKER]`-grade
residual naming the file that would close it (a Phase-1 gap), and do
NOT write the success summary. Shipping a digitised scorecard that
nothing ever compared against its published source — while reporting
a clean phase — is exactly the ace#1527 failure this step exists to
prevent.
In every branch: do not substitute the PDD's prose restatement of the
table or the Nova brief for the file, and **do not compose a path by
name**.
2. **Resolve from the MANIFEST — `inputs[]` and the folder ids the
manifest itself records, ONE LEVEL (ace#1648) — publisher's file
first (ace#2852) — then fetch the bytes.**
Read `inputs-manifest.yaml` and match the entry for the instrument the
PDD names. If no `inputs[]` entry matches, **or the match is a derived
extraction** (`classifyInstrumentArtifact(entry) === 'derived'` — e.g.
a `text/markdown` "extracted verbatim" file), walk the manifest's own
recorded folder ids — each `subfolders_not_listed[].folder_id`, plus
`source_folder_id` when present — with
`drive_list_folder({folderId})`, **one level deep, never recursively**,
and match there. The manifest's `inputs[]` is direct child FILES only
(orchestrator Step 5c) — that is deliberate, since `inputs[]` is the
frozen evidence set Phase 1 synthesizes from — so a vendor bundle
published as a SUBFOLDER of `inputs/`, the natural shape for a vendor
download, is never in `inputs[]`, and 4k took the skip branch every
time it was.
Walking an id the manifest ITSELF recorded is not guessing; composing a
path from a name still is, and is still forbidden. Exactly one match →
proceed; more than one → HALT rather than pick; zero → HALT per step 1.
Pass BOTH the derived `inputs[]` entry and the walk's matches to the
helper: it resolves the single publisher-class subfolder file over the
extraction and names the passed-over entry in its memo; it keeps the
extraction (with its ace#2110 caveat) only when the subfolders hold no
published file. Before ace#2852 the `inputs[]` match ended the search,
so on `smallholder-seasonal/20261012-1549` the strict path resolved the
extracted `.md` while the publisher's `.xlsx` sat one level down in a
manifest-recorded subfolder.
Classify the outcome with the helper rather than by eye — it owns the
skip-vs-halt split, so the branch cannot silently revert:
```ts
import { resolveInstrumentSource } from '../../lib/instrument-constants';
const resolution = resolveInstrumentSource({
fixedInstrument, // does the PDD mark an instrument [FIXED]?
manifestEntry, // matching inputs[] entry, or null
subfolderCandidates, // matches from the one-level walk
manifestRecordsSubfolders, // did the manifest record any folder ids?
instrumentName,
});
// 'skipped' -> write resolution.memo into ## Build notes and move on
// 'halt' -> print resolution.detail and STOP (no success summary)
// 'proceed' -> resolution.source.file_id is the source
```
**`resolution.artifactClass` says WHAT you resolved, and it is not
decoration (ace#2110).** This step's premise is that the artifact it
diffs against is UPSTREAM of ACE — that is the whole force of "read the
SOURCE, never the Nova brief and never the PDD's restatement". A third
model-authored intermediate defeats it silently: an EXTRACTION of the
workbook, published into `inputs/` as the instrument. `proceed` fires
either way, the diff runs, and `mismatches: 0` is reported by a check
that compared ACE to ACE. Live on `poverty-graduation/20260905-1345`,
whose only instrument in `inputs[]` is a `text/markdown`
"(official, extracted verbatim)" file while the publisher's workbook
sits in a DIFFERENT opportunity's inputs.
`artifactClass: 'derived'` does NOT halt — on that run it was the only
instrument artifact in the frozen inputs, and halting would block a
build over a file that is very likely correct. It changes what you may
CLAIM. Carry `resolution.memo` verbatim (it already appends the caveat)
into the summary's `## Build notes` AND a decision row (§ Decisions
Log, with `review_ask: recommended-confirmation` + `confirm_reason`),
and record the class in Step 7's frontmatter, because a derived check is
real but **unfalsifiable**: an error in the extraction is reproduced
faithfully by the build and the diff still reads clean. Name the
publisher's file that would close it.
Then fetch by `file_id`:
```ts
drive_download_binary({ fileId, writeToPath: '<scratch>/<name>.xlsx' })
```
`drive_read_file` returns a typed `unsupported_binary_mimetype` error for
`.xlsx` / `.pdf` / `.docx` and points at `drive_download_binary` (see
`docs/atom-schemas.md § drive_read_file`) — reach for the binary atom
first rather than rediscovering that (ace#1527). **Read the SOURCE, never
the Nova brief and never the PDD's restatement**: both are model-authored,
and one of them is the artifact this step exists to test.
3. **Trust the extraction BEFORE using it as an oracle — run this FIRST.**
```ts
import {
readXlsxColumn,
assertExtractionTrusted,
} from '../../lib/instrument-constants';
const extracted = readXlsxColumn(bytes, {
sheet, column, firstRow, lastRow, // all declared by the SOURCE
});
const trust = assertExtractionTrusted(extracted.values, {
expectedFirst, expectedLast, expectedRowCount,
// 'none' for a scorecard POINTS column — it restarts at 0 for each
// indicator, so it is never monotonic (ace#2932). Omit it for a
// poverty-likelihood column, which is strictly monotonic.
direction,
});
```
`trusted: false` → **HALT.** Print every `trust.failures` entry and every
`extracted.problems` entry, then stop: do NOT diff, do NOT edit the app,
and do NOT write the success summary. An unchecked extraction is a second
way to ship a wrong instrument while reporting success — the first repair
round's extraction produced `score 4 -> 79.0`, because an `.xlsx` cell
carrying `t="s"` holds an INDEX into `xl/sharedStrings.xml` and an
undecoded index is a perfectly plausible number. `readXlsxColumn` decodes
through the shared-string table and returns an unresolved index as a
STRING for exactly that reason, so a `non-numeric` failure is the
header-leak signature, not a parser quirk. The three assertions
(endpoints, strict monotonicity, row count) are independent and all run,
so the printed list is every way the extraction is wrong, not the first.
4. **Read the BUILT literals from Nova, then diff.** `get_field` over each
scoring `calculate` and each lookup branch, addressed off the Step-4a
`get_app` blueprint map — reuse it, do not re-fetch the app. Build a flat
`key -> points` table plus an `indicator -> {option -> points}` table for
each side, then:
```ts
import {
diffScoringConstants,
compareMaxScore,
} from '../../lib/instrument-constants';
const diff = diffScoringConstants({ source: sourceConstants, built: builtConstants });
const max = compareMaxScore({ sourcePoints, builtPoints, clampAt });
```
`clampAt` is the ceiling of the PDD's `min(<score>, N)` clamp. Judge
nothing by eye: `diff.mismatches`, `diff.missingInBuild` and
`diff.extraInBuild` are the finding set, and `max.clampDead` is the
second-order one.
5. **Any mismatch, or `clampDead: true`, is a HALT — this is not a warn.**
Repair in a **bounded loop, max 3 iterations**: `edit_field` the offending
literal → `save_work` (require `saved: true`) → re-fetch with `app_id` →
re-diff. If anything still disagrees after the third,
surface a structured failure naming every `{key, source, built}` plus
`sourceMax` vs `builtMax`, and do NOT write the success summary.
`clampDead: true` alongside `clampReachableInSource: true` means the built
instrument cannot reach the ceiling its own clamp exists to enforce — the
clamp is dead code and the overshoot the PDD wants observable can never
fire, which is precisely how a wrong instrument stays internally
consistent with its own wrong numbers. Fix the constants; never delete
the clamp to make the check pass.
6. **`## Build notes` records the CHECK, not just its verdict.** Write
into the summary's `## Build notes` the source `file_id` and file name, **`resolution.artifactClass` — whether the diff
was against the publisher's own file or against a derived extraction of
it (ace#2110)** — the sheet / column / row range read, the number
of rows checked, both endpoint values as extracted, `sourceMax` vs
`builtMax` and whether the clamp is live, and the mismatch count (`0` on
success). Add the licence note: the published instrument is reproduced
UNMODIFIED — verbatim transcription of the scorecard and its lookup table
is what the licence permits, and any "improvement", including a tidier
rounding, is out of scope for this build.
(No `[FIXED]` instrument → skip cleanly; `## Build notes` says so. A `[FIXED]`
instrument whose source cannot be resolved — not in `inputs[]`, and not
found by the one-level walk of the manifest's recorded folder ids — is a
**HALT**, never a skip (ace#1648). A skip is a legitimate outcome only in
the first case; a SILENT skip is never one.)
**No added options on a `[FIXED]` instrument (ace#2366).** A "Declined",
"Don't know" or "Refused" option on a `[FIXED]` instrument question is NOT
`[ACE]` latitude unless the instrument itself carries that option — it is an
added option on a verbatim-only tool, even when it scores 0. Record refusal
at the visit level (the PDD's non-payable outcomes), never as an extra
answer. `app-release-qa § Step 4.8` diffs the released form against the
instrument file and blocks on it.
4k′. Partner-instrument field provenance (ace#2197) — runs ACE-DIRECT,
beside 4k. When the PDD builds a form out of a partner's SHIPPED
instrument that sits in inputs/ (a partner CCZ / XForm named by
inputs-manifest.yaml — [FIXED] or not), every field identifier the PDD
names must say where it came from. On spark-facilitator/20260907-1120
§ 5.3 screen 8 listed male_leader_attendence, female_leader_attendence
(verbatim from Spark's v1392 forms-4.xml, typo and all) beside
partner_staff_attendance, which exists nowhere in that app — and the
invented name read as the partner's own field in the build, the Work Order
and the training materials. Resolve the instrument exactly as 4k step 2
does (manifest only, never a composed path), unzip it, and run:
```ts
import {
checkInstrumentFieldProvenance,
declaredNetNewIdentifiers,
} from '../../lib/instrument-field-provenance';
const prov = checkInstrumentFieldProvenance({
fieldSpec, // the PDD's field-spec table rows for this form
instrumentForms, // the partner form XML(s) the PDD cites
declaredNetNew: declaredNetNewIdentifiers(pddMarkdown), // its reused-vs-net-new split
});
```
Every identifier comes back `reused`, `net-new`, or
`claimed-reused-but-absent` (absent from the instrument, yet listed beside a
reused field or on a line that says "reused"/"verbatim", and not declared
NET-NEW). Only the third is a finding, and it is **not** yours to resolve by
guessing: build the field under the PDD's name, record it as NET-NEW in
`## Build notes`, and write one decision row per finding (`evidence_basis:
conflicting`, `review_ask: recommended-confirmation`) quoting
`prov.findings[i].message` — the PDD must either name the instrument's real
field or declare the identifier NET-NEW in its split. No partner instrument
in `inputs/` → skip and say so in `## Build notes`. *Enforced:*
`test/lib/instrument-field-provenance.test.ts` (the real v1392 form and the
run's own PDD).
4l. Entity state-taxonomy fidelity (ace#1564) — runs ACE-DIRECT. When the
followed entity carries states the app must NAME, those names are the
partner's own process vocabulary and ACE only transcribes them. Step 3
hands the architect the taxonomy as PROSE, and the architect needs the
option set to build the phase-filtered step picker longitudinal-visits
requires — so a thin or missing declaration is filled in with something
plausible. On spark-facilitator/20260820-0817 that shipped four invented
phase labels and a different four-way partition of the 24 steps than
Spark's own published guide (which sat in the run's inputs/). Every gate
passed it: validate_app checks structure, the eval grades against a PDD
that describes the lifecycle narratively, and app-release-qa checks counts
and install-time behaviour — an app is internally consistent with its own
invented vocabulary. The Learn app then teaches one mapping while Deliver
offers another, and the invented labels reach real workers and, via the
training deck, the partner.
1. **Trigger.** Fires iff the app ships any state option set the worker
sees — a phase / stage / status / round picker, case-list column, or
filter. **Always fires for `archetype: longitudinal-visits`**, whose
case-list requirement makes such a state mandatory. No such state
anywhere → skip cleanly and say so in the summary's `## Build notes`
(`entity_state_taxonomy: skipped — <reason>`).
**Exception — a declared stateless entity (ace#2774).** When
`parseStateTaxonomy` returns `stateless: true` (the PDD row reads
`stateless [source: …]`), the PDD has positively declared that the
followed entity has no states: brief NO state field, no phase picker
and no state case-list column, and record
`entity_state_taxonomy: stateless — 0 states declared` in the summary.
The diff in step 3 still runs (declared `[]`), so any state option set
the architect added anyway surfaces as `extraInBuild` — an invented
state — and is repaired by removal.
2. **Parse the DECLARED taxonomy — this is the only authority.**
```ts
import { parseStateTaxonomy } from '../../lib/entity-state-taxonomy';
const declared = parseStateTaxonomy(programParameters.entity_state_taxonomy);
```
`declared: false` → **HALT.** The PDD declares no state vocabulary while
the trigger fires: record a Phase-1 finding naming
`program_parameters.entity_state_taxonomy` (and the § Entity Lifecycle
prose it should be derived from), and do not build the picker.
**Do not substitute a generic lifecycle vocabulary to keep the build
moving** — an invented phase name is worse than a gap, because a gap is
visible and a plausible wrong vocabulary is not. `problems` non-empty
(overlapping step ranges, duplicate values, duplicate labels) → HALT the
same way: the ambiguity is the PDD's to resolve, not this step's.
When `declared.source` names a document, the brief must have been
composed from THAT file out of the run's frozen `inputs/` (resolved via
`inputs-manifest.yaml`), not from the PDD's summary table. If it was
not, re-compose before diffing — the summary table is the artifact under
test.
3. **Read the BUILT option set from Nova, then diff.** `get_field` over each
state-bearing select, addressed off the Step-4a `get_app` blueprint map
(reuse it, do not re-fetch), plus the member activity/step list each
state exposes. Then:
```ts
import { diffStateTaxonomy, describeTaxonomyDiff } from '../../lib/entity-state-taxonomy';
const diff = diffStateTaxonomy({ declared: declared.states, built });
```
Judge nothing by eye: `diff.extraInBuild` (an invented state),
`diff.missingInBuild`, `diff.relabelled` (the partner's words rewritten)
and `diff.repartitioned` (the step partition moved) are the finding set,
and `describeTaxonomyDiff` renders them.
4. **Any finding is a HALT — this is not a warn.** Repair in a **bounded
loop, max 3 iterations**: `edit_field` the offending option value / label
/ member list → `save_work` (require `saved: true`) → re-fetch with
`app_id` → re-diff. If anything still disagrees after the
third, surface a structured failure listing every finding line and do NOT
write the success summary. Never "fix" the diff by editing the PDD's
declared taxonomy to match what was built.
5. **The Learn app is briefed from the SAME declared taxonomy**, so
Learn/Deliver agreement is transitive — two builds that each match the
PDD cannot contradict each other. If the Learn app was briefed from
anything else, that is the same defect one app over: record it as a
residual against `pdd-to-learn-app` rather than reconciling Deliver to
Learn.
6. **`## Build notes` records the CHECK, not just its verdict.** Write
into the summary's `## Build notes` the declared
state values with their labels and step ranges, the source document (or
`none — declared inline in the PDD`), the number of states and steps
compared, and the finding count (`0` on success).
7. **Propagate the corrected taxonomy to the CASE-LIST ENUMS
(dimagi-internal/ace#1688).** Steps 3–4 repair the FORM's option labels
via `edit_field`. They do not touch the case-list columns — and this
step's own trigger (step 1) names a *case-list column* as a surface the
taxonomy reaches. That gap is the whole of ace#1688: on
`spark-facilitator/20260820-0817` the Phase-3 correction landed on the
form itemsets and never on the enums, so the `fcap_community` tile kept
rendering the earlier INVENTED taxonomy while the form offered Spark's
real one. Stored `1` read as `1. Introduction` before the visit and
`1. Planning` during it.
**ACE does not author these enums — the architect does.** The case-list
tool family (`add_case_list_columns`, `update_case_list_column`,
`configure_case_list`) carries a `kind: 'id-mapping'` column whose
`mapping: [{value, label}]` is supplied by whoever calls it, and the
autonomous architect calls it while building the picker. So the enum is
composed from the architect's reading of the brief, independently of the
itemset, and nothing reconciles the two. These atoms are available at
**ACE-direct** (Step 4d already uses the family), which is why the
reconciliation belongs here rather than in an upstream issue.
1. For each state-bearing select repaired above, read the module's case
list — `get_module({app_id, moduleUuid})` off the Step-4a addressing
map — and select every column with `kind: 'id-mapping'` whose `field`
is a case property that select writes.
2. **Derive, do not compare-then-guess.** Build the mapping from the
form's itemset: one `{value, label}` per option, using the option's
own value and its repaired label. Keep the column's existing `field`,
`header`, `sort` and visibility; replace only `mapping`. Where the
tile deliberately shows a SUBSET of the options, keep that subset —
the rule is subset, not equality — but every entry it does keep must
come from the itemset.
3. `update_case_list_column({work_id, request_id, moduleUuid, columnUuid,
column})` with the full column body (the uuid carries over; never
supply one).
4. `save_work` (require `saved: true`), then re-fetch via
`get_module({app_id, …})` and re-assert value-for-value against the
itemset. **Bounded loop, max 3 iterations**, same shape as 3–4. Still
disagreeing after the third → structured failure naming each column
and the values that differ; do NOT write the success summary.
5. Record in the summary's `## Build notes`: columns rewritten, values per column, and the
form each was derived from.
**Never reconcile the other way.** The itemset is the authority — it is
what the worker picks from and what the stored value means. Editing the
form's labels to match a stale enum re-introduces the invented
vocabulary this step exists to remove.
8. **Record every tile value no form writes BY DESIGN
(dimagi-internal/ace#3220).** A state the PDD says is set OUTSIDE the
forms — a Phase 10 close-out bulk case update, an HQ automatic case
update, an integration — has a case-list label and no form writer, so
the released-CCZ gate cannot tell it from a stale enum. For each such
state, and ONLY where the PDD states it (the `entity_state_taxonomy`
row, § Entity Lifecycle, or the build residual this step files for it —
e.g. connect-fi's *"No form sets stage Handed over at close-out; every
adult still in progress on 28 Feb 2027 needs a bulk case update"*), add
an entry to the summary's `externally_written_case_values` frontmatter
(Step 7): `property` (the case property the column renders), `values`
(the stored values, exactly as the tile maps them), `writer` (what sets
them, e.g. `Phase 10 close-out bulk case update on HQ`) and `source`
(the PDD section, or the residual quoted verbatim). Keep the residual
too — it is the human-facing ask that the out-of-form write actually
happens; this entry is its machine-readable twin. **Never add an entry
to clear a gate finding:** a tile value with no PDD statement behind it
is an invented or stale enum, and the fix is to remove it (sub-step 7),
not to declare it. No such state → `externally_written_case_values: []`.
*Enforced downstream:* `app-release-qa § Step 4` check 3 runs
`checkCczCaseListEnumFidelity` over the released CCZ and halts with
`[BLOCKER]` `case-list-enum-drift` if any of this was missed — that gate
is what makes this step falsifiable rather than aspirational
(`lib/ccz-enum-fidelity.ts`, `test/lib/ccz-enum-fidelity.test.ts`).
4n. Derived-chain guard check (a value computed from a subtree the visit
never entered) — runs ACE-DIRECT (ace#1823). Placed immediately before
4m so every STRUCTURAL check still lands ahead of the language layer,
which stays LAST. A repair here edits relevant / calculate
expressions and never label text, so it cannot demote a translation.
**The defect.** A form gates a group on consent and then computes derived
values at form ROOT, where no `relevant` applies. On a vacant / refused /
no-eligible-respondent visit the group is skipped, `count()` over the empty
nodeset returns **0**, and the form submits a complete, confident, wrong
record. Live on `hh-poverty-targeting` (HQ app
`ce668763ad6c4b48ac5f4cd4502f3f8c`, released build), read straight out of
`deliver-latest-release.ccz` → `modules-0/forms-0.xml`:
```
/data/roster relevant="/data/consent_screen/consent = 'yes'" <- gated
/data/member_count = count(/data/roster/is_member[. = 'yes']) <- NOT guarded
/data/hh_size_band = if(/data/member_count <= 3, 'le3', …) <- NOT guarded
/data/size_points = if(/data/hh_size_band = 'le3', 31, …) <- NOT guarded
/data/ppi_score = if(/data/visit_outcome = 'completed', <sum>, '') <- guarded
```
So `member_count = 0`, `hh_size_band = 'le3'`, `size_points = 31` on every
non-payable door. `ppi_score` is correctly blank, which is exactly why this
survives: **nothing looks wrong at the score level.** The corruption is one
layer down, in the band — and the band is the field a band-boundary fraud
control groups on (PDD § Evidence Model, Layer C). On that run it is
**1,072 non-payable doors of 3,794** (28%) landing in the 31-point band by
construction. A worker with more vacant doors looks like a worker
clustering at the boundary; the signal and the artefact are
indistinguishable.
Every gate is blind to it. A `calculate` over an empty nodeset is valid
XForm, so `validate_app` passes, `app-release-qa` passes (it checks counts
and install-time behaviour), the app installs, plays and submits, and the
eval grades against a narrative PDD. Phase 7 blanked the chain in the
synthetic fixture and declared the deviation — a fixture-side workaround.
The app still ships this way, so a real deployment would too.
```ts
import { checkDerivedChainGuards, formatDerivedChainReport }
from '../../lib/derived-chain-guard';
const report = checkDerivedChainGuards(rootFields); // from Step 4a's get_form
```
**Two guard shapes count, because the app that motivated this used the
second:** the field carries its own `relevant`, OR its `calculate` is a
conditional whose CONDITION reads a field outside the gated subtree (the
`ppi_score` shape). A conditional whose condition reads only tainted fields
is **not** a guard — `if(member_count <= 3, 'le3', …)` looks defensive and
faithfully converts a phantom 0 into a phantom band. Taint therefore
PROPAGATES: guarding the leaf is not enough and guarding the final score is
not enough. Every node between the gated subtree and the submitted record
has to be able to say what its value means on a visit that never happened.
1. Run the check on the root field list already fetched in Step 4a.
2. **For each finding, apply the SAME guard the payable path already
uses** — normally the visit-outcome discriminator (`relevant:
"/data/visit_outcome = 'completed'"`, or the inline `if(...)` shape if
the chain already uses one). Re-run to confirm `findings.length === 0`.
Bounded at 3 iterations like 4a–4g.
3. **A finding may be CLEARED BY JUSTIFICATION instead**, and this is a
real case, not an escape hatch: sometimes a zero over an empty nodeset
is exactly right ("units delivered on a refused visit: 0"). The check
does not claim otherwise. It claims the form has to SAY so — record each
cleared finding as a decision row (§ Decisions Log) naming the field
and why its value is meaningful on a skipped visit. Silence is not a
justification.
4. Record `formatDerivedChainReport(...)` in the summary's `## Build
notes` either way.
(Forms with no `relevant`-gated container skip cleanly — `gatedSources` is
empty and there is nothing to find. *Enforced:*
`test/lib/derived-chain-guard.test.ts` +
`test/skills/deliver-l0-loop-integrity.test.ts`.)
4o. Metric terms are SUBMITTED on the paid form (ace#2590) — runs
ACE-DIRECT. Placed after 4n and before 4m: a repair adds a hidden
calculate field and never edits label text, so it cannot demote a
translation. Step 4h checks that what is TAUGHT is collectable; nothing
else checks that what is MEASURED is submitted.
**The defect.** Connect receives the paid form as visits and nothing
else, so a PDD § Success Metrics indicator is computable from visit data
only if every numerator and denominator term rides on that form. A value
held on the CASE (or captured on an unpaid form, such as enrolment) is
invisible to Connect unless the paid form mirrors it as a hidden
`calculate`. On `spark-facilitator/20261001-2208` PDD §8.2 S1 was
"Households represented ÷ enrolled number_of_households"; the Community
Meeting Record READ `#community/number_of_households` inside the validate
of `hh_represented_at_the_meeting` (`. <= #community/number_of_households`)
and never submitted it — none of its nine hidden case-write mirrors carried
it. Phase 7's `semantic-registry-author` had to list S1 under "Not
computable from visit data": the run completed and a partner-owned
indicator vanished from every report. The 20260926-1800 build only had S1
because it happened to submit `enrolled_households`.
1. Enumerate, verbatim, every PDD § Success Metrics row the indicator
layer will compute, and split each into its terms — `numerator` +
`denominator` for a rate, `value` for a count or mean. Resolve EACH term
to where its value lives — this is the judgement, made once, per term:
`{kind:'field', fieldId}` (captured or calculated on the paid form),
`{kind:'case', property, caseType}` (a case property, e.g. one written
at enrolment), or `{kind:'external', reason}` (data visits cannot carry:
a payment ledger, an external survey, a supervisor log). Do not resolve
a term to `external` because mirroring it is inconvenient — `external`
means no visit can carry it.
2. Run the check against the PAID form's fields from Step 4a's `get_form`
(the form whose submission Connect pays — the deliver-unit form):
```ts
import { checkMetricTermsSubmitted, formatMetricTermsReport }
from '../../lib/metric-terms-submitted';
const report = checkMetricTermsSubmitted(terms, paidFormFields);
```
3. **For each `case-not-mirrored` finding, add a hidden field whose
`calculate` is the case reference** (e.g. `enrolled_households =
#community/number_of_households`; a blank guard `if(#community/x = '',
0, #community/x)` also counts) via `add_fields` in the open private work,
`save_work`, re-read the form with `app_id`, and re-run until
`findings.length === 0`. `readsAt` names where the form already READS
the value — that is the proof the value is on the device at submit time.
For `field-absent` / `field-not-a-value`, either the resolution in step 1
was wrong (fix the mapping) or the PDD's captured field is missing (add
it). Bounded at 3 iterations like 4a–4n.
4. **Any term still unsubmitted after the loop, and every `external` term,
is named in the summary as not computable, with the reason** —
`report.notComputable` plus the residual findings, as one decision
row per term (§ Decisions Log — `deliver-metric-not-computable-<slug>`,
with `review_ask: recommended-confirmation` + `confirm_reason`, since
a partner-owned indicator is going unmeasured) and in the
`metric_terms` frontmatter block. A metric that
silently stops being computable is the defect; a named one is a
decision a reviewer can see and answer.
5. `report.status === 'unable'` means the check **did not run** — no terms
were enumerated, or the paid form read is empty. That is NOT a pass:
record `formatMetricTermsReport(report)` with its reason in the
summary's `## Build notes` and fix the input. Record the report in
`## Build notes` either way.
*Enforced:* `test/lib/metric-terms-submitted.test.ts` (the shipped S1 form
as positive control; the same form with `enrolled_households` mirrored as
the negative control) + `test/skills/deliver-l0-loop-integrity.test.ts`.
4m. Language layer — runs ACE-DIRECT, LAST of the 4x steps (ace#1556). Applies only when the PDD names a working language other than English; otherwise skip and say so in the summary.
**Why ACE-direct and not the architect.** The architect's operating prompt
(nova plugin `1.26.0`/`1.27.0`, `skills/autobuild/SKILL.md`) says *"Never
treat your own language fluency as a substitute or bulk-translate
self-generated text through `update_translations`. Only save target text
supplied by the user."* An `/ace:run` supplies no human target strings, so
the architect declines and the layer silently never lands — 207 units
copied, 0 translated, in both targets on `spark-facilitator/20260820-0817`
(ace#1556). ACE is the caller — the "user" in that sentence — so ACE
supplies the target text through the same six atoms on its own Nova MCP
surface. Running here also makes translate-LAST **structural**: every
English-editing step (4a–4k) is already done, so nothing can demote a
translation to `out-of-date` behind you.
Execute `_app-component-library.md § app-language-layer` **ACE's ACE-direct
recipe** verbatim — `get_languages` → `add_language(copyFrom: 'en')` →
page `get_translatable_content` and author real values via
`update_translations` (≤50 units/call, echoing each just-read
`sourceFingerprint`) → `get_languages` again. Read the atoms' live schemas
from Nova's `tools/list`; do not paraphrase them here.
**Gate:** `out-of-date` and `missing` must both be 0 at hand-off. Record
the final per-language coverage counts in the summary's `## Build notes`,
plus one line
stating the translations are ACE-authored (`origin: ai`) and carry
`needs-review` until a speaker of the language reviews them. If the layer
cannot be completed, halt loud with the counts — do NOT write a summary
claiming a language layer the app does not carry. Partial coverage is the
false affordance the issue was filed about: units left `origin: copied`
are English strings wearing the language's name, and a worker cannot tell
them apart from real translations.
(Optional) Inspect the built app via
/nova:show <app_id>to cross-check structure against the PDD before writing the summary.Self-evaluate (LLM-as-Judge):
- Does the app structure match the PDD Deliver spec?
- Is the delivery unit framed correctly for the archetype?
- Are all Connectify fields configured (Deliver Unit, Entity ID)?
- Are verification criteria encoded in form questions?
- Did every field the PDD spelled
select/lookupship as a select with a real option source, and is every remaining gap named (Step 4f)? - Does every screen hold a set the worker can hold in view, with no group
over the ceiling and no
repeatnested in a field-list (Step 4g), and is each survivingwarnjustified in## Build notes? - Does every node feeding
entity_id/entity_nameactually vary per worker and per entity — no hiddencaseWritefake preload (Step 4h), no baretextseparator between two parts (which compiles to subtraction), and no dependency on a followup form reading its own case? - If any consent is sought from the people whose data or images are
captured — spoken or field-gated — does that script carry all six
consent-script-floorelements,confidentialand where-the-data-goes included? - Is every PDD § Success Metrics numerator / denominator term SUBMITTED
on the paid form — captured, or mirrored from the case by a hidden
calculate— and is every term that is not named as not computable, with the reason (Step 4o)? - If the PDD marks an instrument
[FIXED], was every scoring constant diffed against the SOURCE file frominputs-manifest.yaml(not the brief, not the PDD's restatement) on atrustedextraction, with zero mismatches and a clamp that can still fire (Step 4k)? If the step skipped, does## Build notessay why — and was the reason "no instrument is[FIXED]"? An unresolvable[FIXED]source is a HALT, not a skip (ace#1648).
6b. Exit gate — nova-app-lint must exit 0 before you return. Run
scripts/nova-app-lint.ts --app <nova_app_id> --type deliver (invocation
in agents/commcare-setup.md § Step 1.4) on the SAVED app. It checks in
code the standing instructions this skill briefs (app name, the
section/module terminology rule including bound lookup rows, lookup
binds, case-list labels vs form labels, casedb preloads, Connect-marker
floor, starter module). Fix every [BLOCKER] it names, save, re-run; do
not edit for a [WARN] — record it in ## Build notes. Quote its first
line (… PASS|WARN — N blocker(s) …) in ## Build notes. Phase 3 will
not dispatch the eval while it fails, so returning with blockers only
moves the same fix later.
Write the summary to
ACE/<opp-name>/runs/<run-id>/3-commcare/pdd-to-deliver-app_summary.mdwith required frontmatter:--- nova_app_id: <id-returned-by-autobuild> # `/build/`, NOT the legacy `/apps/` route, which 404s (ace#1431). # Built by `novaAppUrl()` in `lib/nova-url.ts` — the single source. nova_app_url: https://commcare.app/build/<id-returned-by-autobuild> archetype: <atomic-visit | longitudinal-visits | focus-group | multi-stage> delivery_unit: <one-line description matching the PDD> # Addressing map — Nova is uuid-addressed (2026-07-31, ace#1132). # COPIED VERBATIM from the get_app response § 4a step 2 already read # (the `[uuid …]` printed on each module/form/field) — NEVER typed # from a pattern, re-derived, or reconstructed from memory (ace#2144). # app-deploy Step 2.5 and app-connect-coverage address off this block # instead of calling get_app, so a wrong uuid here is trusted. nova_uuids: modules: - name: <module name> uuid: <moduleUuid> forms: - name: <form name> uuid: <formUuid> # Only the fields later steps address directly (entity_key, # payable marker, image uploads). Not every field. fields: entity_key: <fieldUuid> # Componentized runs ONLY (Phase 1 products.mode == componentized): the # Phase-1 component ids this Deliver app built forms for, verbatim. # app-deploy copies it into products.apps.deliver.components_built via # buildCommcareProducts (ace#2157). Omit on the synthesized path. components_built: [<component_id>, ...] externally_written_case_values: [] # Step 4l sub-step 8 (ace#3220). # Tile values no form writes BY DESIGN, each # {property, values, writer, source} — e.g. # {property: stage, values: [handed_over], # writer: "Phase 10 close-out bulk case update # on HQ", source: "<PDD residual, verbatim>"}. # app-release-qa check 3 passes ONLY these as # [INFO]; empty list is the usual value. option_source_gaps: [] # Step 4f — one entry per PDD-declared select # that still shipped as free text, with the # table + value column + label column it needs. # Empty list is the expected value. option_register: # Step 4f. One entry per PDD-declared partner # register. Omit the block ONLY when the PDD # declares no register — an absent block on a # triggered field is a BLOCKER at the eval, since # "the check did not run" is indistinguishable from # "it was skipped because it would have failed". # `verified` (ace#1886) is the run's EVIDENCE the # select is actually live: it comes from # verifyLookupBind over a get_field read-back, # never from the write's own response, which # reports `options: []` on a correctly bound field. - field: <field id as the PDD names it> tag: <lookup table tag the PDD declared> table_id: <uuid returned by create_lookup_table> rows: <n> # rows written, from the partner's own source findings: 0 # diffOptionRegister + diffRegisterRows; >0 HALTED verified: true # verifyLookupBind code `ok`; anything else HALTED lookup_bind_audit: # Step 4f step 8 (ace#2143). EVERY lookup-backed # select in the app, whoever bound it — not just # the ones this step bound. Required whenever the # app has at least one; `sites: 0` is the value # for an app with none. A missing block on an app # that HAS one reads as "the sweep did not run". sites: <n> # fields audited via auditLookupBinds failures: 0 # >0 HALTED; quote describeLookupBindAudit lines metric_terms: # Step 4o (ace#2590). Always present when the PDD # has a § Success Metrics table. submitted: <n> # terms the paid form carries (report.submitted) not_computable: # every external term + every residual finding; # [] is the expected value - metric: <PDD id, e.g. S1> term: <the PDD's words> reason: <why visit data cannot carry it> instrument_constants: # Step 4k (ace#1527). Omit the block ONLY when no # instrument is [FIXED]. There is no other skip # reason: an unresolvable [FIXED] source HALTS the # step rather than recording a skip (ace#1648). source_file_id: <file_id from inputs-manifest.yaml> artifact_class: published | derived # ace#2110. `resolution.artifactClass`. `published` # = the publisher's own workbook/PDF. `derived` = an # extraction of it, so the check is real but # UNFALSIFIABLE (an error in the extraction is # reproduced by the build and the diff reads clean). # A reader must be able to tell the two apart # without opening Drive. rows_checked: <n> # rows actually diffed against the source mismatches: 0 # anything above 0 means Step 4k halted ---nova_uuidsis read back, not trusted. Its module/form uuids come from theget_appresponse § 4a step 2 already read — a freshget_app({app_id})if a later step added a module or form — copied character for character, never written from a pattern. Onspark-facilitator/20260906-2233the Deliver map recorded11110000-0000-4000-8000-000000000001for a module the app holds as11111111-0000-4000-8000-000000000001(all four module/form uuids truncated the same way, field uuids correct), andapp-deploy's lint then failedget_formwith "No module with UUID … is in this app" (ace#2144). After writing the summary, callget_form({app_id, moduleUuid, formUuid})on one pair from the block; if it does not resolve, rewrite the block from a freshget_appbefore moving on.Every latitude taken and every ambiguity hit is a decision row — the run's review artifact. Append them with ONE
decisions_append_rowscall immediately after this write (§ Decisions Log): one row per place the build exercised latitude the PDD granted (deliver-latitude-<slug>), one per[FIXED]statement that could not be built exactly as written (deliver-ambiguity-<slug>,OPENwhen unresolved), plus the rows Steps 3–4o direct (stated exemptions, cleared derived-chain findings, metrics not computable from visit data, taught-vs-collectable findings). Cite the PDD section insourceon every row. Each row carriesplain, andcheck_at+correct_looks_likewherever the choice is visible in the app; an open question a person must settle carriesreview_ask: recommended-confirmationconfirm_reason(skills/_decisions-review-fields.md).decisions.yamlis what ace-web renders with a comment box and an answer editor on every row, and an answer there carries into the next run. Onpoverty-graduation/20260908-0510this summary listed four[ACE]latitudes in prose (screen shape, transfer-method capture, derived-chain guards, asset menu) and the run's 66 decision rows included none of them, so no reviewer could see or answer them (ace#2371, ace#2384). The Phase 3 boundary now fails this skill if the summary exists and zero rows carryphase: 3-commcare+skill: pdd-to-deliver-app.
The body ends with a section headed exactly
## Build notesfor the build team's notes that are not decisions: the screen-shape and derived-chain reports, the threshold-coherence checks, the case-read and GPS notes, the consent-floor checklist, the instrument-constants and state-taxonomy check records, theverifyLookupBindverdicts, the metric-terms report and the language-layer coverage counts. WriteNone.rather than dropping the heading. A note that records a CHOICE a reviewer could disagree with belongs in a decision row, not here.State every app check at the scope it is keyed on — in the decision row's
plainand in## Build notesalike. A form constraint or acasedbread is keyed on the case the form is filled against, so describe it as "per community" / "per household", never as enforcing a per-worker limit. Onspark-facilitator/20260926-1800the date check was called the "one-community-meeting-per-day check" and the threshold line read "daily cap 1 vs the one-community-meeting-per-day date check — coherent", which Phase 4 and the run's review record then read as the app enforcing the per-WORKER daily cap. It does not: one worker can record two communities on one day, and only Connect's payment unitmax_dailystops the second being paid. Say which limit a per-worker rule relies on (Connect's), and what the app check adds at its own scope.Notify admin group that Deliver app generation is complete.
Archetypes
The Deliver app's structure depends on the PDD's archetype: field. The
"delivery unit" concept is the most archetype-sensitive part of ACE — get
this wrong and connect-opp-setup will configure the wrong verification
rules.
atomic-visit
Delivery unit = one FLW visit to one beneficiary. The form is the verification artifact: every required field, photo, GPS coordinate. Case management follows the standard create → update → close pattern. The form's fields map 1:1 to Layer A and Layer B of the PDD's Evidence Model.
longitudinal-visits
Delivery unit = one FLW visit to one followed entity, and the entity
is a real case with a life longer than the visit. Structurally this is
CommCare case management as normal — case type, registration form,
follow-up form(s) against a case list — with three requirements the
atomic-visit branch does not carry:
- The case list must show which visit is due. Put the entity's
phase / last-activity / next-due state in the case-list columns (and
in search inputs where the caseload is large). An FLW who has to open
a case to discover the next activity will guess instead.
Those state names are the PARTNER's vocabulary, and this archetype
is the reason they exist at all — a case list showing state means
the app must name every state, and the architect will invent the set
if the brief does not carry it. Emit
_app-component-library.md § entity-state-taxonomy(always, for this archetype), carry the PDD's declared taxonomy into the brief verbatim, and HALT rather than invent when the PDD declares none. Verified at ACE-direct by Step 4l (ace#1564). - Follow-up forms preload the case state the predicate reads. Any longitudinal fact Layer A depends on must be on the submitted form to be enforceable downstream — Connect's form-field rules see the form, not the case. Preloading is what makes the longitudinal predicate expressible at all.
entity_idcarries entity + sequence position, per the PDD'spayment-unit-entity-iddecision — typicallyconcat(<case_id>, '-', <activity_code>). As elsewhere,entity_idis a business key, not the case id: the case id alone pays each entity exactly once, ever.
The failure mode to design against is a Deliver app that is
case-managed and a payment predicate that is not — the app tracks the
entity beautifully while Connect pays for any visit at all
(spark-facilitator/20260813-2126, ace#1462).
focus-group
Delivery unit = one completed FGD session, attested by a 5-field CommCare form submitted at session end. The Deliver app for focus-group is intentionally minimal — it is the payment trigger only, not the content-capture surface and not the artifact-upload surface.
FGD content lives in a Google Doc, not in this app. The gdoc is
written after the session ends — typically hours or days later — and
cannot be linked from the attestation form at submission time. All
qualitative content (per-section themes, verbatim quotes, consensus
grading, post-FGD report, facilitator reflection) lives in the gdoc.
Audio recording (if captured) is out-of-band entirely — it does not
go through CommCare; the facilitator attaches audio to the gdoc as a
Drive attachment or shares it through a separate Drive folder. The
attestation form captures only session-happened evidence + consent
confirmation, and each submission is one payment unit. See
docs/superpowers/specs/2026-05-15-focus-group-archetype-redefinition.md.
App shape (one module, one form):
- Module 1: Session Attestation (case type:
fgd_session). - Form: "Session Attestation" (case-create,
connect.deliver_unitset). One submission = one completed session = one payment trigger.
Required fields on the attestation form (5 fields):
| Field | Kind | Notes |
|---|---|---|
consent_all_participants |
single_select | Required attestation: did every participant consent to participate? Options: yes (consent obtained from all participants) / no (one or more declined / not obtained). Constraint: . = 'yes' — form cannot be submitted without affirmative consent, because there is no payment for an FGD held without consent. |
session_date |
date | When the session was held. Facilitator picks; usually today (submitted at session end) but can be one or two days back if writing it up later. |
venue |
text | Free-text venue description. Hint: include the village/community name + the specific space (e.g. "Kibera, community hall behind the primary school"). |
gps |
geopoint | Captured at the venue. Anchors location verification (Layer A). Captured at form-fill time on the FLW's device — implicitly proves the FLW was AT the venue when they attested. |
photo |
image | A single evidence photo. Hint: an attendance sheet (first names + role + consent marks, NO faces) or a venue photo. Faces only if every participant has actively consented to a face photo. |
That is the complete form. No audio, no participant count, no per-section
fields, no gdoc link, no reflection, no facilitator-name field
(captured implicitly via Connect's FLW identity). Auto-generated
case_name from concat(#user/username, '-', #form/session_date) keeps
the case list legible.
Connect markers:
connect.deliver_unitset on the form.connect.entity_iddefaults toconcat(#user/username, '-', today())— one paid delivery per facilitator per day, the realistic case for 60-90 min sessions + travel. This is already a business key (good — see the §entity_idREQUIRED rule). If any LLO schedules ≥2 sessions/day per facilitator, override to a finer-grained business key, e.g.concat(#user/username, '-', /data/session_date, '-', /data/venue)— NOT#case/case_id(payment-unit-entity-idDecisions Log row).
Coordinator review flow (out-of-band):
Layer A verification happens automatically against form contents
(GPS within an expected radius of the planned venue, photo attached,
consent attested, session_date within an expected fielding window).
Layer B verification is the coordinator reviewing the facilitator's
gdoc, matched to the attestation submission by (FLW identity, session_date, venue) — there is no gdoc_link field, so matching is
operator-driven (coordinator sees the attestation in the FormRepeater
feed, expects a gdoc from that facilitator about that session,
follows up if it doesn't arrive within the gdoc submission window).
Specifically not included:
- No audio upload through CommCare. Audio recording (if captured) is out-of-band entirely. CommCare doesn't carry large audio files for FGDs — they live in Drive or wherever the LLO keeps audio.
- No
gdoc_linkfield. The gdoc doesn't exist when the attestation is submitted. Linkage between attestation and gdoc is coordinator-driven, by(FLW, session_date, venue)match. - No per-section structured summary fields. Qualitative content lives in the gdoc.
- No participant-count / segment / start-end-time fields. These go in the gdoc.
- No facilitator-reflection field. Goes in the gdoc.
- No pre-checklist field. The pre-session preparation is the LLO's responsibility; the consent attestation is the only check that needs to gate payment.
- No pre-session + post-session + reviewer-verification form split. One form, submitted at session end.
- No case management beyond per-session. No case lifecycle, no per-beneficiary cases, no per-participant follow-up.
- No Learn-app-equivalent training surface.
pdd-to-learn-appis a no-op for focus-group archetype.
Brief language for /nova:autobuild: open with "this is a 5-field
session attestation form for an FGD opportunity. The mobile form
captures only proof-of-session-happened (consent, date, venue, GPS,
photo); it is NOT a content-capture form and NOT an artifact-upload
form. Qualitative content lives in a Google Doc written after the
session, out-of-band; audio (if captured) lives in Drive separately.
One submission = one completed session = one Connect deliver_unit
submission = one payment trigger."
multi-stage
Generate one Deliver app per stage that has its own delivery work, branching on each stage's archetype. The two Deliver apps may have completely different structures (e.g., Stage 1 = focus-group session form, Stage 2 = atomic household-visit form).
MCP Tools Used
- Google Drive MCP:
drive_read_file,drive_create_file - ACE decisions MCP:
decisions_append_rows(Step 7 — § Decisions Log) - Nova plugin slash commands:
/nova:autobuild,/nova:show,/nova:list,/nova:edit - Nova MCP tools ACE calls directly (Steps 4a–4l):
get_app,get_module,get_form,search_blueprint(semantic id → uuids in one call),add_case_list_columns,edit_field,configure_connect,update_form,get_lookup_tables,set_field_options_source,create_lookup_table,update_case_list_column, and the private-work lifecyclebegin_work/get_work/save_work/discard_work(voidcraft-labs/commcare-nova#693, 2026-09-27). Every mutation is staged —work_id+request_id, noapp_id— and lands only on asave_workansweringsaved: true; reads takeapp_id(saved) orwork_id(candidate). Seeplaybook/integrations/nova-integration.md § The private-work authoring contract. Nova is uuid-addressed since 2026-07-31 (dimagi-internal/ace#1132) —moduleUuid/formUuid/fieldUuid, never indexes; resolve the whole map with ONEget_app({app_id})at Step 4a and reuse it. The app-level Connect mode is set byconfigure_connect({work_id, request_id, mode, participants}), which is REPLACE-ALL (ace#1133) —update_appno longer carriesconnect_type. Enforced bytest/skills/nova-uuid-addressing.test.ts.
See playbook/integrations/nova-integration.md for plugin status.
Mode Behavior
- Auto: Build via
/nova:autobuild, write summary, notify admin, proceed. - Review: Build, write summary, present summary for review before proceeding.
Dry-Run Behavior
When --dry-run is active:
- Do NOT call
/nova:autobuild(Nova builds are durable side effects). - Write the composed brief and the intended Nova invocation to
comms-log/dry-run-pdd-to-deliver-app.md. - Do not write
app-summaries/deliver-app-summary.md(nonova_app_idyet). - State tracks as
dry-run-success.
Decisions Log
Review fields (schema v6 — REQUIRED). The decisions log is the run's review artifact (the build memo is retired). Every row this skill appends carries plain — one plain-language line for a programme partner who has never seen ACE — and, where the choice is visible somewhere a reviewer can open, check_at + correct_looks_like. An open design question the build could not settle (e.g. no recording path when a whole group refuses the photo) is a row with review_ask: recommended-confirmation + confirm_reason, not only a residual. The atom rejects a partner-facing row without plain, or with jargon in it. Rules, a worked row and the review_ask semantics: skills/_decisions-review-fields.md; field contract: docs/decisions-contract.md.
This skill writes to the per-run ACE/<opp-name>/runs/<run-id>/decisions.yaml
— the register ace-web renders on the public run page, where a reviewer
comments on and answers each row and the answer carries into the next run.
The bar criterion and schema live in skills/idea-to-pdd/SKILL.md § Decisions Log Convention (canonical authority). It owes two kinds of row: one per
latitude taken and per ambiguity hit (REQUIRED, below), and any catalogue row
that meets the bar.
Every latitude and ambiguity is a decision row (REQUIRED)
Every place the build exercised latitude the PDD granted, and every [FIXED]
statement that could not be built exactly as written, is a decisions.yaml
row — and the row is the only record of it; there is no second rendering to
keep in step. These are exactly the calls the register exists for: an [ACE]
latitude is an inferred default, a [FIXED] ambiguity is a conflicting
one. Until ace#2384 this section was only a catalogue "not a required set",
and the app build wrote zero rows — poverty-graduation/20260905-1345 and
20260908-0510 carried 61 and 66 rows, none from Phase 3, while this summary
listed the latitudes in prose. A regression of #399.
| Row field | From the entry |
|---|---|
id |
deliver-latitude-<slug> / deliver-ambiguity-<slug>; slug from the PDD § and the subject, so a re-run of the same PDD re-derives the same id and the atom's idempotent skip holds |
phase, skill |
3-commcare, pdd-to-deliver-app |
question |
what the PDD left open (latitude) or said two ways (ambiguity) at that § |
ai-default |
what ACE chose (latitude) or how it resolved the ambiguity, as a short label, an exact member of options |
options |
that label plus each alternative the build weighed, 1–8 words each |
source |
the PDD § it concerns, naming the document |
evidence_basis |
[ACE] latitude → inferred (stated only when the PDD itself names the value chosen); [FIXED] ambiguity → conflicting |
conflict_signals |
ambiguity only, at least 2 entries: the [FIXED] statement as written, and the reading or constraint that competed with it — each cited |
value_set_by |
ace |
status |
ai-default, always (a caller-asserted human-decided is rejected — ace#2307) |
reasoning |
why ACE chose it; an ambiguity left OPEN begins OPEN —; it ends Spot-check: <where>. |
plain |
REQUIRED — one line for a programme partner: what was chosen, no field ids, no §-references, no ACE jargon |
check_at, correct_looks_like |
where in the app a reviewer sees it, and what is there when it is right — whenever the choice is visible |
review_ask, confirm_reason |
recommended-confirmation + one plain sentence, when a person must settle it before launch (an OPEN ambiguity, a gap with no fix this run) |
Prefer check_at; keep the Spot-check: sentence too. Schema v6 gives
the location its own field (check_at, docs/decisions-contract.md) — write
it directly. Still end reasoning with Spot-check: <app> › <module> › <form> › <field>.: the write boundary fills check_at from that sentence when it is
absent, and older ace-web summaries built each decision from a fixed set of
keys (apps/opps/summary.py on ace-web main, c20ef34: id, phase, skill,
question, ai-default, override, options, source, status, reasoning,
override_reasoning, evidence_basis, conflict_signals) and dropped anything
else, so reasoning is the copy every renderer shows.
The boundary checks it. verify_phase_artifacts(phase='commcare')
returns a decisions report (lib/build-phase-decisions.ts). If
3-commcare/pdd-to-deliver-app_summary.md exists while zero rows carry
phase: 3-commcare and skill: pdd-to-deliver-app, the Phase 3 boundary
fails — a build that took no latitude still owes its catalogue rows (below).
A live partner-facing row with no plain is a warning. app-test-cases rows
in the same phase do not count.
Worked example (the first entry is the real 20260908-0510 latitude; the
second is illustrative):
decisions_append_rows({
runFolderId: <run-folder file_id>,
opportunity: <opp-slug>,
run_id: <run-id>,
rows: [
{
id: "deliver-latitude-transfer-method-capture",
phase: "3-commcare",
skill: "pdd-to-deliver-app",
question: "C6 §12 Q1 leaves open whether one flow serves every transfer method or method is a build variant — which did the build ship?",
"ai-default": "one flow, method-gated branches",
options: ["one flow, method-gated branches", "one form per transfer method"],
source: "C6 PDD §12 Q1 [ACE]",
status: "ai-default",
evidence_basis: "inferred",
plain: "One form covers every transfer method, showing only the questions for the method chosen.",
check_at: "Deliver app › Transfer record › transfer method",
correct_looks_like: "Choosing a method shows only that method's questions.",
value_set_by: "ace",
reasoning: "One flow reads the method off the household case and relevance-gates three artifact branches. An assumption, not a resolution. Spot-check: Deliver app › Consumption support distribution › consumption_dist › transfer_method."
},
{
id: "deliver-ambiguity-entity-id-grain",
phase: "3-commcare",
skill: "pdd-to-deliver-app",
question: "Targeting §3 and C5 §2 name different payment grains — which does the Deliver entity_id key on?",
"ai-default": "household",
options: ["household", "household member"],
source: "Targeting PDD §3 and C5 PDD §2 [FIXED]",
status: "ai-default",
evidence_basis: "conflicting",
plain: "Payment is counted per household, so one household is paid at most once.",
check_at: "Deliver app › Asset delivery › payment key",
correct_looks_like: "Two deliveries to members of the same household share one payment key.",
review_ask: "recommended-confirmation",
confirm_reason: "Two parts of the design name different payment grains; the build pays per household until the design's author decides.",
conflict_signals: [
"Targeting PDD §3 [FIXED]: one payable survey per household",
"C5 PDD §2 [FIXED]: an asset is delivered to a named member"
],
value_set_by: "ace",
reasoning: "OPEN — both are [FIXED]; keyed on household so the Targeting cap holds, flagged for the design author. Spot-check: Deliver app › Asset delivery › asset_delivery › entity_id."
}
]
})
The rejection is atomic — one row whose ai-default is not in its options
costs the whole batch — so check each row before sending. If the atom is
unbound this session, hand-write per skills/idea-to-pdd/SKILL.md § Schema and write semantics (top-level decisions:, not rows:).
Common load-bearing decisions for Phase 3
A catalogue of decisions that commonly qualify under the bar — a working template, not a required set. Emit whichever meet the bar, in the same call as the latitude and ambiguity rows.
| ID | Question | Map to surface |
|---|---|---|
deliver-unit-count |
How many distinct deliver units (modules × forms) does the Deliver app expose? | PDD Deliver App Specification numeric |
one-form-per-module-workaround |
Are we one-form-per-module to dodge Nova's CCZ marker bug? | pdd-to-deliver-app-eval connect-marker-coverage dimension; CLAUDE.md gotcha |
multimedia-coverage-strategy |
What multimedia (text vs voice prompts vs both) does the Deliver app surface? | app-multimedia-coverage skill output; PDD multimedia note |
option-source-binding |
For each PDD-declared select/lookup field, where did its options come from — a Project data table, an inline enumeration, or a named gap? | Step 4f option_source_gaps; pdd-to-deliver-app-eval § Capture fitness |
The Phase 3 boundary fence enforces these rows
(agents/orchestrator-reference.md § Phase Write-Back Contract § Decisions log clause); the renderer (skills/decisions-render) regenerates the gdoc
at end of every phase.
Each row this skill writes uses phase: 3-commcare and
skill: pdd-to-deliver-app.
Change log
| Date | Change | Author |
|---|---|---|
| 2026-10-19 | Step 4l sub-step 8 — tile values no form writes BY DESIGN are recorded, not dropped (ace#3220). connect-fi's Deliver app labels stage = handed_over "Handed over at close-out"; no form writes it because the PDD sets it by the Phase 10 close-out bulk case update, so app-release-qa check 3 graded a case-list-enum-drift BLOCKER on every re-release. Operator decision (2026-10-19): keep the label. The summary now carries externally_written_case_values ({property, values, writer, source}, each tied to a PDD statement), which checkCczCaseListEnumFidelity accepts as [INFO] for exactly those values on exactly that property; anything undeclared still fails. Enforced: test/lib/ccz-enum-fidelity.test.ts § declared out-of-form writers. |
ACE team |
| 2026-10-16 | Step 7 writes a nova_uuids block, copied from § 4a step 2's get_app and read back (ace#2144). app-deploy Step 2.5 and app-connect-coverage both read the Deliver summary's nova_uuids: instead of calling get_app, but Step 7 never specified one (Learn's Step 7 did). On spark-facilitator/20260906-2233 the map that was written had all four module/form uuids pattern-truncated (11110000-… for 11111111-…) and the lint's get_form failed. The block is now part of the required frontmatter, sourced verbatim from the get_app response, with a one-pair get_form read-back after the write. Enforced: test/skills/deliver-summary-nova-uuids.test.ts. |
ACE team |
| 2026-10-16 | § entity_id: a date-only key component is a per-worker-per-day grain (ace#2935). The quoted-separator example used #form/visit_date, which keys per worker per day; it now uses a time-bearing timestamp and says so, and names the bare-now() trap (a hidden field renders it date-only). |
ACE team |
| 2026-10-16 | Step 4f: a lookup's value column must be whitespace-free (ace#2984). On kaduna-graduation/20261015-1411 kgrad_services had no code-shaped column, three selects saved the display name ("Placeholder: services directory entry 1 …"), every ACE gate passed (the values were unique), and Nova's upload_app_to_hq preflight refused the Deliver app: "Saved values can't contain whitespace." Same class as ace#2143, Nova's second rule. verifyLookupBind (and so auditLookupBinds) now returns whitespace-values; authored tables carry a slug code column as the saved value. Enforced: test/lib/option-register.test.ts. |
ACE team |
| 2026-10-16 | Exit gate nova-app-lint (Step 6b); Step 4f step 8 runs through it. The standing instructions this skill briefs are now checked in code right after the build (scripts/nova-app-lint.ts, lib/nova-app-lint.ts) instead of only by the eval: on smallholder-seasonal/20261015-1402 the Deliver eval took three passes (~42 min) to clear "module" in 7 bound lookup rows (ace#2976) and a partial edit_lookup_rows update that wiped 8 rows (ace#2978); on kaduna-graduation/20261015-1411 whitespace in a bound value column was first refused by Nova's upload preflight (ace#2984) and case-list labels drifting from form labels were first caught at app-release-qa Step 2.8. verifyLookupBind gains whitespace-values, blank-values and blank-labels. Enforced: test/lib/nova-app-lint.test.ts (real blueprints), test/lib/option-register.test.ts, test/agents/phase3-lint-gate.test.ts. |
ACE team |
| 2026-10-12 | Step 4k prefers the publisher's file over a derived inputs[] extraction (ace#2852). resolveInstrumentSource returned the inputs[] match before it looked at manifest-recorded subfolders, even when that match classed derived — so on smallholder-seasonal/20261012-1549 the strict path resolved INSTRUMENT — Nigeria 2018 PPI (… extracted verbatim).md while the publisher's nigeria-ppi-2020-scorecard-and-lookup-table.xlsx sat in the recorded subfolder official-nigeria-2018-ppi (povertyindex.org). The ace#1648 order (inputs[] first) predates the ace#2110 classifier, which disclosed a derived source but never re-ordered the search. Step 2 now walks the subfolders when the inputs[] match is derived; the helper resolves a single published subfolder file over it (naming the passed-over entry in its memo), halts on more than one, and keeps the derived entry with its caveat only when nothing published exists. Enforced: test/lib/instrument-constants.test.ts (real layout and ids from that run). |
ACE team |
| 2026-10-03 | Build memo retired — decisions.yaml is the review artifact (schema v6). Every [ACE] latitude, [FIXED] ambiguity, stated exemption and not-computable metric this skill used to tabulate under the summary's ## Build memo is now a decision row only (plain, check_at/correct_looks_like, review_ask + confirm_reason for open questions), the non-decision check records move to the summary's ## Build notes, and the Phase 3 boundary fails a written summary with zero rows under skill: pdd-to-deliver-app. Contract: docs/decisions-contract.md. |
ACE team |
| 2026-10-01 | New Step 4o — every PDD § Success Metrics term is SUBMITTED on the paid form (ace#2590). On spark-facilitator/20261001-2208 the Community Meeting Record read #community/number_of_households inside a validate and never submitted it, so PDD §8.2 S1 (households represented ÷ enrolled households) was not computable in Connect and Phase 7 listed it under "Not computable from visit data" — the run completed and a partner-owned indicator silently vanished. 4h covered taught-vs-collectable; nothing covered measured-vs-submitted. 4o resolves each metric term to a paid-form field, a case property, or an external source with a reason, runs checkMetricTermsSubmitted (lib/metric-terms-submitted.ts) over the paid form, mirrors any case-held term as a hidden calculate, and names every term still unsubmitted as not computable in the summary (metric_terms frontmatter + build memo). Enforced: test/lib/metric-terms-submitted.test.ts + test/skills/deliver-l0-loop-integrity.test.ts. |
ACE team |
| 2026-10-01 | Step 7: every app check in the build memo is stated at the scope it is keyed on (build-memo-eval, spark-facilitator/20260926-1800). The Deliver memo called the per-community date check the "one-community-meeting-per-day check" and judged it "coherent" with the per-worker daily cap of 1; Phase 4 and the programme memo then credited the app with enforcing that cap, which only Connect's payment unit max_daily does. Paired with connect-opp-setup Step 8's scope paragraph and skills/build-memo's --check. |
ACE team |
| 2026-09-26 | Step 4j sub-step 6 no longer hard-codes the counter timing (ace#2515). The snippet hard-coded a pre-increment timing into its payableCapacity call, which reads a correct min(<casedb count> + 1, cap) clamp as capacity cap + 1 and HALTs it — then "repairs" it into an under-pay. Observed on spark-facilitator/20260926-1413 (min(#form/step_info/prior_index + 1, 3), correct, would have halted). It now calls checkBuiltClampCapacity, which resolves the timing via classifyCounterTiming over the get_field read-backs (Nova #form/ and #<case_type>/ spellings accepted — captured read-back spells the case read #community/…) and returns unable rather than assuming one. Enforced: test/lib/payable-cap-build-time.test.ts (the repro as positive control; min(<casedb>, cap) and an under-paying min(<casedb> + 1, cap - 1) as negative controls; a skill-text guard against a literal timing), fed from the captured get_field read-back test/fixtures/nova/capped-index-readback-spark-20260926-1413.json. |
ACE team |
| 2026-09-25 | The case-UPDATE entity_id rule now SANCTIONS the case read it used to forbid (ace#2199). The REQUIRED paragraph still told the architect that a followup case-ref could never be built (citing voidcraft-labs/commcare-nova#458, closed COMPLETED 2026-08-15) and steered followup forms to concat(username, <date>) — the atomic-visit grain, which cannot express a per-entity cap and is the ace#1462 failure for a longitudinal-visits design. The paragraph's own re-open condition ("re-verify against a live compiled CCZ") is now met: on spark-facilitator/20260925-1536 the Deliver followup form was briefed with #case/ reads and the released build 5c1eef4323224550b1c36a7da9521e71 compiles entity_key as a live calculate over instance('casedb') (@case_id, pilot_fcap_step). The rule now says: read the case with #case/<property> in a hidden calculate; caseWrite + literal default is still not a read (ace#1224); a visible case-bound field pre-fills the previous answer (ace#2006); never fall back to the username grain for a longitudinal design. Enforced: test/skills/pdd-must-not-assert-mechanisms.test.ts (inverted to require the read and forbid the retired closure). |
ACE team |
| 2026-09-17 | Step 4j gains sub-step 6 — capped-index arithmetic (ace#2148). When a per-entity cap rides in entity_id as a clamped counter, the clamp constant is NOT the cap: a casedb read is the state BEFORE this submission, so min(<casedb count>, N) admits N + 1 distinct keys and the (N+1)-th mints an index that has never existed, which Connect pays. spark-facilitator/20260906-2233 shipped a cap of 3 binding at 4 — 28 payable events against a declared total_cap_per_flw of 21 — with validate_app, compile_app and make_build all green, the CCZ structurally perfect, and the app internally consistent with its own wrong key; is_payable was correctly 0 on the fourth meeting and made no difference, because Connect never reads it. Nothing on this path re-derived the arithmetic, so it surfaced only in pdd-to-deliver-app-eval, one Nova build later. The step deliberately does NOT compare the clamp to the cap — both correct spellings are live in this same opportunity five weeks apart and share no constant (min(<casedb count> + 1, 3) vs if(pcts >= 3, 2, pcts)) — it traces the first cap + 1 submissions, mechanically via lib/payable-cap-arithmetic.ts. Paired with _app-component-library § payability-scoped-key CAPPED INDEX and the released-form backstop in app-release-qa § Step 4. Enforced: test/lib/payable-cap-arithmetic.test.ts + test/skills/payable-cap-wiring.test.ts. |
ACE team |
| 2026-09-17 | Step 4f gains step 8: EVERY lookup-backed select in the app is audited, not just the ones 4f bound (ace#2143). On spark-facilitator/20260906-2233 app-deploy could not upload the Deliver app — Nova's upload_app_to_hq preflight refused it: "A lookup-powered choice list uses activity_id for its saved values, but malawi_activities repeats the same value in several rows." The table carried other on seven rows, one per FCAP step: unique WITHIN each filter partition (step_id = step, so a worker never sees two at once) and not unique across the table, which is what Nova requires. ACE owned the uniqueness rule already — diffOptionRegister has carried it since ace#1621 — but only on the PARTNER-REGISTER path, where the PDD names a register file in inputs/. malawi_activities was authored by the build itself, so that path never fired; and 4f's per-field verify was unreachable too, because steps 1–7 only ever touch a field that FAILED an assertion and this one shipped as a correctly-shaped single_select from the start. Every ACE artifact recorded it as "BOUND and read-back-verified" and Nova's preflight was the first thing in the run that noticed — after a full Nova build, media coverage and two translation layers. The fix is scope, not a second copy of the check. Nova's preflight sweeps every lookup-powered choice list in the app, so ACE's does too: auditLookupBinds over one site per bound field, run unconditionally, including when degraded is empty and when no register is declared. verifyLookupBind now takes the bound table's ROWS as a required argument (an optional one is a check a caller skips by forgetting) and refuses duplicate-values, empty-table, no-rows-read-back and partial-rows-read-back — the last because get_lookup_table_rows pages at 100 rows and a duplicate on an unread page is invisible. It also splits bindLanded out of verified, so scripts/probe-nova-fixtures.ts keeps reporting on Nova's BINDING capability and never reads a table-content finding as an upstream regression. Enforced: test/lib/option-register.test.ts (the seven-row other repro, blank-value collision, absent/partial/empty rows, and a whole-app sweep whose only failure is an architect-built site the run never bound). |
ACE team |
| 2026-09-11 | Every build-memo [ACE] latitude and [FIXED] ambiguity is also a decisions.yaml row (ace#2384, regression of #399). § Decisions Log was a catalogue "not a required set" and the app build wrote none — 61 and 66 rows on the two poverty-graduation runs, none from Phase 3, while Step 7's memo listed four latitudes in prose. The rows derive from the same entry list as the memo tables ("one source, two renderings"); the spot-check location goes in reasoning because ace-web's summary drops unknown keys. The Phase 3 boundary now fails a memo with entries and no rows under this skill's tag. Enforced: lib/build-phase-decisions.ts via verify_phase_artifacts, test/lib/build-phase-decisions.test.ts, test/skills/build-phase-decision-rows.test.ts. |
ACE team |
| 2026-09-11 | Step 7 names the Deliver build-memo section ## Build memo, with [ACE] latitudes taken and [FIXED] ambiguities hit sub-tables (ace#2371). Steps 3–4n direct notes "into the build memo" throughout, but the memo had no fixed home: on poverty-graduation/20260908-0510 it existed as a section headed "Deliver app — build memo" inside this summary, not named as a memo and never linked to a reviewer. skills/build-memo now collates this section into the run's programme memo at the end of Phase 4; the section content itself is unchanged. |
ACE team |
| 2026-09-06 | Step 4k records WHAT it diffed against — a derived extraction is no longer indistinguishable from the published source (ace#2110). 4k's premise is that its oracle is UPSTREAM of ACE; its own text says "read the SOURCE, never the Nova brief and never the PDD's restatement: both are model-authored, and one of them is the artifact this step exists to test." It named two model-authored intermediates and was blind to a third — an EXTRACTION of the workbook, published into inputs/ as the instrument. resolveInstrumentSource proceeded on the mere existence of a manifest entry, with no inspection of mime type, name or provenance, so on poverty-graduation/20260905-1345 the check resolved a text/markdown "(official, extracted verbatim)" file, diffed the build against it, and reported mismatches: 0 — a fidelity check that compared ACE to ACE, while the publisher's workbook sat in a DIFFERENT opportunity's inputs (hh-poverty-targeting, folder official-nigeria-ppi-2020 (povertyindex.org)). Sibling of #1648 and its exact inverse: that one is the unresolvable branch taking a silent skip, this is the resolvable-but-wrong-artifact branch where no branch fires and the run reports green. Disclosure, not a gate — a derived source still PROCEEDS, because on that run it was the only instrument artifact in the frozen inputs and halting would block a build over a file that is very likely correct. What changes is what the run may CLAIM: classifyInstrumentArtifact ties go to derived (under-claiming costs a memo line; over-claiming reports a published-source check that never happened), the memo carries the caveat verbatim, and Step 7 gains artifact_class. The point is that a derived check is real but unfalsifiable — an error in the extraction is reproduced faithfully by the build and the diff still reads clean. Enforced: test/lib/instrument-constants.test.ts (positive control is the real poverty-graduation entry; negative controls cover a derivation pasted into a spreadsheet, an unknown container, and a published PDF). |
ACE team |
| 2026-09-28 | ACE-direct edits move into Nova private work (voidcraft-labs/commcare-nova#693, deployed 2026-09-27). Every mutation in Steps 4a–4l now takes work_id + request_id (no app_id) and is staged (saved: false) until save_work; a stale/refused save is returned as data, so the skill requires saved: true, saves before every read-back, and reads back with app_id. Step 4f: create_lookup_table commits to the Project immediately but the bind is staged — it is now saved before verifyLookupBind reads it back, so an unsaved bind can no longer pass as verified while HQ receives the unbound field. Step 4 treats an architect report that names a work_id with pending changes as an incomplete build. no-starter-module premise corrected (new apps start empty). Contract: playbook/integrations/nova-integration.md § The private-work authoring contract. |
ACE team |
| 2026-09-06 | Step 4f's partner-register handoff is RETIRED — ACE builds, binds and PROVES the register (ace#1886). voidcraft-labs/commcare-nova#545 closed COMPLETED 2026-09-02 and scripts/probe-nova-fixtures.ts returned both on 2026-09-06: a select accepts a {kind:'lookup'} options source and get_field reads it back. All three routes were confirmed live — add_fields optionsSource, set_field_options_source on an existing select, and edit_field converting a text field (set_field_options_source refuses a text field outright, so the conversion is not optional). So 4f now extracts, creates, populates AND binds, and renderRegisterCsv is deleted along with the operator step it existed for. The halt is narrowed, not dropped: it still fires on an undeclared register (Phase-1 gap), an unreadable declared source, any diffOptionRegister finding, and — new — a bind that does not VERIFY. That last one is the point. add_fields answers a correctly bound lookup field with "options": [] and no mention of the source, so the write response cannot distinguish a landed bind from a missing one in either direction; only a get_field read-back can, via verifyLookupBind. An unverified bind is the ace#1621 defect wearing a better disguise — the select renders empty to a worker while every ACE artifact reports the register shipped. Enforced: test/lib/option-register.test.ts (verifyLookupBind, positive + four negative controls), test/scripts/nova-fixtures-probe.test.ts. |
ACE team |
| 2026-09-02 | New Step 4n — derived-chain guard check (ace#1823). The released hh-poverty-targeting Deliver form guards ONE node of its derived PPI chain and leaves twelve unguarded at form root. /data/roster is gated on consent, so on a vacant / refused / no-eligible-respondent visit count() over the empty nodeset returns 0 and the form submits member_count = 0, hh_size_band = 'le3', size_points = 31 — the 31-point band, by construction, on 1,072 non-payable doors of 3,794 (28%), on the exact field the PDD's Layer-C band-boundary fraud control groups on. ppi_score IS guarded (if(visit_outcome = 'completed', …)), which is why it survived: nothing looks wrong at the score level and the corruption sits one layer down. A calculate over an empty nodeset is valid XForm, so validate_app, app-release-qa, install, play and submit all pass. Phase 7 blanked the chain in the fixture and declared the deviation — the app still ships this way, so a real deployment would too. 4n runs lib/derived-chain-guard.ts over the Step-4a field list: taint PROPAGATES along the chain (guarding the leaf or the final score is not enough), and a conditional whose TEST reads only tainted fields is not a guard — if(member_count <= 3, 'le3', …) is the corruption wearing an if(). A finding clears by applying the payable path's own discriminator OR by a recorded justification, because a zero over an empty nodeset is sometimes exactly right; what the check forbids is silence. Placed before 4m so every structural check stays ahead of the language layer. Enforced: test/lib/derived-chain-guard.test.ts (negative control: a naive detector that ignores the inline-guard shape fails 3 assertions, incl. flagging the correct ppi_score) + test/skills/deliver-l0-loop-integrity.test.ts. |
ACE team |
| 2026-08-27 | Step 4l gains sub-step 7 — the corrected taxonomy propagates to the CASE-LIST ENUMS (ace#1688). 4l steps 3-4 repair the FORM's option labels via edit_field and stop there, while 4l's own trigger (step 1) names a case-list column as a surface the taxonomy reaches. On spark-facilitator/20260820-0817 the Phase-3 FCAP correction landed on the form itemsets and never on the enums, so the fcap_community tile rendered the earlier ACE-invented taxonomy while the form offered Spark's real one — stored 1 read as 1. Introduction before the visit and 1. Planning during it, off by one on the surface the Learn app explicitly teaches the worker to read (m3_start, quiz q9). ACE does not author these enums — the autonomous architect does, via add_case_list_columns / configure_case_list's kind: 'id-mapping' column, whose mapping the caller supplies; it composes them from the brief independently of the itemset and nothing reconciles the two. Those atoms ARE available ACE-direct (Step 4d already uses the family), so the reconciliation lands here rather than as an upstream Nova issue: derive mapping from the itemset, update_case_list_column, re-assert, bounded 3-iteration loop. A SUBSET is allowed (a tile may deliberately label fewer options); reconciling the other way is forbidden — the itemset is the authority. Paired with the downstream gate that makes it falsifiable rather than aspirational: app-release-qa § Step 4 check 3 halts with [BLOCKER] case-list-enum-drift. Enforced: lib/ccz-enum-fidelity.ts + test/lib/ccz-enum-fidelity.test.ts, whose negative control is the shipped drift itself and must FAIL. |
ACE team |
| 2026-08-26 | Step 4k's skip is split, and its source is resolvable through the manifest's own folder ids (ace#1648). 4k's trigger ANDed "the PDD marks an instrument [FIXED]" with "inputs-manifest.yaml carries a source file for it" into ONE silent skip, so nothing to check and the thing I must check is unreachable were indistinguishable and both reported green. They were not equally rare: inputs[] records direct child FILES only, so a published instrument bundle sitting in a SUBFOLDER of inputs/ — the natural shape for a vendor download — always took the second branch. On hh-poverty-targeting/20260824-1404 the workbook sat in official-nigeria-ppi-2020 (povertyindex.org)/ and none of the five inputs[] entries was it, so a 4k run following its documented path checks nothing. A skip that disables a correctness check is worse than one that degrades an output, because the run still says green. Two changes: step 2 may now resolve through ids the manifest ALREADY records (subfolders_not_listed[].folder_id, source_folder_id) by walking them ONE level with drive_list_folder — walking a recorded id is not guessing, composing a path by name still is and is still forbidden — and orchestrator Step 5c now MANDATES recording those ids. Step 1's trigger is split: no [FIXED] instrument → skip cleanly; a [FIXED] instrument whose source does not resolve → HALT, never a skip. The decision is delegated to resolveInstrumentSource in lib/instrument-constants.ts so it is unit-tested rather than prose-only. Enforced: test/skills/instrument-source-resolution.test.ts (5 assertions red against the pre-fix text), test/lib/instrument-constants.test.ts, test/mcp/gdrive/generate-inputs-manifest.test.ts. |
ACE team |
| 2026-08-24 | Step 4f gains a partner-register halt (ace#1621). 4f's halt was scoped to payment correctness — a still-degraded select halts only when it feeds_entity_id on a PAYABLE deliver unit — so a field that fails neither test recorded an option_source_gaps entry and proceeded. That is right for a genuinely unknowable set and wrong for a register that EXISTS: on spark-facilitator/20260820-0817 the meeting-activity repeat shipped 11 ACE-authored placeholders identical on all 24 FCAP steps while Spark's own 78-activity register sat in the run's inputs/, and the recorded gap deferred the catch to an operator reading the residual days later. When the PDD declares <field> from <tag> [source: …] [filtered by …], an inline invented option list is now a HALT whatever the payability status, and is never dischargeable as a named gap; both inline rungs of the step-5 escape ladder are withdrawn for such a field. Mechanical via lib/option-register.ts (parseRegisterDeclaration + diffOptionRegister), sourcing rows from the partner's .ccz fixture XML in preference to a prose guide because a production CCZ carries the REAL value codes the partner's M&E joins on. Where ACE cannot finish, the terminal behaviour is extract → build the table → halt naming the remaining operator step. (Both reasons recorded for this halt have since been overtaken. The 2026-08-24 reason — a missing create atom — was superseded 2026-09-01, when create_lookup_table proved to ship columns and rows atomically. The 2026-09-01 replacement reason — a refused BINDING, voidcraft-labs/commcare-nova#545 — was itself superseded on 2026-09-06, when that issue closed COMPLETED and the bind was accepted and read back live. Neither is a current constraint; see the 2026-09-06 row.) Paired with _app-component-library § partner-option-register and the eval's option_register_fidelity hard-gate. Enforced: test/lib/option-register.test.ts. |
ACE team |
| 2026-08-23 | New Step 4l — entity state-taxonomy fidelity (ace#1564). The followed entity's state model lived only as PROSE in the PDD's § Entity Lifecycle, and nothing in Step 3's brief-composition checklist asked for it — while longitudinal-visits REQUIRES a case list showing state, so the architect must name every state and invents the set when the brief carries none. On spark-facilitator/20260820-0817 the PDD's 1 = Planning (steps 1–14) … 4 = Transition (steps 23–24), sourced from Spark's own published FCAP guide sitting in the run's inputs/, shipped as four invented labels over a different partition, with all 24 step names invented too. Learn then teaches one mapping while Deliver offers another, and the invented words reach real workers and the partner. Step 3 now emits _app-component-library § entity-state-taxonomy (always for this archetype) and parses program_parameters.entity_state_taxonomy with parseStateTaxonomy BEFORE briefing — declared: false or non-empty problems is a HALT with a Phase-1 finding, never a licence to invent; where the row names a source document the brief is composed from THAT file out of inputs/. 4l then diffs the built option set with diffStateTaxonomy: any invented, dropped, relabelled or re-partitioned state is a HALT with a bounded 3-iteration repair loop, not a warn. Deliberately ships NO canonical vocabulary — hard-coding one would impose ACE's words on every partner, the mirror image of the defect. Paired with the eval's entity_state_fidelity hard-gate. Enforced: test/lib/entity-state-taxonomy.test.ts + test/skills/entity-state-taxonomy-component.test.ts + test/skills/deliver-l0-loop-integrity.test.ts. The language layer moved 4l → 4m in the same change: it must stay LAST of the 4x steps because every English-editing step has to precede it, and 4l's repair loop calls edit_field on option labels — running it after the layer would demote those translations to out-of-date. Pointers updated in _app-component-library § app-language-layer, both -eval change logs, and test/skills/app-language-layer.test.ts. |
ACE team |
| 2026-08-20 | New Step 4k — fixed-instrument constant fidelity (ace#1527). Nothing on this path opened the [FIXED] source instrument in inputs/ and diffed it, so on hh-poverty-targeting/20260819-1435 the digitised Nigeria PPI 2020 shipped with 9 of 17 point values wrong and all 101 poverty-likelihood values invented — and every gate passed it, because each one is structurally blind to a constant's VALUE (validate_app checks structure, the eval grades against a narrative PDD, app-release-qa checks counts and install-time behaviour, and the architect transcribes from a model-authored brief). 4k resolves the source file from inputs-manifest.yaml, fetches it with drive_download_binary + writeToPath, and runs lib/instrument-constants.ts: assertExtractionTrusted FIRST (endpoints + strict monotonicity + row count — the first repair-round extraction produced score 4 -> 79.0 from an undecoded t="s" shared-string index), then diffScoringConstants and compareMaxScore over the built literals read via get_field. Any mismatch, or a clampDead verdict, is a HALT with a bounded 3-iteration repair loop, not a warn — a built max of 96 against an official 102 made the PDD's min(ppi_score, 100) clamp dead code, which is how the instrument stayed internally consistent with its own wrong numbers. Paired with _app-component-library § fixed-instrument-transcription and the eval's fixed_instrument_fidelity hard-gate. Enforced: test/lib/instrument-constants.test.ts + test/skills/deliver-l0-loop-integrity.test.ts. |
ACE team |