connect-opp-setup
Create and configure a Connect opportunity from a PDD: shell, verification flags, payment units, test-user invite. Needs an approved PDD, a program, and released apps.
SKILL.mdskills/connect-opp-setup/SKILL.md
GitHubConnect Opportunity Setup
Run workspace: when the dispatch carries a run_workspace: block, its values replace the .env ones this skill names — ACE_HQ_DOMAIN → run_workspace.hq_domain, ${ACE_HQ_API_KEY} → run_workspace.hq_api_key (minted here, per run, for a partner space), the preflight connect_orgs → run_workspace.connect_orgs — and a partner workspace never falls back to .env. Contract: skills/_run-workspace.md.
Create and fully configure a Connect managed opportunity on the program
owned by the PM org — the configured PM org (connect_orgs.pm_org from
/ace:doctor --preflight, passed in by the connect-setup dispatch) for a
program this run created, or — when the program was reused — the org recorded
in its URL (opp.yaml.connect.program.url, /a/<org>/program/<id>/), which
stays authoritative. When connect_orgs.nm_org is configured the
opportunity is HELD by that network-manager org (the real PM→NM flow, Step
3); when it is not, the PM org holds it (legacy self-managed). Never type an
org slug here (playbook/integrations/connect-api.md § Which Connect orgs ACE acts in).
Inputs
| Parameter | Used for | Resolver |
|---|---|---|
pdd |
archetype + Evidence Model (Layer A → verification flags), payment plan, program_parameters.learn_passing_score |
explicit arg (a Drive file id); default: 1-design/idea-to-pdd.md |
program_id |
program UUID (the opportunity is scoped to it) | explicit arg; default: 4-connect/connect-program-setup.md; else opp.yaml.connect.program.id |
pm_org / holding_org |
the program's org (every PM-only page) and the org the opportunity is created HELD BY | explicit arg; default: the dispatch's connect_orgs block (lib/connect-orgs.ts phase4Orgs(); see Step 3) |
hq_server, hq_domain, learn_app_id, deliver_app_id |
the released apps the opportunity wires to | explicit arg; default: 3-commcare/app-deploy_summary.md |
component_pdds (componentized only) |
the component PDDs, read for their verification rules and payment terms. Step 8a's rule rows need every rule in the PDD set, and on a componentized programme they live in the component PDDs (e.g. a Targeting PDD's duplicates rule), not the overview; Step 6's payment-unit set comes from each component's payment terms (§ Archetypes › Componentized programmes, ace#2163) | explicit arg; default: run_state …idea-to-design.products.components[].pdd_file_id + .program_level[].file_id |
deliver_app_summary |
Step 8a's rule rows: the CCZ-side enforcement a rule relies on when Connect cannot carry it | explicit arg; default: 3-commcare/pdd-to-deliver-app_summary.md |
Creating an opportunity is a build step: the full Process below runs inside
/ace:run Phase 4. Its configuration steps also run on an opportunity that
already exists, with explicit ids and no run_state.yaml. See § Operations
on an existing opportunity.
Phase folder anchor
The connect-setup agent passes a phaseFolderId (the 4-connect folder ID,
anchored to the run folder per agents/orchestrator-reference.md § Per-Phase Folder Lifecycle). Every drive_create_file write in this skill MUST set
parentFolderId = phaseFolderId — drive_create_file's parentFolderId is
required and must be a folder ID, never a path string. Writing by path-string
alone makes the artifact land outside 4-connect and fail
verify_phase_artifacts(phase='connect') (jjackson/ace#635).
Products
4-connect/connect-opp-setup.md(written withparentFolderId = phaseFolderId) — opp UUID, verification flags, payment units, ACE test-user invite URL, connect_int_id (ConnectProd integer id, from the create response)decisions.yamlrows (phase: 4-connect,skill: connect-opp-setup) — one rule row per PDD verification rule, plus[ACE]latitude and[FIXED]ambiguity rows (Step 8a, § Decisions Log). The decisions log is the run's review artifact; these rows are the only record of where each rule is enforcedrun_state.yaml.phases.connect-setup.products.connect— single atomic block withprogram(copied fromopp.yaml.connect.programfor run self-containment),opportunity,ace_test_usersub-keys. Read bysynthetic-data-generate(opportunity.connect_int_id), Phase 6 mobile recipes, and other skills within the same run. Per-run only — no other run reads it.
Operations on an existing opportunity
Three parts of the Process are the one place their mechanics are written
down, and each runs standalone against an opportunity that already exists:
one ACE built, or one it didn't. connect-opp-modify is the procedure for a
change to a live opportunity (approval, before-read, refusals, record), and
it points here for these mechanics instead of restating them.
| Operation | Section | Needs | Build-only parts to skip outside a run |
|---|---|---|---|
Verification flags (form_field_rules, duration floors, submission window) |
Step 5 | opportunity_id, pm_org, the rules (from a PDD's Layer A or the caller), the released Deliver CCZ for the real question paths |
the self-managed gap rows (Step 8a); run_state writes |
| Payment units | Step 6 | opportunity_id, pm_org, total_budget, the payment plan, deliver-unit server_ids from connect_list_deliver_units |
the phases.connect-setup.status write on opportunity_underfunded (report to the caller instead) |
| Learn passing score read-back and repair | Step 4, verify-after-create › passing_score |
program_id, opportunity_id, pm_org, the intended score, whether a PDD decided it |
none; the shared-row caution applies everywhere |
Outside a run, write the outcome where the caller says: the program
context doc's Decisions for a program project, or the run folder when one
is bound. comms-log/observations.md and auto_surfaced mean the
caller's record.
Terminology
Every string this skill puts in front of a reader follows
skills/_terminology.md: Connect, never CommCare Connect, and
US spelling, always — program, enrollment, enroll, organization, behavior, center; never
programme, enrolment, organisation (owner, 2026-10-20: "everything should be US spelling",
ace#3232). Identifiers (case properties, slugs, ids, config keys) keep their spelling.
Process
Read inputs from GDrive (paths in
## Inputsabove).Read the PDD's
archetype:and## Evidence Modelsection. These are the inputs for steps 4–5. The Evidence Model's Layer A column is the spec for verification flags; Layer B/C inform soft-flag metadata. If the PDD has no Evidence Model section, stop and return an error — the PDD is incomplete andidea-to-pddshould re-run with the stress-test rubric.Pre-flight: resolve the two orgs, then make the holding org's program application ACCEPTED.
3.0 Resolve the orgs (operator decision 2026-09-26). From the connect-setup dispatch take
connect_orgs(/ace:doctor --preflight) and compute, exactly asphase4Orgs()inlib/connect-orgs.tsdoes:connect_orgs.nm_orgmode pm_org(program's org)holding_org(target_organization_slug)set, ≠ PM org pm-nm(the normal shape)connect_orgs.pm_org, or a reused program's recorded orgconnect_orgs.nm_orgunset self-managed(legacy, other instances)same the PM org pm_orgis the program's org and the org whose URL every PM-only surface needs;holding_orgis the org the opportunity is created under and lives in forever. Never type either slug —playbook/integrations/connect-api.md § Which Connect orgs ACE acts in.Why Phase 4's opportunity is held by the NM org
The program-manager / network-manager model. A program manager creates the program in the PM org, invites a network manager, and only once the NM accepts can the PM create an opportunity held by that NM — Connect enforces the gate itself (live on
turmeric-market-study/20260914-1742):POST /api/programs/<id>/opportunities/rejects withOrganization must have an accepted application for this program.Until 2026-09-26 Phase 4 always created a self-managed opportunity (held by the PM org). That shape can never carry verification rules: Connect's verification-rules page is PM-only and serves only when the requesting org is NOT the holding org (
is_opportunity_pm,commcare_connect/program/utils.py) — so every self-managed run silently dropped its PDD's Layer A payability rules (ace#2419, observedspark-facilitator/20260925-1536). With an ACE-controlled NM org configured, Phase 4 now runs the real PM→NM flow on every run: the NM org holds the opportunity, and the PM org — a different org — sets its rules. Proved live 2026-09-26 on probe opportunity7bfcb845-015c-416a-a200-9795c68dfc55(matrix inplaybook/integrations/connect-api.md § PM→NM org-URL matrix).This is still ACE's build/QA opportunity, not the partner LLO's. The NM org here is ACE's own; the awarded partner's opportunity is created in Phase 9 by
llo-onboarding, after the partner accepts, on freshly-copiedcc_app_ids (ace#573). Two opportunities remains the model.There is no repair path if the holding org is wrong:
target_organization_slugis create-time only andconnect_update_opportunitycarries no org field. The Playwright create fallback therefore THROWScross_org_create_unsupportedrather than creating under the PM org (it used to warn and do exactly that).3a. Invite + accept the holding org (unconditional; the conflict is the skip). In BOTH modes the holding org needs an ACCEPTED
ProgramApplicationfor this program — inself-managedmode the holding org is the PM org itself, which still needs one (omittingtarget_organization_slugdoes not waive it: the REST backend sendsorganization_slugas the holding org — dimagi-internal/ace#1251).Any other
target_organization_slug→ HALT. Phase 4 never creates an opportunity for a partner LLO (Phase 8/9 have not run, so no partner has accepted). If a caller passes a slug that is notholding_orgabove, halt:FATAL:
target_organization_slugnames<slug>, which is not this instance's configured holding org (<holding_org>). Phase 4 builds ACE's own build/QA opportunity; the partner's opportunity is created in Phase 9 byllo-onboardingafter their invite is accepted. An opportunity created under the wrong org can never be reassigned.Run the round-trip unconditionally — attempt the transition and treat the conflict as the skip (CLAUDE.md § Conventions). Do NOT try to read the application state first. (
connect_list_invitesnow reads the PM page's real rows — org display name + status, integer ids, no UUID — since #3017; it is a status read, not the trigger.)mcp__plugin_ace_ace-connect__connect_send_llo_invite({ organization_slug: <pm_org>, // the program's org program_id, organization: <holding_org>, // nm_org in pm-nm mode; pm_org when self-managed }) // → { program_application_id, status: 'invited' } — CAPTURE the id NOW mcp__plugin_ace_ace-connect__connect_accept_program_application({ organization_slug: <pm_org>, program_id, // REQUIRED — the accept is program-scoped application_id: <program_application_id from the invite>, }) // → { status: 'accepted' }Capture
program_application_idfrom the invite POST and write it toproducts.connect.program_application_id(Step 10). It is not recoverable later:connect_list_invitesshows only the integer row id, never the UUID.The accept is Connect's automation shortcut: its REST view (
ProgramApplicationAcceptView) authorises the caller as an admin of the program's org, so it is called with the PM org and the NM admin identity plays no part. Observed live 2026-09-26: invite68b0a689-7326-4c13-a21e-ef492db3a738→invited, accept →accepted, then the NM-held create succeeded.The accept is gated (ace#2954, ace#3009, operator rule 2026-10-16) and takes no extra argument. It allows exactly two cases: (a) the application is listed on the OWN programme page (
/a/<org>/program/) of an org ACE controls (ACE_CONNECT_PM_ORG/ACE_CONNECT_NM_ORG/ACE_CONNECT_CONTROLLED_ORGS, environment only) — internal testing: ACE's own NM org never "applies", so the PM accepts it fromINVITED; or (b) the partner has already accepted their invitation, i.e. a row on the PM programme page readsAPPLIED— the PM's accept is then the legitimate final step. It refuses a non-controlled org still atINVITED(PartnerHasNotAppliedError— the tekzal/20261013-2314 incident) and fails closed when neither can be read (ApplicationAcceptUnverifiableError). In Phase 4 only (a) applies. A refusal here is unexpected — it usually means the PM org's own application (self-managed; the PM view lists no application ids). Do not work around it; record it and halt.Do not paraphrase these signatures — the parameter names above are the atoms' real ones, per
docs/atom-schemas.md. Every name in this block was wrong until ace#1800 (connect_send_llo_invitetakesorganization, nottarget_organization_slug;connect_accept_program_applicationtakesorganization_slug/program_id/application_id). The wrong block was invisible because it only executes on the FIRST Phase 4 run of a program — every later run short-circuits on the conflict below.The skip response carries no id — do not go looking for one (ace#1800). On a reused program the invite rejects with
{error: 'validation_error', fields: {organization: ['Organization already has an application for this program.']}}and names noapplication_id(observed onhh-poverty-targeting/20260828-0702, ace#1800). That is fine: the create derives the application server-side from(target_organization_slug, program_id). Recordprogram_application_id: nullwith the reason. This error does NOT distinguishINVITED/APPLIEDfromACCEPTED— if the existing application is merely pending,connect_create_opportunityfails loudly withOrganization must have an accepted application for this program.Let that be the check; do not infer acceptance from the skip.Log the round-trip result to
comms-log/observations.mdso the operator can audit the state mutation. This is the only Phase 4 step that mutates Connect state outside the opp itself.
Create the opportunity via
connect_create_opportunity.⚠️ Single-active-opp check (per-program, per-application) — an OBSERVATION, not a rule Connect is known to enforce. ACE has one recorded instance of a prior opp deactivating when a new managed opp was created on the same program: the leep-paint-collection run, 2026-05-06, where
LEEP Paint Surveillance — India v1flipped from active to inactive without warning as this skill recreated the opp (jjackson/ace#106 finding 11). It also has a direct counter-observation: onbednet-check-2-visit/20260908-1544(programefb8af66-fbfd-488f-bf99-66f864cea68b, orgai-demo-space) ten opportunities on one program were concurrentlyactive: true, and an immediate post-create re-read of the very opp that run's WARN named (5fdee3b8-e859-4aa7-a4be-8ad6f727917a) came backactive: truewithdashboard_read: "ok"— a real read of the edit-form toggle, and no deactivation (ace#2290). So neither half of the old wording holds up: do not write "Connect enforces one active managed opp per acceptedProgramApplication" or "creating this opp will deactivate<x>" into the WARN, the phase summary, orconnect-opp-setup.md. Neither is established.Leading hypothesis for the divergence — a hypothesis, not a finding. The 2026-05-06 side effect was attributed to the accept-application step in 3a. On a self-managed org (PM org == target org) that step now short-circuits on
Organization already has an application for this programand never reaches the accept call at all, which would explain the side effect not firing today. That is checkable in upstream source, not from here: runskills/upstream-regression-triageagainstdimagi/commcare-connectbefore promoting either half of this back to fact (ace#2290).Keep the check regardless. It is one list call, and one real deactivation was observed once — what changed is the claim it makes, not whether it runs. Before calling
create_opportunity, list opps for this program and report which are active:mcp__plugin_ace_ace-connect__connect_list_opportunities({ organization_slug, hydrate: true, // REQUIRED — see below })hydrate: trueis not optional, and do NOT passprogram_id(ace#1022). The opportunity LIST page carries only id / name / short description. Until #1022 this atom hardcodedactive: falseon every row, so this WARN could never fire — the silent-deactivation surprise it exists to catch was undetectable by it. It also acceptedprogram_idand silently ignored it, returning the whole org (live: 20 opportunities from four other programs for a program that had zero). Nowprogram_idis refused loudly, andactiveis absent unless you hydrate — a missing value you must handle, rather than a fabricated one you cannot tell from a real one.hydrate: truefetches each row throughget_opportunity, which parses the real toggle off the edit form. Filter to this program yourself from the hydrated rows.Count the hydrated rows in this program with
active=true. N>1 is the normal case on a mature program — every/ace:runpublishes a fresh opp and per-run accumulation is by design (CLAUDE.md), so "close prior opps first" is not the remedy and is no longer asked for. If the count is non-zero, surface ONE[WARN]line inauto_surfacedcarrying the count and the ids:[WARN] <N> opportunit(y|ies) already active on this program before creating "<new-name>": "<old-name>" (id=<old-uuid>), … . A new managed opp MAY deactivate them — unverified, one observation each way (ace#2290). Proceeding.Do not assert in that WARN — or anywhere downstream — that a named opp will be, or has been, deactivated. If a prior opp matters to this run, re-read it with
connect_get_opportunityafter the create and record theactivevalue you actually observed.The auto-mode default is to proceed, unchanged (matches current Phase 9 pause-point auto-approval). Review-mode pauses for explicit operator confirmation.
4.0 Resume check — finish THIS run's opportunity, never mint a second (ace#2083). The create below makes an opportunity with no payment units; Connect's
is_setup_completestays false until Step 6 adds one, and every read then reportsdashboard_read: 'setup_incomplete'. A dispatch that dies between here and Step 6 leaves exactly that, and a re-dispatch that creates again strands it for good: Connect has no opportunity-delete route, and its only purge (delete_stale_opportunities) takes visit-free opportunities at SIX MONTHS. Meanwhile each one costs a fullEXPECTED_OPP_BUDGETin every program-ceiling sum on the org (11 of 71 rows on the shared PM org, 2026-09-06 — ACE's own abandoned Phase 4 runs). So, before creating:- Read
phases.connect-setup.products.connect.opportunityfrom this run'srun_state.yaml(null on a first dispatch). connect_list_opportunities({organization_slug: <pm_org>, name: <run_id>, hydrate: true})— the PM org lists NM-held opportunities too;nameis a substring match, so the run id finds every row of this run.- Decide with
planOpportunityCreate({runId, expectedName, recorded, listing, rows})(lib/connect-opp-resume.ts, pinned bytest/lib/connect-opp-resume.test.ts), whereexpectedNameis the exact"<run_id> · <PDD display name>"you would create:create→ proceed with the create below.adopt→ do NOT create. Useopportunity_id, record it now (the write below), takedeliver_units[].server_idfromconnect_list_deliver_units, and continue at Step 5 on that opportunity.setup_incomplete: truemeans Steps 5, 6 and 6.5 all still have to run on it;falsemeans setup is done — callconnect_list_payment_unitsbefore Step 6 and create only the units not already listed by name. Log the adoption and itsreasontocomms-log/observations.mdand the step log.halt→ stop Phase 4 with a[BLOCKER]carryingcode,opportunity_idsandreason. Never create on a halt: every halt is a case where creating could add another permanent orphan.
The atom enforces the same rule.
connect_create_opportunityrefuses anis_testcreate whose exact name already exists (OPPORTUNITY_ALREADY_EXISTS, naming the ids) or whose listing came back incomplete (OPPORTUNITY_EXISTENCE_UNKNOWN). Treat either as this step'sadopt/halt;allow_existing_name: trueis for a replacement the operator explicitly approved, never a retry.Record the id the moment the create returns (ace#2412). Right after a successful create (or an
adopt), before Step 5, writephases.connect-setup.products.connect.opportunity: {id, name, url}withupdate_yaml_file({merge: 'deep'})on the run'srun_state.yaml(novalidateAs— Step 10 writes the full block and validates it). That one write is what lets the next dispatch's 4.0 take therecordedbranch.Endpoint: the new endpoint is
POST /api/programs/<program_id>/opportunities/and takes the full opp config in one shot — including dates, total_budget, and structuredlearn_app/deliver_apppayloads. The server resolves HQ creds, registers HQApiKey records as needed, fetches app names from CommCareHQ synchronously, and syncs learn modules + deliver units in the same transaction. Args:organization_slug:pm_orgfrom Step 3.0 — the org that owns the program (connect_orgs.pm_org, or the reused program's recorded org)program_id: from step 1 (program.md)name: construct as"<run_id> · <PDD display name>"— therun_idfrom run_state (formatYYYYMMDD-HHMM) as a FRONT prefix, separated by·(space-middot-space, U+00B7), then the PDD display name. Example:"20260601-0739 · Malaria RDT Performance Sampling". The run-id MUST lead. Why front-prefix: the ACE test user accumulates dozens of near-identical opp invites across dogfood runs, and the run-id is the only token that disambiguates THIS run's opp from all the others. As a front prefix it lands on the tile's FIRST line — short and never clipped by the tile's name-wrap — so Phase 6's mobile claim/resume recipes can anchor their tile match on it (text: ".*${OPP_RUN_ID}.*") and find this run's opp deterministically. The old run-id-SUFFIX form ("<display> — <slug> (run <run-id>)") put the discriminator at the END of a long name that wraps to 2-3 lines on the tile — the most-clipped position — which is exactly the wrong place for the matcher. (Applies to ACE-driven dogfood runs — sameis_test: trueframing as below.) Code-enforced since jjackson/ace#755: theconnect_create_opportunityatom rejects anis_test: truename without the run-id front prefix (INVALID_OPP_NAME_PREFIX,mcp/connect/opportunity-name.ts) before any network call.short_description: ≤50 chars (server-enforced; the Connect opportunity edit form rejects longer values with a genericEnsure this field has no more than 50 characterserror). Truncate the PDD's intervention summary to a one-line headline and stash the long-form intent indescription. Mobile-app-facing.description: default to ≤250 chars — a single-paragraph headline. The Connect server has an undocumented length threshold where HTTP 500s start firing intermittently around ~700 chars; trimming to ~250 cleared the path on leep-paint-collection (see #106 finding 7). Stash the full PDD intervention prose in the opp's Drive summary doc and link to it from the headline. When the server-side length cap is widened safely, drop this guidance.descriptionis WORKER-VISIBLE — it renders in full on the job card, the first screen an FLW sees. It is not a bookkeeping field and it is not internal. Write it for the worker deciding whether to accept the job. Never put run ids, phase names, capture bookkeeping or any other build-time scaffolding in it.Live on turmeric-market-study/20260828-1108: a capture opportunity's
descriptionended "…This opportunity is a capture vehicle for Phase 6 walkthrough screenshots on run 20260828-1108." It rendered legibly on the job card, the job card was captured as a walkthrough screenshot, and that frame was then cited by BOTH the FLW training guide and the training deck — internal scaffolding one review away from reaching trainees. A screenshot cannot be retro-fixed: correcting the field does not correct frames that already show it, and re-shooting the job card needs an unclaimed opportunity. Get it right at create time.target_organization_slug:holding_orgfrom Step 3.0 — the configured NM org inpm-nmmode (its application ACCEPTED in 3a). Pass it explicitly in both modes. Read the create response'sorganization_slugback and assert it equalsholding_org; a mismatch is a[BLOCKER](the holding org cannot be changed later).start_date/end_date: opportunity dates (YYYY-MM-DD; must fit inside the program window)total_budget: an integer in the opp currency's whole unit (Connect storestotal_budget,amount,org_amount,max_totalas integers in the SAME unit —PositiveBigIntegerField/PositiveIntegerFieldincommcare_connect/opportunity/models.py; no cents). Two bounds, both mandatory:- Upper bound (ceiling): must fit inside
program.budget − Σ(other managed opps). - Lower bound (floor — funds ≥1 FLW): must be
≥ min_budget_for_one_user × FUND_USERS, wheremin_budget_for_one_user = Σ over the opp's planned payment units of (max_total × (amount + org_amount))andFUND_USERSis a small smoke headroom (default 3) so a few claims are possible. This mirrors Connect's managed-opp capacity formulanumber_of_users = total_budget / Σ(max_total × (amount + org_amount))(Opportunity.number_of_users, models.py): atotal_budgetbelowmin_budget_for_one_useryieldsnumber_of_users < 1, which under-allocatescreate_claim_limitsand leaves the FLW unable to claim a full visit allotment. - ACE computes
min_budget_for_one_userfrom the planned payment units — known from the PDD's deliver design BEFORE create — and sizestotal_budgetto≥ min_budget_for_one_user × FUND_USERSclamped by the ceiling. The PDD's program budget is a HINT/ceiling, NOT the opp budget: if the PDD budget is too small to fund even one user at the planned payment units, the opp-budget floor WINS and the program budget must be raised to accommodate (seeconnect-program-setupheadroom, jjackson/ace#588). Archetype-agnostic — applies to every opp.
- Upper bound (ceiling): must fit inside
is_test: set explicitly totruefor every ACE-driven run. The server defaults totrueif omitted, but sending it explicitly makes the value visible in the create-response and auditable in the verify-after-create check below. ACE is in dogfood mode — every opp it stands up is for engineering exercise, NOT a production rollout — so the test flag must be on. The eventual production-grade opp (post- dogfood) will flip this tofalse; that flip will be a deliberate operator decision, not a defaulted-off slip.learn_app:{ hq_server_url, api_key, cc_domain, cc_app_id, description, passing_score }hq_server_url: full URL (e.g.https://www.commcarehq.org)api_key: the raw 40-char HQ API key for the project space (read from~/.claude/plugins/data/ace-ace/.env→ACE_HQ_API_KEY). Server creates theHQApiKeyrecord if it doesn't exist. Partner workspace (run_workspace.is_default: false): instead mint a key restricted to the partner's space first —commcare_create_api_key(domain: <run_workspace.hq_domain>, name: ace-run-<hq_domain>-<run-id>)— and pass the returnedhq-key:<name>reference here (and asapi_keytoconnect_preflight_learn_app_user). NeverACE_HQ_API_KEYon a partner's opportunity (skills/_run-workspace.md).cc_domain: HQ project space slugcc_app_id: bare 32-char HQ app iddescription: required (Connect form marks it*); pulled from PDD § Training Planpassing_score: 0–100. Read it fromrun_state.yaml.phases.idea-to-design.products.pdd.program_parameters.learn_passing_score— that is the typed handoff and it is authoritative. Fall back to the PDD's § Program Parameters table, then to 80 ONLY when the PDD decides nothing; when you fall back, say so explicitly in the step log and incomms-log/observations.md, because a defaulted gate is a program decision ACE made on the operator's behalf.This value cannot be set anywhere else. Nova's
connect.assessmentexposes only{id, user_score}— there is nopassing_scoreslot — so this call is the ONLY place the Learn gate is established. It is a required field on the atom, so it cannot be omitted, only set wrong. Getting it wrong is silent: the app still builds, the worker still sees a result screen, and only the gate is different from the one the program specified. Onbednet-check-2-visit/20260813-2313the PDD specified a source-stated 6-of-6 gate (100); with percentage scoring over six items 5-of-6 is 83.3, so defaulting to 80 would have passed a worker who got one wrong, against an explicit program decision that they must not.
deliver_app:{ hq_server_url, api_key, cc_domain, cc_app_id }—cc_app_idMUST differ fromlearn_app.cc_app_id(server-validated)auto_activate: passfalseexplicitly (it is also the atom default since #584). This skill's ordering is create (Step 4) → create payment unit(s) (Step 6) → activate (Step 6.5). Activation requires at least one PaymentUnit;auto_activate: truewould try to activate at create time before any PU exists, which Connect rejects ("At least one payment unit must exist before activating") AND rolls back the entire create — leaving noopportunity_idand an orphan inactive opp. Create the draft here, then activate in Step 6.5.
The response includes
opportunity_id(UUID), the resolved app names, and arrays oflearn_modules/deliver_unitsalready synced from HQ. Captureopportunity_idfor downstream steps. Don't callconnect_list_deliver_unitsafter this — the create response already carries the full list underdeliver_app.deliver_units.A 5xx or timeout from the create is an AMBIGUOUS outcome — never retry it blind (ace#2580). The endpoint fetches app names from HQ and syncs learn modules + deliver units synchronously, so a slow HQ can push it past the gateway while the transaction still commits. Observed on
spark-facilitator/20261001-2208:504 Gateway Time-out, an immediate list showed no new row, and ~2–3 minutes later the opportunity was there. A retry would have minted a second opportunity on the program. So: wait, thenconnect_list_opportunities({organization_slug, name: <the run id>})(nameis a case-insensitive SUBSTRING match, so the run-id prefix alone is enough and is unique; it was strict equality until ace#2891, which made a run-id lookup answer[]withlisting.complete: true— the one answer that licenses exactly the retry this paragraph exists to prevent) — repeat over a bounded window of ~5 minutes. Corroborate a[]against the UNFILTERED walk'slisting.total_countbefore believing it. If the row appears, adopt it: takedeliver_units[].server_idfromconnect_list_deliver_unitsandconnect_int_idfrom the Step 6.5/activate/response (it returnsid, the integer), and say in the step log that the create response was lost. Re-create only once the bounded wait has confirmed the opportunity is absent — and if the row lands after that, the atom'sOPPORTUNITY_ALREADY_EXISTSrefusal names it: adopt it per Step 4.0.App-wire fields are write-once at create. Connect's
/editform does NOT exposelearn_app/deliver_app— they're only set at create.connect_update_opportunityonly coversname/short_description/description/end_date/is_test. If Phase 3 re-uploads an app after this skill ran, the existing opp is wired to a stale (now-abandoned) HQ id — the recovery is manual deletion in the Connect web UI (Connect'sdelete_opportunity()helper exists incommcare_connect/opportunity/deletion.pybut no Django view exposes it yet; seeskills/sweep-connect/SKILL.md § Implementation notes) followed byconnect_create_opportunityagainst the canonical HQ ids (if the stale one could not be deleted, the create needsallow_existing_name: true— the operator's call, since the stale one stays on the org until Connect's six-month purge), then update the current run'sphases.solicitation-management.products.solicitation.connect_opportunity_idto the new UUID. This recovery is low-cost. A labs solicitation already published for this opp is unaffected: solicitations are scoped to the labsprogram_id, not to any specific Connect opportunity UUID (connect_opportunity_idis ACE-side bookkeeping recording ACE's intended target, not a labs-side foreign key — seeskills/solicitation-create/SKILL.md). The public solicitation URL, deadline, and pending applications all continue uninterrupted. CCC-301 will eventually retire this dance by exposingupdate_opportunity({learn_app, deliver_app}).Verify-after-create (mandatory). Immediately after the create response returns, call
connect_get_opportunity({organization_slug, opportunity_id})and compare what was sent against what the server stored.⚠️ Only some of those fields are READABLE at this point (dimagi-internal/ace#1647). A never-activated opportunity renders only the edit-form half of
connect_get_opportunity's hydration; the dashboard half returns nothing until the opp has been through/activate/(Step 6.5). A Write→Read→Compare that cannot read is worse than no check — it reports confidence it does not have — so the list below is split, and the unreadable half is deferred to Step 6.6, not asserted here.Verified live on
bednet-check-2-visit/20260825-1310Phase 4: the same opp read twice through the same atom, ~2 min apart either side of activation. Pre-activation the payload carriedname,short_description,description,end_date,is_test,active,currency,countryand nothing else; post-activation it also carriedtotal_budget,start_date,program_name,learn_appanddeliver_app. Upstream half tracked as #1637.Verify HERE (readable pre-activation):
name,short_description,description— string matchend_date—YYYY-MM-DDmatch (theturmeric-20260503run hit a write-vs-read drift whereend_date=2026-08-09was accepted by create butconnect_get_opportunityreturned""; this read-back is the canary)is_test— boolean match
DEFER to Step 6.6 (unreadable pre-activation — do NOT assert here):
start_date—YYYY-MM-DDmatchtotal_budget— numeric matchlearn_app.cc_app_idanddeliver_app.cc_app_id— bare 32-char match
An absent field is
unreadable-at-this-point, never a match. If any DEFERRED field unexpectedly IS present in this read, compare it — a present value is real. But never record a comparison you did not perform: writeunreadable-at-this-point (deferred to Step 6.6)against each one you skipped, incomms-log/observations.mdand in whatever verification summary this step reports. Silently counting an absent field as a pass is the exact defect #1647 fixed; the sibling class is ace#1449 below, where a field the read surface does not render came backundefinedand read as "unreadable" rather than "mismatch".Also check:
passing_score(also runs on an existing opportunity: § Operations on an existing opportunity) — numeric match. Read it withconnect_get_learn_passing_score({organization_slug, program_id, opportunity_id}), NOT fromconnect_get_opportunity(ace#1449).connect_get_opportunityhydrates from/a/<org>/opportunity/<id>/editplus the read-only detail page, andlearn_app_passing_scoreis rendered on neither — it lives only on the program-scoped init-edit form. So this comparison silently had nothing to compare against: the field came backundefined, which reads as "unreadable", not as "mismatch". There is no REST alternative — commcare-connect's automation API (PR #1135) ships no opportunity GET endpoint at all.Two return values that are NOT the same thing:
passing_score: null— the input rendered EMPTY, i.e. the gate is unset. Do not coerce that to0;0means "every worker passes", the most permissive possible gate, and is the opposite of unconfigured.- the atom THROWS — the field is absent from the form altogether, meaning
the form shape changed. Treat as
[BLOCKER]; do not substitute a default, or you will report a gate value ACE invented as one Connect stored.
Severity depends on whether the PDD decided the value, because the two cases mean different things:
- The PDD stated a gate (
program_parameters.learn_passing_scoreis set) and the read-back differs →[BLOCKER]. The gate the program specified is not the gate that is live, this call is the only place it can be set, and nothing downstream re-checks it. Do not proceed; log both values tocomms-log/observations.md. - ACE defaulted the value (the PDD decided nothing) and the server
returned its own →
[INFO]; document the diff and proceed.
The pre-2026-08-14 rule made this
[INFO]unconditionally, on the reasoning that "Connect's server has its own default". That is true and irrelevant when the PDD stated a gate: a server default silently overriding an explicit program decision is precisely the drift this read-back exists to catch.On a mismatch, name ROW REUSE as the first suspect — not server drift (dimagi-internal/ace#1350). Connect keys
CommCareAppon(cc_app_id, cc_domain, organization, hq_server), not on the opportunity, and the create path calls_build_commcare_app(update_existing=False). So when aCommCareApprow already exists for thiscc_app_id,get_or_create'sdefaultsare IGNORED and thepassing_scoreyou just posted is silently discarded — the opportunity inherits whatever an earlier one set. The POST looks perfectly healthy; only this read-back sees it.That is the likeliest cause of "posted 100, read back 80", and it looks nothing like a server default, so say so in the
[BLOCKER]:passing_scoremismatch on<opp>: sent<X>, server returned<Y>. Most likely cause is CommCareApp ROW REUSE — Connect keys that row on(cc_app_id, cc_domain, organization, hq_server), not on the opportunity, and the create path ignoresdefaultsfor an existing row. Repair withconnect_set_learn_passing_score— and note it will move the gate for EVERY opportunity in this org wired to the same HQ Learn app, so checkprevious_passing_scorein its response before accepting the change.ACE normally avoids this because every
/ace:runbuilds a fresh Nova Learn app, so every create posts a newcc_app_idand always takes thecreated=Truebranch. That invariant breaks on a forked run, a hand-wired opp, or a Phase 4 re-mint against the same release — exactly the cases where a mid-run operator is least expecting it.Do not eyeball the severity — call
classifyPassingScoreReadbackfromlib/passing-score-readback.tswith{posted, readBack, pddDecided}. It also names the CAUSE, which the[BLOCKER]alone did not, and returns the repair (ace#1350):A mismatch here is almost certainly not server drift. Connect's create path runs
CommCareApp.objects.get_or_create(cc_app_id, cc_domain, organization, hq_server, defaults=...)withupdate_existing=False, and the row is keyed on the app, not the opportunity. If a row for thiscc_app_idalready exists in this org,get_or_createignoresdefaultsand the postedpassing_scoreis silently discarded — the opportunity inherits whatever an earlier one set.On the
blockerverdict, apply the returned repair —connect_set_learn_passing_score(posted)— then re-read. Surface itscautioninauto_surfaced: the score lives on the sharedCommCareApprow, so the repair moves the gate for every opportunity in the org wired to this HQ Learn app. Record theprevious_passing_scorethe atom returns.ACE has escaped this only by an unstated invariant — every
/ace:runbuilds a fresh Nova Learn app, so every create posts a newcc_app_idand takes thecreated=Truebranch. It breaks the moment anything reuses an HQ Learn app: a forked run, a hand-wired opp, a Phase 4 re-mint against the same release, or an opp created against a prior run's app to save a build.
Action on mismatch:
end_date(or any DEFERRED field that did read, and disagreed) →[BLOCKER]inauto_surfaced; log the diff tocomms-log/observations.mdwith both values; do NOT proceed to Step 5. The opp is in an unknown state — operator must inspect via the Connect web UI before continuing. (The deferred fields get the same[BLOCKER]treatment at Step 6.6, where they are readable.)- Description / is_test disagreement →
[INFO]log to observations; proceed. passing_scoredisagreement → severity per the rule above:[BLOCKER]when the PDD stated the gate,[INFO]when ACE defaulted.
This step costs one extra HTML-driven
connect_get_opportunitycall (~2s) and catches a class of bugs the producing skill's response alone cannot — server-side serialization gaps, schema drift, or silent overrides. The 0.11.7 ocs-chatbot-qa rework adopted the same "verify after every external write" discipline; this is the Connect side of that rule (seeagents/ace-orchestrator.md § External Mutations — Verify After Create).- Read
Configure verification flags via
connect_set_verification_flags(also runs on an existing opportunity: § Operations on an existing opportunity), mapping the PDD's Evidence Model Layer A to the surfaces Connect actually has. (This atom still goes through the legacy HTML form — the verification config page isn't part of PR #1135's automation API.)Call it with
organization_slug: <pm_org>— the PROGRAM's org, never the holding org. The page is PM-only: Connect serves it only when the requesting org manages the program AND is not the org holding the opportunity (is_opportunity_pm; ace#2419). So:pm-nmmode → call atpm_org. Proved live 2026-09-26 on the NM-held probe7bfcb845-015c-416a-a200-9795c68dfc55: at the PM-org URL a rule saved (form_field_rules_saved: 1); at the NM-org URL the page redirected.self-managedmode → the page can never serve (request org == holding org). The atom now raises a typedverification_page_pm_onlyerror instead of mis-blaming the payload. Do not retry; record every intended rule underverification.not_applied_reasonciting ace#2419, record each as a rule decision row withenforcement: gap(Step 8a), and carry the rules to Phase 9's partner opportunity. ConfiguringACE_CONNECT_NM_ORGis the fix.
Read this before writing anything: five of the eight documented flags no longer exist on Connect's form (dimagi-internal/ace#1013).
duplicate,gps,gps_radius_meters,catchment_areasanddeliver_unit_checks[].check_attachmentsare still accepted by the atom for back-compat, but the corresponding inputs are ABSENT from the page — Django drops them and the atom still returnsok: true. Setting them does not enforce anything; it only writes an artifact that claims enforcement which does not exist. Do not send them. Record the affected Layer A rules as[PLATFORM]gaps in the phase summary and name where they are enforced instead (usually the CCZ).What works today:
form_field_rules— the only surface on which a PDD Layer A predicate can be enforced server-side. One entry per(question_path, question_value, deliver_unit_id)the predicate requires. Additive and idempotent.question_pathis a JSONPath into the HQ form-JSON doc, NOT an XForm XPath. Connect evaluates it asjsonpath_ng.ext.parse(f"$.{question_path}")against the whole forwarded document, in which HQ nests the instance underform— so the CCZ's/data/meeting_basics/meeting_conductedis written here asform.meeting_basics.meeting_conducted. An XPath makes that parse raise, the receiver 500s on the HQ→Connect forward, and every payable visit is lost while the device still shows "1 form sent to server!" (observed onspark-facilitator/20260813-2126, dimagi-internal/ace#1301 — HQ motech log recorded a 500 for the deliver form while every non-payable form on the same app got 200). The atom normalises/data/a/b→form.a.bfor you, but write it correctly so the artifact reads truthfully.- Read the group/question names out of the RELEASED CCZ, never
from the PDD's prose. The PDD quotes the partner's original app;
Nova rebuilds the form and routinely flattens or renames groups, so
the PDD path can name a node that does not exist — and a rule
matching no node enforces nothing while reporting success. Download
the released CCZ (
commcare_download_cczwithwrite_to_path), read the payable form's<bind nodeset=...>list and its<select1><value>options, and translate each nodeset toform.<...>. (Live example: PDD said/data/community_meeting/meeting_type; the released CCZ bound it at/data/meeting_type, so the rule isform.meeting_type.) question_valueis the stored option value, never the display label.deliver_unit_idis the DUserver_id— same valuerequired_deliver_unitstakes, not the per-opp display index.nameis capped at 25 chars; a longer name silently fails the WHOLE formset.- Verify via
form_field_rules_savedin the response — the count Connect actually persisted. It is the only evidence the write landed; compare it against the number of rules you sent. But note what it does NOT prove: ace#1301 shipped two rules that persisted fine and then crashed Connect's receiver on every payable visit, because "Connect stored the string" and "Connect can evaluate the string" are different claims. The end-to-end proof is Phase 6'sconnect_get_deliver_progressshowingdelivered >= 1.
form_submission_start/form_submission_end: HH:MM:SS — set only if the PDD has time-of-day plausibility constraints.deliver_unit_checks[].duration_minutes— MINUTES, matching the form's own help text. Set only if the PDD states a minimum delivery duration. (The oldduration_secondsspelling is now REFUSED with a typed error rather than silently writing a 60x-too-long floor — ace#1013.) Verify viadeliver_unit_checksin the response — the deliver_unit formset read back after the write, one{deliver_unit_id, duration_minutes}per row. A requested floor that does not read back on its unit throwsverification_duration_not_persisted(ace#2945); record the read-back value, not the value you sent.- Any other flag is refused, not dropped.
gps,duplicate,catchment_areas,gps_radius_metersandcheck_attachmentshave no input on the live form, so a truthy value now raisesunsupported_verification_flagBEFORE the POST. That is deliberate: the atom used to accept them, Django dropped them, and it returnedok: true— which is how six opportunities across 2026-06-06..07-28 reported "verification flags configured" with nothing persisted. If the PDD needs one of these predicates, express it as aform_field_rulesrule or cite the CCZ-side enforcement; do not re-send the dead flag.
A per-entity cap or a "repeat is not payable" rule REQUIRES a
form_field_rulesrow — theentity_idkey does not enforce it (ace#2512). Withduplicaterefused (above) and force-disabled under automatic visit verification, Connect resets a repeat key topending, auto-approves it and pays it again (playbook/integrations/connect-api.md § A repeated entity_id is PAID AGAIN). So when the PDD caps paid encounters per entity (or per step), write a rule on the Deliver form's computedpayable_slotfield (question_value= its storedyes), read out of the released CCZ like any other path. If the released form has no such field, do NOT record the cap as enforced: record it as a[PLATFORM]gap / Phase 3 defect in the summary. Also check the payment unit'smax_daily/max_totalagainst EVERY record kind that carries thedeliver_unitmarker, not only the payable ones — flagged and rejected visits still count toward both caps, so a non-payable record kind on the paid unit exhaustsmax_totalearly.Layer A rules that none of these can carry (photo-present, date sanity, cross-field consistency) are normally already enforced in the CCZ — cite that in the summary rather than leaving the rule looking dropped. Dedup is NOT among them: a CCZ cannot see another submission's payment, and Connect does not dedup payment on a repeated key (ace#2512).
Configure payment units (also runs on an existing opportunity: § Operations on an existing opportunity) via
connect_create_payment_units(plural, atomic batch — the new automation API takes a list). Build one entry per unit in the PDD's payment plan; the entire request is rejected if any unit is invalid.Which org URL, Steps 6–7 (live matrix 2026-09-26,
playbook/integrations/connect-api.md § PM→NM org-URL matrix). Passorganization_slug: <pm_org>to every call in Steps 6–9. The REST atoms here (create_payment_unit(s),activate_opportunity,send_flw_invite) hit/api/opportunities/<id>/…, which authorises an admin of EITHER the program's org or the holding org, so the slug does not select anything — but the HTML reads DO read the org off the URL:connect_list_payment_unitsat the NM-org URL returns rows WITHOUTpayment_unit_uuid/amount(observed on the probe), andconnect_get_learn_passing_scoreis program-scoped (404 at the NM org).pm_orgserves every surface Phase 4 uses.ace#573 does not bite across orgs. Connect keys the
CommCareApprow — and so itsDeliverUnits — on the HOLDING org (program/api/serializers.py:CommCareApp.objects.get_or_create(..., organization=<holding org>)). The probe opportunity, held by the NM org and wired to a Deliver app whose PM-org DeliverUnit (7083) was already bound to another opportunity's payment unit, got a fresh DeliverUnit (7084) and its payment unit created cleanly.ALWAYS pass
total_budget(the same whole-unit integer you set on the opportunity in Step 4) as a top-level arg toconnect_create_payment_units/connect_create_payment_unit. The MCP uses it to enforce the funds-≥1-FLW guard in code — it computesnumber_of_users = total_budget / Σ(max_total × (amount + org_amount))over the integers in the request and rejects withopportunity_underfundedBEFORE creating any PU when< 1(jjackson/ace#729). This is the authoritative guard; it cannot be fooled by a dollars-vs-cents mix the way the old hand-computed check could. If you getopportunity_underfunded, the opp is genuinely unclaimable — raisetotal_budget(and the program budget), lowermax_total, or fix theamountunit; do NOT retry unchanged. Per-unit fields:name: descriptive (e.g."Per verified visit")description: payment criteriaamount: per-unit FLW pay from PDD. MUST be a non-negative integer in WHOLE currency units (e.g. whole USD), NOT cents. Connect storesamount,org_amount, andtotal_budgetas plain integers in the SAME whole-currency unit — verified against commcare-connectopportunity/models.py:PaymentUnit.amountandorg_amountarePositiveIntegerField,Opportunity.total_budgetisPositiveBigIntegerField; no cents, no decimals. The serializer rejects floats. If the PDD specifies a fractional rate (e.g.$1.50), round to the nearest whole unit ($1.50→2) and log an[INFO]tocomms-log/observations.mdnoting the rounding.- NEVER pass cents.
$1.50→150is a 100× overpay: Connect reads150as $150 per visit, which (a) renders the wrong number on the FLW Download gate ("Earn up to N USD for visit") and (b) inflatesΣ(max_total × (amount + org_amount))100×, so the budget guard below demands a 100×-too-largetotal_budgetandnumber_of_users = total_budget / Σ(...)collapses below 1 → the FLW physically cannot claim the opp (noOpportunityClaimis created). Caught live onbednet-spot-check/20260605-2303: a$1.00intent passed as100showed "Earn up to 100 USD for visit" and made a$100program budget fund 0.008 users. - Never silently truncate (
$1.50→1) — that's where the priorturmeric-20260503run produced a malformed PU. - Sub-$1 rates round UP to the $1 minimum, never to cents. A
PDD smoke rate like
$0.50is NOT representable in whole USD — use1(the minimum payable whole unit) and log an[INFO]. Do NOT reach for cents (50) to preserve the half-dollar: Connect reads50as $50/visit, which is the exact mix that shipped an unclaimable opp onbednet-spot-check/20260606-2013(amount=50, total_budget=50→number_of_users=0.05). If the PDD genuinely needs sub-dollar economics, that's a PDD/program issue to raise, not a cents workaround.
- NEVER pass cents.
org_amount: per-unit LLO pay — REQUIRED for managed opps. Same WHOLE-currency-unit integer constraint asamount(NOT cents). (Note: commcare-connect commit4c430de3loosened this validation on 2026-04-29 to accept0— before that fix, falsy values were rejected withorg_amount is required for managed opportunities.)max_total: total visits per user across the opportunity (≥1)max_daily: visits per user per day (≥1)start_date/end_date: optional; default to opportunity datesrequired_deliver_units: which DU ids MUST be completed for this payment to trigger. Passdu.server_id, notdu.id. As of 0.13.126connect_list_deliver_unitspopulatesserver_id(the server-side primary key Connect's DB uses) on each returned DU by reading the create-payment-unit form's checkbox values; this is the field the create endpoint accepts.du.idis the per-opp display index (1, 2, 3…) and is rejected by the server as "Invalid Data". The MCP backend has a name-mapping fallback that acceptsdu.idand maps it for you, but passingserver_iddirectly is the documented happy path. Every payment unit MUST carry at least onerequired_deliver_unitsid — this is a hard pre-create gate, not a nicety. Before callingconnect_create_payment_units, assert each unit'srequired_deliver_unitsis non-empty (wire the just-synced DUserver_id(s) from Step 4'sconnect_list_deliver_unitsonto the unit — for a single-DU app that's the one DU'sserver_id). An emptyrequired_deliver_units: (a) fails the opp'sis_setup_complete; (b) blocks Phase 8connect_send_flw_inviteand Phase 6 mobile screenshot capture (the FLW can't claim the opp); and (c) makes Phase 7 synthetic accrual mintcompleted_works: 0/completed_module: 0regardless of how many visits are generated — the engine attributes completed work to a unit's required DUs, so with none there is nothing to attribute (jjackson/ace#843, confirmed live onhh-poverty-targeting/20260702-1456: a PU existed butrequired_deliver_units: []→ 498 visits, 0 completed works). Closes jjackson/ace#106 finding 5 + jjackson/ace#843.optional_deliver_units: which DUs are bonus/optional. No DU id may appear inrequiredandoptionalof the same unit, AND no DU may appear in two payment units in the same request — the server rejects the whole batch.
Use
connect_create_payment_unit(singular) only when adding a single PU after the fact; under the hood it sends a 1-item list to the same endpoint and shares the same validation rules.Verify-after-create (mandatory) — the CREATE RESPONSE is the authoritative round-trip source.
connect_create_payment_unitsreturns Connect's own post-persist serialization of each stored PU. That object — not a follow-up list call — is what you assert against the request payload. Per unit, check:name,amount,org_amount,max_total,max_daily— exact match against the request payload.required_deliver_units— must be non-empty AND array length matches request AND contains the same DU ids (order may differ). A genuinely emptyrequired_deliver_unitsin the create response is a[BLOCKER]even if the rest of the unit matches (it zeroes Phase 7 accrual — see the create-time gate above, jjackson/ace#843).optional_deliver_units— same.
Do NOT gate on
connect_list_payment_unitsfor these fields. That atom's HTML-scraped read path cannot see them:required_deliver_unitsandoptional_deliver_unitscome back[], anddescriptioncomes back"", unconditionally — the listing renders DU display names in an Alpine.js template with no server ids (pinned bytest/mcp/connect/unit/html-scrape.test.ts"still cannot parse deliver-unit server IDs from the listing"). So an emptyrequired_deliver_unitsfrom the list call is a constant of the read path, carrying zero information about stored state — halting on it halts every healthy run. Observed live onspark-facilitator/20260813-2126: the create response carriedrequired_deliver_units: [6617]while the list call on the same PU returned[], and the opp activated normally (Connect only permits activation when a valid PaymentUnit exists). Same divergence onbednet-spot-check/20260728-2222. Tracked as dimagi-internal/ace#1026 (consolidated into the #1022 read-back umbrella); until the atom either parses the DU column or omits what it cannot read,connect_list_payment_unitsis corroboration onpayment_unit_uuidandnameonly.idis NOT on that list — it is a per-opp display index, not the server PK (dimagi-internal/ace#1642). The listing's first column is the row's#, so the same PU thatconnect_create_payment_unitsreturned asid: 2495reads back asid: 1seconds later (observed onbednet-check-2-visit/20260825-1310, opp7cea3952-8375-43ca-979f-53db29302409). Passing the list'sidanywhere a server id is required reproduces thedu.idvsdu.server_id"Invalid Data" rejection this step already warns about for deliver units. The durable identifier ispayment_unit_uuid— it matches the create response'spayment_unit_idexactly and is the only field safe to key on.The check's intent stands — an unwired payment unit is exactly the #843 class and must still be caught. It is caught at the create response (and, end-to-end, by Phase 6's
connect_get_deliver_progressshowingdelivered >= 1). What changed is only which source is authoritative.Action on any mismatch in the create response: halt with a
[BLOCKER]in the gate brief specifying the exact field divergence (sent vs. stored). Log the full sent payload AND the full server response tocomms-log/observations.md. Do NOT proceed to Step 7 — a malformed PU cascades throughis_setup_completeand silently breaks every downstream skill (Phase 8 invites, Phase 6 screenshots). Operator recovery is delete + recreate via the Connect web UI; a connect-delete-payment-unit atom (not yet built) would let the skill self-heal.This guard is the producer-side complement to the
connect-program-setup-evalrubric. The eval correctly graded a malformed PU aspayment_unit_fit: 5.0(warn) on theturmeric-20260503-0835run, but by then Phase 4 had already handed off to Phase 5 with corrupted state. Catching the malformation at the source converts a multi-phase cascade into a single-skill halt.Budget-funds-≥1-FLW guard — now enforced in MCP code (jjackson/ace#729). You do NOT hand-compute this guard. When you pass
total_budgettoconnect_create_payment_units(Step 6), the connect MCP computesnumber_of_users = total_budget / Σ(max_total × (amount + org_amount))over the request integers and rejects withopportunity_underfundedBEFORE creating any PU when< 1. This supersedes the hand-computed prose check that shipped in #722 — that check failed live onbednet-spot-check/20260606-2013because the agent evaluated it in dollars ($0.50) while storing cents (50), sonumber_of_usersread as 5 in-head but 0.05 in Connect. Code over the sent integers can't make that mistake. This is the silent root behind the Phase-6 Deliver "Unable to claim" / no-OpportunityClaimclass.On
opportunity_underfunded: the create did NOT happen (no orphan PU). Write a[BLOCKER]tocomms-log/observations.md, setphases.connect-setup.status: errorin the current run'srun_state.yaml, and HALT — do NOT activate or hand off to Phase 6. Remediate by raisingtotal_budget(and the program budget — see the Step 4 floor +connect-program-setup), loweringmax_total, or fixing theamountunit; then re-run. Do NOT retry unchanged. The error payload carriestotal_budget,min_budget_for_one_user,number_of_users, and a per-PUbreakdownfor the remediation message. The Step 4total_budgetfloor sizes it right going in; this code guard is the structural backstop that makes an underfunded opp impossible to ship regardless of agent unit-reasoning.
6.5. Activate the opportunity in Connect (REQUIRED for ACE-driven runs; prerequisite for Step 7's test-user invite).
ACE's dogfood pipeline activates the opp here, in Phase 4, so the ACE
test user can be invited synchronously and Phase 6
app-screenshot-capture can drive the AVD against a real opp.
Real-LLO go-live remains a separate, human-gated event in Phase 9
llo-launch — that skill becomes idempotent on already-active opps
from this step (skip-and-log).
Why this isn't a Phase 8→9 boundary violation: the opp lives in
connect-ace-prod (Dimagi-controlled HQ project space) and is
is_test=true. The ACE test user (${ACE_E2E_PHONE}) is also ACE-
controlled. No real LLO sees this state until Phase 9's awardee
email, which is still gated behind the unconditional Phase 8→9 pause.
Always attempt the activate transition — do NOT pre-check the
activeflag to decide whether to call it. Callconnect_activate_opportunity({organization_slug, opportunity_id})unconditionally. The atom hitsPOST /api/opportunities/<id>/activate/, which validates that the opp hasn't ended and at least one PaymentUnit exists (Step 6 satisfies the latter). Returns{ id, opportunity_id, name, active: true }on success.Why unconditional (jjackson/ace#624): the managed-opp create endpoint returns
active: trueas a create-side flag, but that is NOT the/activate/state transitioninvite_users/(Step 7) requires.connect_get_opportunityreportsactive=truewhile the opp is still un-transitioned, so the old "skip if already active" pre-check skipped the only call that actually enables invites — and Step 7 then failed. Calling/activate/on such an opp succeeds (it is not yet active by the endpoint's definition); it rejects only an opp that already went through this same transition. So always call it and branch on the result, never on the read-back flag.Treat the "already active" validation error as the idempotent skip signal (NOT a hard error). If the activate call rejects specifically because the opp already completed the
/activate/transition (e.g. a Phase-4 re-run on the same opp), catch that one error, log[INFO]tocomms-log/observations.md, and proceed to Step 7:<ISO> connect-opp-setup: opp <id> already activated; treating activate rejection as idempotent.Verify activation via Step 7's invite succeeding — NOT via the scraped
activeflag. Do not callconnect_get_opportunityto confirmactive=truehere: that read-back flag is the same create-side signal that returnstrueon an un-transitioned opp (see above), so it can't distinguish a real/activate/from a no-op. The authoritative confirmation is the downstreamconnect_send_flw_invitein Step 7 —invite_users/hard-rejects a non-active opp ("Opportunity must be active to invite users"), so a successful invite is the only proof the transition actually landed. If Step 7's invite rejects with that error, the activate didn't take — halt there with[BLOCKER].On hard error from the activate call, halt with
[BLOCKER]and surface the server error verbatim inauto_surfaced. The most common cause is "no PaymentUnit" — Step 6's verify-after-create check should already have failed loudly; if we got here without one, the verify-after-create has a gap to file against.
6.6. Deferred verify-after-create — re-read the dashboard fields now that they are readable (dimagi-internal/ace#1647).
Step 4's Write→Read→Compare could not see start_date,
total_budget, learn_app.cc_app_id or deliver_app.cc_app_id: a
never-activated opportunity renders only the edit-form half of
connect_get_opportunity. Activation (Step 6.5) makes the dashboard
half readable, so the deferred comparisons run here, against the
same payload Step 4 sent.
- Call
connect_get_opportunity({organization_slug, opportunity_id})once more. - Compare against the Step 4 request payload:
start_date—YYYY-MM-DDmatchtotal_budget— numeric matchlearn_app.cc_app_id— bare 32-char matchdeliver_app.cc_app_id— bare 32-char match
- On any disagreement →
[BLOCKER]inauto_surfaced; log both values tocomms-log/observations.md; do NOT proceed to Step 7. A wrongcc_app_idmeans the opportunity is wired to the wrong CommCare app, which cascades silently through Phase 6's device walk and Phase 7's accrual. - If a field is STILL absent after activation, that is a
[BLOCKER]too — not a pass. The read surface changed shape; say so verbatim rather than substituting a default or treating the absence as agreement. Never record a comparison you did not perform.
Do not confuse this with Step 6.5's own verification: activation is
still confirmed by Step 7's invite succeeding, never by the scraped
active flag (see above). This step re-reads stored configuration
values, which is a different question from did the state transition
land.
Pre-invite the ACE test user (REQUIRED for PersonalID registration).
This step is structurally load-bearing for ACE's emulator-driven mobile testing — not just a convenience. Two reasons ACE must invite
${ACE_E2E_PHONE}to every Connect opportunity it creates:(a) Claim flow: Phase 6
app-screenshot-capturedrives Connect mobile through the claim-opp flow as this user; without the invite, the opp won't appear in the test user's opportunity list and the screenshot recipe stalls.(b) PersonalID registration gate (the critical reason): Connect-id's
/users/start_configurationendpoint runs an@app_integritydecorator that synchronously callscheck_number_for_existing_invites(phone)over HTTP to connect.dimagi.com. For phone numbers with NO existing invite anywhere in Connect, this lookup hangs long enough to trip gunicorn's worker timeout, which kills the worker viasys.exit(1). The client receives an empty/malformed response and force-stops (CommCare NPE ongetSessionFailureSubcode()). See SentryCONNECT-ID-3F.POST /api/opportunities/<id>/invite_users/validates that the opp is active and not ended. Step 6.5 above just satisfied the active precondition synchronously, so the invite fires here directly — no deferral.Args:
connect_send_flw_invite({ organization_slug: <pm_org>, opportunity_id: <UUID from step 4>, phone_numbers: [process.env.ACE_E2E_PHONE] })The atom returns
{ status: 'queued', invited_count: N }because the server invokesadd_connect_users.delay(...)async. TheUserInviterow + SMS go out within a few seconds. Treatqueuedas success for sending the invite — but see the queued ≠ accepted caveat below.queuedis NOT proof the invite landed — read it back (ace#824/#855).invited_count: Nonly confirms Connect enqueued theUserInvite. The resultingOpportunityAccessmay have no linked ConnectID user, and Connect's mobile API filters opportunities byopportunityaccess__user:Opportunity.objects.filter(opportunityaccess__user=request.user, archived=False)An access with a null user matches nothing, so the opportunity is invisible to the device forever — and per the ConnectID change in
2bd03c4it does NOT self-heal. Proven live 2026-07-25: a fresh invite returning{status:'queued', invited_count:1}never appeared on device through either an in-app sync or a swipe-refresh, while five previously-linked opportunities rendered fine.So read it back with
connect_list_flw_invites(ships since ace#855; read-only, one authenticated GET):connect_list_flw_invites({ organization_slug: <pm_org>, opportunity_id: <UUID from step 4>, phone: '${ACE_E2E_PHONE}' })Branch on whether a row EXISTS — not on
claimed:match !== null→ the invite is recorded.status: 'pending'withname: nullis the NORMAL pre-claim state (acceptance happens on-device when Phase 6'sconnect-claim-oppclaims the tile), so pending is a pass here. Recordinvited_atand proceed.match === null(no row at all for that phone) → recover before you warn — wait, re-read, then re-send ONCE (ace#2946). Aqueuedsend fired right after/activate/can create nothing at all, and one identical re-send fixes it. Do this, in order:- Wait ~60 s and re-read with the same
connect_list_flw_invitescall (wait via a Monitor/until-loop or the next tool call, never a foregroundsleep). A row now → treat asmatch !== nullbelow. - Still null → re-send the SAME
connect_send_flw_invitecall ONCE (sameorganization_slug,opportunity_id,phone_numbers). The invite endpoint is idempotent per phone, so a second send cannot double-invite. Then re-read. - Only if the row is STILL absent record
invite_row_present: falseand WARN loudly: the send reported success twice but Connect has no invite. That is the #824 silent failure; name it in the phase summary rather than letting Phase 6 discover it on AVD wall-clock.
Record BOTH sends (timestamps + each read-back result) in the run's
comms-log/observations.md, so a reader can tell "recovered on re-send" from "landed first time". Do not re-send more than once — a second failure is the signal, not something to loop on.Live evidence:
smallholder-seasonal/20261014-0859Phase 4, opp30605010-4be8-49e3-954c-deeb0b4ad8d6(held by the NM org).connect_activate_opportunityat ~07:00:50Z, then the invite →{invited_count: 2, status: "queued"}; three reads at ~07:01, ~07:02 and ~07:03:50 (both PM- and NM-org URLs) returnedinvites: [], while the same atom returned rows on a sibling opp, so the reader worked. One identical re-send at ~07:03 → both rows present,status: pending, and the requester row'sinvited_datewas15-Oct-2026 07:03— the RE-SEND time, not ~07:00. The first queued send created nothing; it was not lag that later caught up. (Suspected, not proven: the asyncadd_connect_userstask ran before the just-activated state was visible to the worker.)- Wait ~60 s and re-read with the same
Do NOT gate on
match.claimed— it reports whether the worker has already CLAIMED the opp, so requiring it would fail every legitimate fresh run. The atom throwsWorkersTableSchemaErrorif Connect reshapes the workers table, so a parsing failure is loud rather than a false verdict.The claim-recipe centering bug (jjackson/ace#800 —
centerElement: trueon the OPP_RUN_ID title-scroll) is still the thing to rule out when a tile IS present and asserted-visible but won't claim; that is a recipe failure, not an invite-propagation one.Hold the invite metadata in memory for Step 10's consolidated write:
Collect the READ-BACK result, not just the send — these are the exact field names
lib/phase-products-schema.tsdeclares andREQUIRED_PRODUCT_KEYSchecks at the phase boundary. Using any other spelling (the pre-#1286 template saidinvited_phone/invited_at) writes fields the schema does not declare: they survive only because the object is.passthrough(), so they validate clean at write time and contribute nothing at the boundary, and Phase 4 then failsverify_phase_productson every run and repairs by hand.ace_test_user = { phone: ${ACE_E2E_PHONE}, # or this run's minted phone — see 7b invite_row_present: <bool>, # match !== null from connect_list_flw_invites — REQUIRED at the boundary connect_user_id: <string | null>, # populates on CLAIM; null on a pending row is NORMAL status: <accepted | pending | unknown>, checked_at: <ISO timestamp>, # when the read-back ran (NOT the send time) }invite_row_present: falseis a LEGAL, contract-satisfying value — it records a verified-absent row, which is what Step 7's[BLOCKER]acts on. What the boundary rejects is the key being ABSENT, i.e. the read-back never ran. Captureconnect_user_idandstatusfor real rather than defaulting them — Phase 6 wants both — but do NOT readconnect_user_idas a fault indicator here.connect_user_id: nullon a pending row is the NORMAL pre-claim state, NOT the ace#824 signature (ace#1663). On this read path the id is not a view of theOpportunityAccess.userFK at all:parseWorkersTable(lib/connect-flw-invites.ts) extracts it from the Name cell, which Connect renders as a bare-until the worker claims the opportunity — socellTextyields null and the id stays null on every pending row, whatever the underlying link is. It populates on CLAIM, exactly likename, and null therefore carries no information about #824 at Phase 4. Reading it as a fault would raise a[BLOCKER]on every healthy run, since Phase 4 invites and Phase 6 claims. Verified live onhh-poverty-targeting/20260824-1404: the same atom and the same phone returnedconnect_user_id: nullon a fresh pending invite and"1277adbd0ceea89e367d"on a CLAIMED opp of the same program.At Phase 4 the only #824 discriminator on this path is row existence (
invite_row_present), which Step 7 already gates on. The linked-vs- unlinked distinction does not become observable until after a claim — Phase 6's claim attempt is what surfaces it.All Connect-opp-setup state is emitted in one atomic write at end-of-skill (Step 10). A crash between Step 7 and Step 10 loses the in-memory metadata; recovery is
/ace:step connect-opp-setupre-run — the invite atom itself is idempotent (already-invited phone numbers return without error from the queued add-users task).The historical
ace_test_user_invite_pending_until_activeflag is no longer written. Step 6.5 activates synchronously, so the deferred-to-llo-launchbranch is dead code.llo-launch(Phase 9) only sends the real-LLO invite; it does not re-fire the ACE test-user invite.
7b. Per-run demo test user — OFF BY DEFAULT (ace#1289). Skip this entire sub-step unless the switch is on.
Read the switch first, and read it exactly once:
perRunTestUserEnabled() from lib/per-run-test-user.ts — the env var is
ACE_PER_RUN_TEST_USER, and it is false for unset / empty / false /
0 / off / any typo (it fails CLOSED). If it is off, Step 7 above is
the whole story: invite ${ACE_E2E_PHONE}, read it back, and move on.
Nothing below runs.
What changes when it is on. Instead of the fixed ${ACE_E2E_PHONE},
this run mints its OWN demo number:
const testUser = derivePerRunTestUser(run_state.yaml.run_id) // lib/per-run-test-user.ts
// → { phone: '+7426…', phoneLocal, countryCode: '+7', name: 'ACE Test <run_id>' }
Deterministic from the run id, so a re-entered or resumed run registers the
SAME user rather than orphaning the first one. Send the Step 7 invite to
testUser.phone in place of ${ACE_E2E_PHONE} — everything else about the
call is identical.
The read-back branch INVERTS, and this is the part to get right.
Registration requires a pre-existing invite (connect-id decorators.py), but
Connect creates the invite → OpportunityAccess link only at invite time and
only if the ConnectID user ALREADY EXISTS (opportunity/tasks.py). A phone
nobody has registered yet therefore produces no row, or a row with
connect_user_id: null — and under a per-run phone that is the EXPECTED
state, not the ace#824 silent failure. Do NOT [BLOCKER] on it.
Classify with classifyPerRunPreRegistrationGate(match) and log its
reason verbatim. The real gate lives downstream in Phase 6, after the
device registers and the invite is re-sent — see
skills/app-screenshot-capture § Step 4. The verified ordering is
invite → register → re-invite (ace#855, live-verified on
hh-poverty-targeting/20260702-1456); the old self-heal that used to paper
over the missing link, resend_connect_invite, is dead code with zero
callers.
Write two extra fields into the Step 10 ace_test_user block — both are
REQUIRED at the phase boundary once per_run is true
(PER_RUN_TEST_USER_REQUIRED_KEYS in lib/phase-products-schema.ts):
phone: <testUser.phone> # the MINTED number, not ${ACE_E2E_PHONE} — Phase 6
# has no fallback and cannot proceed without it
per_run: true # the contract discriminator; it is what moves the
# blocking gate from Phase 4 to post-registration
Do not flip the switch on to "try it". The precondition is: the 7 camera
ids in connect-register-from-otp.yaml are calibrated against a live
mobile_capture_ui_dump on the APK baseline in force, and one fresh-signup
registration has completed on that same baseline. Until then, per-run phones
route every run through an uncalibrated photo-capture surface whose
runFlow.when visible: guard fails SILENTLY. The sentence names no version
on purpose — it said 2.63.2, the baseline moved to 2.64.0 on 2026-09-06,
and all five copies stayed behind (ace#1289). Both clauses remain open at
2.64.0: residuals R2 and R4 of
docs/mobile-calibration/connect-2.64.0-2026-09-06.md.
7.5. Learn-app CCHQ pre-flight (Phase 6 prerequisite, idempotent).
Call connect_preflight_learn_app_user against CCHQ before the
Phase 6 mobile recipe triggers start_learn_app from the Android
client. Defense-in-depth probe for the auth / domain / user-conflict
failure modes — surfaces them as a structured {ok, action, reason}
outcome before Phase 6 boots the AVD.
The probe is read-only — it hits GET /a/<domain>/api/v0.5/user/
with the same ACE_HQ_API_KEY Phase 6 will eventually use.
Args:
connect_preflight_learn_app_user({
hq_domain: <opp.connect.program.learn_app.cc_domain>,
connect_username: <ACE test-user ConnectID username> | omitted,
api_key: "${ACE_HQ_API_KEY}",
hq_username: "${ACE_HQ_USERNAME}",
})
Branch:
ok: true, action: 'would_create' | 'would_reuse_existing' | 'skipped'→ log[OK]tocomms-log/observations.mdand proceed to Step 8.ok: false→ halt with[BLOCKER]and surfacereasonverbatim. The remediation text is embedded inreason(rotate the HQ API key, fix the domain, reconcile the conflicting CCHQ user record, etc.). DO NOT continue to Step 8 — Phase 6 would 500 with no diagnostic.
If the run is being executed without the ConnectID username in
hand (the common case — ACE doesn't always know the FLW's
ConnectID at opp-create time), omit connect_username. The probe
then validates only API-key auth + domain reachability, which
already catches the most common failure modes (rotated key,
archived domain, CCHQ outage) at near-zero cost.
Write config summary via
drive_create_filewithparentFolderId = phaseFolderId(the4-connectfolder; surfaced atACE/<opp-name>/runs/<run-id>/4-connect/connect-opp-setup.md):Opportunity ID (UUID) and URL (
<CONNECT_BASE_URL>/a/<holding_org>/opportunity/<uuid>/)Both orgs and the mode: program org (
pm_org), holding org (holding_org),org_mode, and theprogram_application_idfrom 3a, so a reviewer knows which org the opportunity lives in (and so which org's membership lets them see it).All configuration details (dates, total_budget)
Verification flags (final values, including which were inherited from defaults vs. set explicitly)
Deliver units (from create response) and Payment units (from step 6)
Whether the FLW pre-invite landed or is deferred until activation
ConnectProd int_id (
connect_int_id, from the Step 4 create response; see step 9) 8a. Record the opportunity's calls as decision rows — ONEdecisions_append_rowscall right after the Step 8 write (§ Decisions Log). The decisions log (decisions.yaml, schema v6) is the run's review artifact: ace-web renders every row with a comment box and an answer editor, so "where is each PDD verification rule enforced" is a question a reviewer can answer on the run page. These rows are the ONLY record of these calls —connect-opp-setup.mddoes not restate them. Three kinds:Rule rows —
connect-rule-<slug>, question exactlyWhere is the PDD verification rule '<rule>' enforced?, with one row per verification rule stated in ANY PDD of the run: the overview, and on a componentized run every component and program-level PDD (see § Inputs). Each carriesplain,check_at(the Connect setting or app form/field a reviewer opens) andcorrect_looks_like, plusscope(record|entity|worker|programme) andenforcement(enforced|by-design|gap) — set them, or omit both and the write boundary computes them (classifyRule,lib/decision-review.ts); never contradict its rules (below).ai-defaultis the shortoptionslabel (§ Decisions Log table);reasoningnames the precise placement and its evidence, which is exactly one of:Connect form_field_rules: <name>— evidence:form_field_rules_saved;Connect payment unit max_daily = <n>/Connect payment unit max_total = <n>(per worker) — evidence: the payment unit's create response;Connect deliver_unit_checks.duration_minutes/Connect form submission window— evidence: the value persisted;CCZ: <form> / <field> — <constraint or relevant>— evidence: the Deliver summary line or released-CCZ bind that carries it;Not configurable on Connect — applied in <where>;Not configurable on Connect — not applied anywhere in this build.
"Not configurable on Connect" is a valid answer and must be stated, never omitted.
connect_set_verification_flagsrefusesduplicate,gpsandgps_radius_meters(Step 5, ace#1013), so a PDD's duplicate-visit or GPS rule is exactly the row a reviewer cannot find anywhere else. A rule with no row is the defect this step exists to prevent.A known limitation is a rule row with
enforcement: gap— a rule Connect refused (theself-managed-modeverification_page_pm_onlycase in Step 5, a refused flag) and that nothing else in the build holds. Neverby-design: the design needs it and the build does not carry it.decisions_enrichturns everygaprow into a review ask at phase end, so you do not setreview_askon it yourself.Name the enforcement point that holds the rule AT ITS SCOPE, first. A rule about a WORKER ("at most 1 payable meeting per CBF per day", "total cap 21 per CBF") is held on Connect by the payment unit's
max_daily/max_total, which count per worker. An app (CCZ) check is keyed on the case its form is filled against — a community, a household — so it cannot stop one worker recording two communities on one day, and a per-step clamp of 7 × 3 is 21 per COMMUNITY, not per worker. Write the payment-unit limit first; an app check may follow as support, stated at its own scope (also CCZ: date_of_meeting check — per community), and the row'sscopeisworker. Onspark-facilitator/20260926-1800both caps were writtenCCZ: … ; also Connect payment unit …and recorded asCCZ constraint, which told the reviewer the app enforced them. The write boundary'sclassifyRuletreats a per-worker rule held only by an app check as agap— get the order right here so the row says what holds it.[ACE]latitude rows —connect-latitude-<slug>, one per opportunity-configuration value the PDD left to ACE (dates, budget, max visits, payment amounts, flag thresholds).[FIXED]ambiguity rows —connect-ambiguity-<slug>, one per[FIXED]statement Phase 4 could not configure exactly as written.
The Phase 4 boundary fails this skill if it wrote
connect-opp-setup.mdbut zero rows carryphase: 4-connect+skill: connect-opp-setup(ace#2384), and warns on any live partner-facing row with noplain.
Capture the ConnectProd integer opportunity ID (Phase 7 prerequisite).
Phase 7's
synthetic-data-generateaddresses opportunities by their ConnectProd integer id (the labs/synthetic surfaces and the/a/<org>/opportunity/<int>/URLs key off it) — a different identifier from the UUID. ConnectProd exposes BOTH on the same Opportunity row: it predates UUIDs and still returns the legacy integeridalongside theopportunity_idUUID. It is not a labs-minted value (labs only mints its own id for a purely-synthetic opp with no Connect opp behind it).The integer is already in the Step 4 create response.
connect_create_opportunityreturnsint_id(ConnectProd's integerid) next toid(the UUID) — it is a required field ofManagedOpportunityResponse. Captureint_idfrom that response asconnect_int_id. No Labs call, no UUID→int mapping, no subagent-reachability concern — the value ACE needs is in the response it already received at create time. (connect_activate_opportunityreturns it too, so a re-read is the fallback.)If
int_idis somehow absent from the create response (it should not be — the field is required upstream), log[WARN]tocomms-log/observations.mdand continue withconnect_int_id: null; the Phase 7 operator can pass--opp-int-idmanually.Historical note (jjackson/ace#686 follow-up): earlier versions did a
mcp__connect-labs__labs_context()lookup here, on the mistaken belief — from a 2026-05-06 observation of the UUID-onlyconnect_list_opportunitiesscrape path — that "the integer lives only in the labs DB." That was wrong: the create/activate REST responses carry it. The Labs lookup ALSO failed silently whenever Phase 4 ran as a subagent (the connect-labs MCP atoms aren't bound in subagent sessions), which is exactly howconnect_int_idwas leftnullon malaria-rdt/20260602-1409.Write the consolidated Connect outputs block to
run_state.yaml.phases.connect-setup.products.connectas one atomic patch. Read the program reference fromopp.yaml.connect.program(or fromruns/<run-id>/4-connect/connect-program-setup.md) and copy it into the run'sproducts.connect.programso the run state is self-contained:phases: connect-setup: products: connect: domain: <ACE_HQ_DOMAIN, e.g. connect-ace-prod> # REQUIRED handoff key — phase-products contract + ace-web summary read it; omitting it fails verify_phase_products at the Phase 4 boundary fence (jjackson/ace#734) organization_slug: <= pm_org_slug; LEGACY key kept so pre-2026-09-26 readers still resolve> pm_org_slug: <the program's org — Step 3.0 pm_org> # PM-only pages (verification rules, passing score) resolve here holding_org_slug: <the org HOLDING the opportunity — Step 3.0 holding_org> # nm_org in pm-nm mode; reviewer access + opp URL resolve here org_mode: <pm-nm | self-managed> program_application_id: <UUID captured from the Step 3a invite POST | null on the already-applied skip> program: id: <UUID copied from opp.yaml.connect.program.id> url: <CONNECT_BASE_URL>/a/<org>/program/<uuid>/ opportunity: id: <UUID> # from Step 4 create response name: <verbatim display name> # from Step 4 create response — the exact tile text Connect renders (em-dash, NOT slug-reassembled). Phase 6 reads this as its OPP_NAME envVar; never recompose. url: <CONNECT_BASE_URL>/a/<holding_org_slug>/opportunity/<uuid>/ # the HOLDING org's URL — where the opportunity lives connect_int_id: <integer | null> # ConnectProd integer id = create-response int_id (Step 9) verification: # Step 5's persisted config (shape per lib/phase-products-schema.ts) form_field_rules: - name: <=25 chars, e.g. consent_confirmed=yes> question_path: <dotted path READ FROM THE RELEASED CCZ, per Step 5> question_value: "yes" # ALWAYS QUOTE the predicate. Bare `yes` is the string "yes" to the plugin's YAML 1.2 readers and the BOOLEAN true to PyYAML (ace-web's Python side), so the audit record of the payability predicate becomes parser-dependent (ace#2296). update_yaml_file now serializes 1.1-safe, and validate_run_state warns if any unquoted ambiguous scalar reaches the file. deliver_unit_id: <int> form_field_rules_saved: <int> # the count Connect actually persisted — the only evidence the write landed ace_test_user: # field names per lib/phase-products-schema.ts phone: ${ACE_E2E_PHONE} # from Step 7 invite_row_present: <bool> # REQUIRED — the Step 7 read-back result connect_user_id: <string | null> # populates on CLAIM; null on a pending row is NORMAL, not #824 status: <accepted | pending | unknown> checked_at: <ISO timestamp> # when the read-back ran # ONLY when Step 7b ran (ACE_PER_RUN_TEST_USER on — default OFF, so # normally OMIT this line entirely). Setting it flips the boundary # contract to PER_RUN_TEST_USER_REQUIRED_KEYS, which additionally # requires `phone` (the minted number) — see lib/phase-products-schema.ts. per_run: true postcondition: # Step 11 — the live read-back of the end state ok: <bool> checked_at: <ISO timestamp> failed: [<check id>: <detail>, ...] # empty when ok phase6_blockers: [<check id>: <detail>, ...] # non-empty ⇒ Phase 6 must not walkApply via
mcp__plugin_ace_ace-gdrive__update_yaml_filewithmerge: 'deep'on the current run'srun_state.yaml. This is a partial patch of theconnect-setupphase child (justproducts.connect), sotwo-levelwould replace the entireconnect-setupblock wholesale — droppingconnect-setup's ownstatus,steps, etc. when the orchestrator already set them (the #572/#587 lost-update footgun).deeprecursively mergesproducts.connectwhile preserving every sibling at every depth. This skill is the sole writer ofproducts.connect.Pass
validateAs: { kind: 'phase-products', phase: 'connect-setup' }on thisupdate_yaml_filecall. The server validates theproducts.connectblock against the single-source contract (lib/phase-products-schema.ts— the same shape ace-web's summary page reads) BEFORE the Drive write, and rejects withINVALID_PHASE_PRODUCTSif the block drifted — e.g. writing the opportunity atproducts.opportunityinstead of nesting it underproducts.connect.opportunity(the malaria-rdt/20260604-1604 blank- summary drift, jjackson/ace#705). No Drive write happens on rejection; fix the nesting and retry.Phase 8's
synthetic-data-generatereadsphases.connect-setup.products.connect.opportunity.connect_int_idfrom the current run'srun_state.yamlas the default for its--opp-int-idflag. Whenconnect_int_idis null, the skill surfaces a[WARN]and asks the operator to pass--opp-int-idexplicitly OR re-runconnect-opp-setupto re-readint_idfrom the Connect create/activate response.Do not write
opp.yaml.connect.opportunityoropp.yaml.connect.ace_test_user. Connect opportunities are created fresh per run (not reused across runs) and live only in this run'sproducts.connect.opp.yaml.connect.programis the only durable Connect reference — written byconnect-program-setup, never mutated here.Post-condition: read the live opportunity back and check the END STATE (inline QA). Every atom above validated its own CALL; none of that proves the opportunity Connect now holds is the one this skill decided. A valid-but- wrong end state — a payment unit that never landed, the test flag off, an invite that was "queued" and never existed (ace#824), an opportunity that never made the
/activate/transition (ace#617/#624) — otherwise surfaces two phases later as a Phase 6 walk hunting a tile that cannot exist. Run the three reads in ONE parallel message, against the holding org's URL:connect_get_opportunity({organization_slug: <holding_org_slug>, opportunity_id})connect_list_payment_units({organization_slug: <holding_org_slug>, opportunity_id})connect_list_flw_invites({organization_slug: <holding_org_slug>, opportunity_id, phone: "${ACE_E2E_PHONE}"})
Then evaluate them with
checkOppPostcondition(read, decided)(lib/connect-opp-postcondition.ts, pinned bytest/lib/connect-opp-postcondition.test.ts).decidedis what THIS skill did: the payment-unit names Step 6 created, the rules Step 5 decided and itsform_field_rules_saved,expectActive: true(Step 6.5 activates every ACE build opportunity),expectTestUserInvited: true(Step 7). The checks:Check Passes when Blocks Phase 6? opportunity_readablethe read returns the opportunity yes is_testis_test === true(undefined is unknown, not a pass)no activatednot active: falseAND an invite row exists —activealone is the create-side flag (ace#617);invite_users/rejects a non-active opportunity, so the row is the proofyes payment_units_matchevery Step 6 unit is listed by name and nothing else is ( nameis the only reliable column)no verification_rules_persistedStep 5's form_field_rules_saved≥ the rules decided (Connect has no read surface for them)no test_user_invitedconnect_list_flw_inviteshas a row for the test user (match !== null— neverclaimed)yes Write the result to
products.connect.postcondition(ok,checked_at,failed[],phase6_blockers[]) in Step 10's patch (re-apply it if Step 10 already ran), and add a## Post-conditiontable to4-connect/connect-opp-setup.md. A failure does not fail the skill or re-run anything — it names the end-state defect in the Phase 4 summary. A non-emptyphase6_blockersis what Phase 6's pre-flight reads to refuse the walk with that reason instead of failing on the device. A read that errors is reported as unconfirmed, never as fine.
Existing objects
- Deliver units are keyed on the holding org's
CommCareApprow (ace#573). A second opportunity in the same holding org on the same released Deliver app cannot bind a payment unit to a deliver unit the first already holds. Step 6 is the gate; a fresh Deliver release is the fix. - The Learn
passing_scorelives on the sharedCommCareApprow (ace#1350). A create against an existing row discards the posted score, and the repair moves the gate for every opportunity wired to that Learn app (Step 4'spassing_scorecheck). - The holding org is create-time only (ace#2919). Step 3 resolves it before the create; there is no repair.
- There is no payment-unit update or delete atom. A malformed unit is recreated in the Connect UI (Step 6).
Archetypes
The PDD's archetype: field shapes verification + payment unit setup:
atomic-visit
- Verification: one
form_field_rulesentry per clause of the PDD's payment predicate, keyed to the released CCZ's paths and stored values. GPS-fence / duplicate / catchment / photo-attachment enforcement is not available (ace#1013 — see Step 5); a required photo is normally enforced in the form itself (required="true()"+appearance="acquire"), so record it as CCZ-enforced rather than missing. - Payment: typically one main payment unit per verified visit, optional bonus tier when Layer B passes (e.g. AI photo-quality check).
- Soft flags (Layer B/C from PDD): logged in
opportunity.mdbut no Connect toggle; surfaced viaflw-data-reviewskill in Phase 6.
longitudinal-visits
- Verification: as
atomic-visit— oneform_field_rulesentry per clause of the payment predicate — plus the longitudinal clause of the PDD's Layer A. Where a longitudinal fact is written onto the form by a case preload, it is a normal form-field rule and needs nothing special; where the predicate depends on case state that never reaches the submitted form, it cannot be a form-field rule. Record that explicitly inopportunity.md § caveatsrather than dropping it silently — a dropped longitudinal clause is exactly how this archetype degrades back intoatomic-visit(ace#1462). - Payment: one payment unit for the whole arc — not one per
phase. The sequence is expressed in
entity_id, not in the number of payment units. (Contrastmulti-stage, which really does take one PU per stage.) On a componentized programme the per-component PDD payment terms set the PU count instead — see § Componentized programmes below; this rule then governs onlyentity_idshape. entity_idis the load-bearing field for this archetype. It must carry entity + sequence position, per the PDD'spayment-unit-entity-iddecision — e.g.concat(<case_id>, '-', <activity_code>). Resolve every component to its owncalculatebind in the released CCZ before writing the record — a component is a NODE NAME, not a semantics. Three failure modes to check, and the third is invisible to a reader of the composite alone:- the case id alone folds the whole arc onto one entity row;
- a
concat(username, today())-style key silently reverts to cross-sectional grouping; - the sequence component is present but UNCLAMPED, so the
per-entity grouping the PDD mandates silently does not hold.
None of the three is what stops a payment: a repeat key is paid again
(ace#2512), so the PDD's per-entity cap binds only through the
payable_slotform_field_rulesrow of Step 5 — record whether it was written. The record must state, per component, whether the PDD's sequence/clamp semantics are actually implemented — not merely that the component is present. On spark-facilitator/20260828-0703 the key readconcat(<case>/@case_id, '-', /data/fcap_step/step, '-', /data/meeting_summary/meeting_index, '-', <meeting_type>); the PDD's third component ismin(<meetings_on_current_step>, 3), and the clamp lives entirely insidemeeting_index's own bind. That app was correct — but an UNCLAMPEDmeeting_indexproduces a byte-identical composite, passes both failure modes above, and puts every 4th+ meeting per step on its own key (payable either way without the Step 5payable_slotrule, ace#2512). Phase 6'sdelivered >= 1does not catch it either; it takes a 4th meeting on one step to surface. Phase 3'sentity-id-grainnow reports these asresolvedThroughIntermediate— read that list and resolve each entry (ace#1810).
- Soft flags (Layer B/C): progression and sequence-integrity checks
have no Connect toggle; log them in
opportunity.mdand leave them toflw-data-reviewin Phase 6.
focus-group
- Verification: same
form_field_rules-only surface as above (venue GPS was never meaningful here, and is unavailable regardless). Enforce the attestation form's consent / session-held clauses as field rules; the required audio attachment is CCZ-enforced. - Payment: one payment unit per completed group session — set
max_totalto the PDD's planned session count. Do NOT model per- participant payment; the unit is the session.
multi-stage
- Configure verification + payment for Stage 1 only. Subsequent stages get their own opportunities (or their own payment-unit calls on the same opportunity, depending on the PDD's stage-overlap pattern).
Componentized programmes (products.mode: componentized) — any archetype
mode is a second axis, orthogonal to archetype:. When
run_state.phases.idea-to-design.products.mode is componentized, the
programme was authored as separately-designed components, and each
component PDD declares its own payment terms — so payability is
heterogeneous by construction (ace#2163, observed on
poverty-graduation/20260905-1345 and poverty-graduation/20260908-0510).
The archetype rules above still apply, but only to what they actually
govern:
- The archetype rule governs
entity_idSHAPE only — never the payment- unit COUNT.longitudinal-visits' "one payment unit for the whole arc" is a rule about where the sequence is expressed (inentity_id, not in a PU per phase). It is not a PU budget, which is why it composes with a componentized programme instead of conflicting with it. Keep everyentity_idrequirement of the declared archetype (entity + sequence position, the three failure modes above). - Per-component PDD payment terms set the payment-unit count. Read every
component PDD (
products.components[].pdd_file_id, plus the overview's Downstream Phase Guide where it addresses Phase 4) and build one payment unit per component the terms make payable, each with that component's own deliver unit(s),amount,max_totalandmax_daily. A component its PDD declares not separately payable (e.g. enrolment) gets no unit. Never put two independently-payable components' deliver units in one PU'srequired_deliver_units: that field requires ALL listed units before payment triggers, so one PU over a targeting survey and an asset delivery withholds the survey payment until the asset lands months later — a payment model that cannot pay as designed. - A payability that depends on an undeclared deployment choice is a
deployment parameter, not a silent pick. Example: a consumption-support
component paid for in-person distribution but not for remote transfer,
with the transfer method undeclared. Do not choose a reading here. Write
one decision row (the open question —
review_ask: recommended-confirmation,owner: partner, the method named as the parameter inplain_question, both readings inoptions) and make the Deliver app (Phase 3), this skill's payment units and Phase 5's knowledge base carry the SAME working reading — the one the Phase 3 Deliver app already built, read from3-commcare/pdd-to-deliver-app_summary.md— and cite that row. Three phases answering it three ways (paid DU in the app, no PU on Connect, "unpaid" in the bot) is the failure this rule exists to prevent. - Record the precedence. Write a decisions row (
cs-payment-unit-shapeor similar) naming the archetype, the per-component PU set, and each component left unpaid with the PDD clause that makes it so.
MCP Tools Used
- Google Drive:
drive_read_file,drive_create_file(always withparentFolderId = phaseFolderId— the4-connectfolder ID, never a path string),update_yaml_file - Connect (
ace-connectMCP, 0.10.47+):connect_create_opportunity— RESTPOST /api/programs/<id>/opportunities/connect_get_opportunity— verify after create (HTML-driven read, Step 4 verify-after-create, and again at Step 6.6 for the four dashboard fields that are unreadable until activation — ace#1647)connect_set_verification_flags— still HTML-driven (no REST yet)connect_create_payment_units— RESTPOST /api/opportunities/<id>/payment_units/(atomic list)connect_list_payment_units— verify after create (Step 6 verify-after-create — this is the canary that catches PU malformation before it cascades to Phase 8 invites)connect_send_flw_invite— RESTPOST /api/opportunities/<id>/invite_users/
Mode Behavior
- Auto: Create + configure end-to-end, proceed
- Review: Present configuration spec for approval before calling
connect_create_opportunity(the highest-stakes step — opp creation is harder to roll back than verification-flag adjustments)
Dry-Run Behavior
When --dry-run is active:
- Write the full opportunity configuration spec (all fields that would
be POSTed, plus the per-deliver-unit verification + payment-unit
matrix) to
comms-log/dry-run-connect-opp-setup.md - Do not call any
connect_*mutation atom - 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. Rule rows carry scope (record | entity | worker | programme) and enforcement (enforced | by-design | gap) — or omit both and the atom computes them; a per-worker limit is enforced by the payment unit's max_daily / max_total, never first by an app check, and a rule Connect refused is a gap, never by-design. 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 load-bearing defaults to the per-run
ACE/<opp-name>/runs/<run-id>/decisions.yaml. The bar criterion and
schema live in skills/idea-to-pdd/SKILL.md § Decisions Log Convention
(canonical authority). The list below catalogs decisions that commonly
qualify under the bar for this phase — a working template, not a
required set. The skill applies the bar criterion and emits whatever
rows meet it; the catalog is a teaching device that improves over time.
Common load-bearing decisions for Phase 4
| ID | Question | Map to surface |
|---|---|---|
verification-flags |
Which verification flags (gps, photo, location toggle, duration thresholds) does the opportunity require? | PDD Verification Mechanism; downstream Phase 6/8 verification |
payment-unit-shape |
Per-visit fixed amount, tiered, milestone-gated, etc.? | Connect payment-unit creation; PDD Payment Rate |
opportunity-end-date |
When does the opportunity close? | PDD Timeline numeric; gates Phase 9 monitoring cadence |
A close date claims a buffer only when there is one. Write "including a two-week buffer" (in plain, reasoning or the value) only when the close date is LATER than the delivery end the run states (Phase 1's opportunity-dates row). When delivery ends on the close date, state the date plainly — any slack in the PDD's timeline arithmetic sits inside the delivery window, not after it. On spark-facilitator/20261001-2208 the row read "closes on 26 February 2027, including a two-week buffer" next to "delivery from 2 November 2026 to 26 February 2027", copying "2 weeks buffer" from the PDD's timeline line. Enforced: decisions_append_rows refuses such a row with UNSUPPORTED_BUFFER_CLAIM (lib/buffer-claim.ts, test/lib/buffer-claim.test.ts). The same holds for the opportunity description text: never describe a buffer the dates do not leave.
The orchestrator's Phase Write-Back Verifier (agents/ace-orchestrator.md
§ Phase Write-Back Contract § Decisions log clause) enforces the
contract; the renderer (skills/decisions-render) regenerates the gdoc
at end of every phase.
Each row this skill writes uses phase: "4-connect" and
skill: "connect-opp-setup". Append via the decisions_append_rows MCP
atom (ace-decisions server) — do not hand-construct YAML and do not
write decisions.yaml via update_yaml_file. The atom validates each row
against lib/decisions-schema.ts v6 at the call boundary; misspelled
keys (decision, rationale, default, options_considered, notes)
are rejected before they touch Drive, and so is any row missing
evidence_basis (stated | inferred | conflicting — mandatory on
every new row; conflict_signals required when conflicting). Contract:
skills/idea-to-pdd/SKILL.md § The evidence_basis contract. (ace#1485)
Tool call:
decisions_append_rows({
runFolderId: <run-folder file_id>,
opportunity: <opp-slug>,
run_id: <run-id>,
rows: [
{
id: "cs-verification-flags",
phase: "4-connect",
skill: "connect-opp-setup",
question: "which verification flags does this opportunity require",
"ai-default": "duplicate=true, gps=false, catchment_areas=false",
options: [
"duplicate=true, gps=false, catchment_areas=false",
"duplicate=true, gps=true, catchment_areas=false",
"duplicate=true, gps=true, catchment_areas=true"
],
source: "PDD §6/§8 verification mechanism",
status: "ai-default",
evidence_basis: "stated",
plain: "Connect checks each visit for duplicates only; it does not check location.",
value_set_by: "ace",
reasoning: "Smoke opp — no GPS or photo capture in scope; duplicate guard only."
},
...
]
})
Every verification rule, latitude and ambiguity is a decision row (REQUIRED)
Step 8a's rule, [ACE] latitude and [FIXED] ambiguity rows go in the same
decisions_append_rows call as the catalogue rows above, and the row is the
only rendering — nothing else lists these calls. The catalogue alone left
"where is the PDD's duplicates rule enforced" unanswerable on the run page
(ace#2384).
| Row field | From the entry |
|---|---|
id |
connect-rule-<slug> / connect-latitude-<slug> / connect-ambiguity-<slug>; slug from the PDD § and the subject, so a re-run re-derives the same id |
phase, skill |
4-connect, connect-opp-setup |
question |
rule: "Where is the PDD verification rule '<quoted>' enforced?"; latitude / ambiguity: what the PDD left open or said two ways at that § |
ai-default |
rule: the enforcement-point category as one of the options below; latitude / ambiguity: the chosen value or resolution as a short label |
options |
rule: Connect form_field_rules, Connect deliver_unit_checks, Connect submission window, Connect payment unit limit, CCZ constraint, Not configurable on Connect — applied elsewhere, Not configurable on Connect — not applied; latitude / ambiguity: the chosen label plus each alternative weighed. A per-worker rule (a daily or total cap per worker) is Connect payment unit limit, never CCZ constraint — see Step 8a's scope paragraph |
scope, enforcement |
rule rows only: record | entity | worker | programme and enforced | by-design | gap — set, or omit both for the write boundary's classifyRule. A rule Connect refused and nothing else holds is gap, never by-design |
plain_value |
the ai-default in words — REQUIRED whenever the ai-default is itself jargon (skills/_decisions-review-fields.md), which three rule labels are: Connect form_field_rules → A Connect payment rule on the submitted form, Connect deliver_unit_checks → A Connect check on each delivery, CCZ constraint → A check in the app's form. Name the record when you can (A Connect payment rule on the meeting record). If you omit it on those three labels the write boundary stamps the default wording (RULE_ENFORCEMENT_PLAIN_VALUE, lib/decision-review.ts; ace#2654); the other labels read plainly and need none |
plain, check_at, correct_looks_like |
plain REQUIRED (one line for a partner, the rule may be quoted); check_at the Connect setting or app form › field a reviewer opens; correct_looks_like what they see there when it is right |
source |
the entry's PDD §, naming the document |
evidence_basis |
[ACE] latitude → inferred; [FIXED] ambiguity → conflicting; rule → stated when the PDD itself names where it is enforced, inferred when ACE chose, conflicting for Not configurable on Connect — not applied (the PDD requires it and Connect cannot hold it) |
conflict_signals |
conflicting rows only, at least 2 entries, each cited |
value_set_by |
ace |
status |
ai-default, always |
reasoning |
why, plus — on a rule row — the precise placement and its evidence (Step 8a's list); an ambiguity left OPEN begins OPEN —; it ends Spot-check: <the Connect setting, or the CCZ form › field>. |
Keep the Spot-check: sentence at the end of reasoning even when you set
check_at: the write boundary derives check_at from it when a producer
omits the field. The boundary checks it:
verify_phase_artifacts(phase='connect') returns a decisions report
(lib/build-phase-decisions.ts) that fails when this skill wrote
connect-opp-setup.md but zero rows in 4-connect under
skill: connect-opp-setup, and warns on live partner rows with no plain.
Worked example — one rule held on Connect's form-field rules (its jargon
ai-default carries plain_value), and two rules Connect cannot hold
(connect_set_verification_flags refuses duplicate / gps /
gps_radius_meters, ace#1013): one the app holds instead, and one nothing
holds (a known limitation, enforcement: gap):
decisions_append_rows({
runFolderId: <run-folder file_id>,
opportunity: <opp-slug>,
run_id: <run-id>,
rows: [
{
id: "connect-rule-survey-completed",
phase: "4-connect",
skill: "connect-opp-setup",
question: "Where is the PDD verification rule 'only a completed survey is payable' enforced?",
"ai-default": "Connect form_field_rules",
plain_value: "A Connect payment rule on the survey record",
options: [
"Connect form_field_rules",
"CCZ constraint",
"Not configurable on Connect — applied elsewhere",
"Not configurable on Connect — not applied"
],
source: "Targeting PDD §9 [FIXED]",
status: "ai-default",
evidence_basis: "stated",
scope: "record",
enforcement: "enforced",
plain: "\"only a completed survey is payable\" applies to each survey; Connect pays a survey only when the worker recorded it as completed.",
check_at: "Connect › opportunity › verification rules",
correct_looks_like: "A rule is listed that pays only surveys marked completed.",
value_set_by: "ace",
reasoning: "Connect form_field_rules: survey completed — form_field_rules_saved: 1. Spot-check: Connect › opportunity › verification rules."
},
{
id: "connect-rule-one-survey-per-household",
phase: "4-connect",
skill: "connect-opp-setup",
question: "Where is the PDD verification rule 'one payable survey per household' enforced?",
"ai-default": "Not configurable on Connect — applied elsewhere",
options: [
"Connect form_field_rules",
"CCZ constraint",
"Not configurable on Connect — applied elsewhere",
"Not configurable on Connect — not applied"
],
source: "Targeting PDD §9 [FIXED]",
status: "ai-default",
evidence_basis: "conflicting",
scope: "entity",
enforcement: "enforced",
plain: "\"one payable survey per household\" applies to each household; Connect cannot check it, so the app holds it by giving each household a single payable record.",
check_at: "Deliver app › Targeting survey › registration",
correct_looks_like: "A second survey of the same household is not counted as a new payable record.",
conflict_signals: [
"Targeting PDD §9 [FIXED]: one payable survey per household",
"connect_set_verification_flags refuses duplicate (ace#1013): no Connect-side duplicate check"
],
value_set_by: "ace",
reasoning: "CCZ: Targeting survey / entity_id — payability-scoped key. Spot-check: Deliver app › Targeting survey › registration › entity_id."
},
{
id: "connect-rule-gps-within-50m",
phase: "4-connect",
skill: "connect-opp-setup",
question: "Where is the PDD verification rule 'visit GPS within 50 m of the household' enforced?",
"ai-default": "Not configurable on Connect — not applied",
options: [
"Connect form_field_rules",
"CCZ constraint",
"Not configurable on Connect — applied elsewhere",
"Not configurable on Connect — not applied"
],
source: "Targeting PDD §9 [FIXED]",
status: "ai-default",
evidence_basis: "conflicting",
scope: "record",
enforcement: "gap",
plain: "\"visit GPS within 50 m of the household\" is not checked anywhere in this build; Connect has no setting for it.",
check_at: "Connect › opportunity › verification",
correct_looks_like: "No location-distance check is listed.",
conflict_signals: [
"Targeting PDD §9 [FIXED]: visit GPS within 50 m of the household",
"connect_set_verification_flags refuses gps / gps_radius_meters (ace#1013)"
],
value_set_by: "ace",
reasoning: "Not configurable on Connect — not applied anywhere in this build. Spot-check: Connect › opportunity › verification."
}
]
})
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-10-19 | connect_list_opportunities sends filtered=1 (ace#3211). Connect's opportunity list now applies default filters (status=Active, is_test=No) unless the request carries filtered=1; every ACE opportunity is is_test, so the listing came back complete: true, total_count: 0 on orgs holding opportunities, blinding the Step 4 504 recovery, the OPPORTUNITY_ALREADY_EXISTS guard and Step 4a's Σ. Every page of the walk now carries it. Enforced: test/mcp/connect/unit/list-opportunities-filtered.test.ts. |
ACE (#3211) |
| 2026-10-18 | ## Inputs are parameters with resolvers. New § Operations on an existing opportunity names Step 5 (verification flags), Step 6 (payment units) and Step 4's passing-score read-back and repair as the one written source for those mechanics, runnable on an existing opportunity_id; connect-opp-modify points here instead of restating them. New § Existing objects. The stale Phase 8 selected_llo input row is gone (Phase 4 runs before Phase 8; the holding org comes from connect_orgs). |
ACE (#3148 batch 3) |
| 2026-10-03 | Build memo retired — decisions.yaml is the review artifact (schema v6). Step 8 no longer ends connect-opp-setup.md with a memo section; new Step 8a records one rule row per PDD verification rule (Where is the PDD verification rule '<rule>' enforced?, with plain, check_at, and scope / enforcement set or computed by classifyRule), plus latitude and ambiguity rows, and a rule Connect refused is a rule row with enforcement: gap that decisions_enrich turns into a review ask; products.connect.build_memo is no longer written, and the Phase 4 boundary fails a written summary with zero rows under skill: connect-opp-setup. Contract: docs/decisions-contract.md. |
ACE team |
| 2026-10-01 | Step 8 names a per-worker rule's PER-WORKER enforcement point first (build-memo-eval, spark-facilitator/20260926-1800). Both per-worker caps — "at most 1 payable meeting per CBF per day" and "total cap 21 per CBF" — were written CCZ: … ; also Connect payment unit … and recorded as CCZ constraint decisions, so the build memo told the reviewer the app enforced them. The app checks are keyed on the COMMUNITY case (the date check, the 7 × 3 per-step clamp) and cannot bound a worker across communities; only the payment unit's max_daily / max_total do. Where applied gains Connect payment unit max_daily / max_total (per worker), the decision options gain Connect payment unit limit, and a scope paragraph says which goes first. Enforced: lib/build-memo-compose.ts checkEnforcementScope via scripts/build-memo-compose.ts --check (test/lib/build-memo-compose.test.ts, on that run's table and decision rows). |
ACE team |
| 2026-10-04 | Rule rows carry plain_value when their ai-default is jargon (ace#2654, spark-facilitator/20261004-1706). The row contract mandated the closed options labels Connect form_field_rules, Connect deliver_unit_checks and CCZ constraint as ai-default but never mentioned plain_value, which the plain-language gate requires whenever ai-default is jargon — so 9 of that run's 12 compliant rule rows failed decisions_enrich and had to be re-emitted with supersedes. The table gains a plain_value row and the worked example a Connect form_field_rules row carrying it; the write boundary now stamps a default for those three labels (RULE_ENFORCEMENT_PLAIN_VALUE, lib/decision-review.ts). Enforced: test/skills/connect-opp-setup-plain-value.test.ts runs the real gate over every rule label and every worked-example row. |
ACE team |
| 2026-10-01 | Step 11: post-condition read-back (inline QA). The atoms validate each call at the boundary, which catches a bad call but not a valid-but-wrong end state. Step 11 reads the live opportunity back (connect_get_opportunity, connect_list_payment_units, connect_list_flw_invites) and checks it against what the skill decided with checkOppPostcondition (lib/connect-opp-postcondition.ts): readable, is_test, activation PROVEN by an invite row (not the create-side active flag, ace#617), payment units by name, verification rules persisted, test user invited. Writes products.connect.postcondition; a non-empty phase6_blockers makes Phase 6 refuse the walk with the reason. Live on spark-facilitator/20260926-1800: 5 of 6 pass; verification_rules_persisted fails (2 decided, 0 saved — the self-managed refusal, ace#2419); no Phase 6 blocker. |
ACE team |
| 2026-05-08 | Add ## Decisions Log section: 3 anchor rows (verification-flags, payment-unit-shape, opportunity-end-date) + bar-criterion reference. Pairs with decisions-log PR #4 (Phase 3-10 writes). |
ACE team (decisions-log PR #4) |
| 2026-05-10 | Move opp activation + ACE test-user invite from Phase 9 into Phase 4 (new Step 6.5 + rewritten Step 7). Closes the chicken-and-egg gap where Phase 6 app-screenshot-capture produced placeholder screenshots because the test user wasn't on the new opp yet — the opp couldn't be activated until Phase 9, but the test user couldn't be invited until activation. Phase 9 llo-launch now hits its idempotent skip-if-active path on every ACE-driven run; it still sends the real-LLO invite to the awarded LLO. Also: tighten Step 4 is_test from "defaults true server-side" to "set explicitly to true" — ACE is in dogfood mode and every opp it creates must be test-flagged so prod analytics, payment exports, and partner dashboards exclude these runs. |
ACE team |
| 2026-06-01 | Step 6.5: always attempt /activate/; treat only the "already active" error as the skip signal (jjackson/ace#624). The managed-opp create endpoint returns a create-side active: true flag that is NOT the /activate/ state transition invite_users/ requires — so the old "read active, skip if true" pre-check skipped the only call that enables invites, and Step 7 failed. Calling /activate/ on such an opp succeeds; it rejects only an opp that already completed the transition. Removed the pre-check; now call unconditionally and branch on the result, not the read-back flag. |
ACE team |
| 2026-06-01 | Step 6.5: verify activation via Step 7's invite, not the scraped active flag (closes jjackson/ace#617, and its #634 duplicate). Dropped the post-activate connect_get_opportunity read-back check — that flag returns true on un-transitioned opps and can't distinguish a real /activate/ from a no-op (the same create-side flag that motivated #624). The authoritative confirmation is connect_send_flw_invite in Step 7 succeeding: invite_users/ hard-rejects a non-active opp, so a successful invite is the only proof the transition landed. |
ACE team |
| 2026-08-12 | Step 5 rewritten around what Connect's verification form actually has (dimagi-internal/ace#1013). The step prescribed gps / duplicate / catchment_areas / location / check_attachments, all five of which were removed from the form — the atom accepts them, Django drops them, and it still returns ok: true, so a compliant agent produced an artifact claiming enforcement that never existed. Step 5 now leads with form_field_rules (the only server-side Layer A surface), requires reading question_path / question_value from the RELEASED CCZ rather than the PDD's prose (Nova flattens groups — a rule on a non-existent node enforces nothing and reports success), documents the 25-char name cap and the duration_seconds-is-really-minutes trap, and requires verifying via form_field_rules_saved. Archetype verification lines updated to match. Found live on spark-facilitator/20260810-0737. |
ACE team |
| 2026-08-14 | Step 5: question_path is a JSONPath into the HQ form-JSON doc, not an XForm XPath (dimagi-internal/ace#1301, blocks-e2e). The step (and the atom's own schema) told agents to write /data/<group>/<question>. Connect evaluates the field as jsonpath_ng.ext.parse(f"$.{question_path}") against the whole forwarded document, so an XPath raises JsonPathParserError inside clean_form_submission — uncaught on the deliver path — and HQ's forward of every PAYABLE visit returns HTTP 500. The device is unaffected (CommCare shows "1 form sent to server!" because HQ did accept the submission), so the run looks green while Connect has no visit and the opportunity cannot pay. Observed live on spark-facilitator/20260813-2126: motech log 500 for the deliver form, 200 for every registration form on the same app; rewriting the rows to form.meeting_basics.* and requeueing the same payload moved Connect from delivered: 0 to delivered: 1 / approved: 1. Correct spelling is form.<group>.<question>; lib/connect-question-path.ts now normalises /data/a/b → form.a.b on both incoming and replayed rows so a poisoned opportunity self-repairs. Also: form_field_rules_saved is no longer treated as sufficient evidence — it proves storage, not evaluability. |
ACE team |
| 2026-08-14 | Step 6 verify-after-create re-pointed at the CREATE RESPONSE (dimagi-internal/ace#1026, umbrella #1022). The step mandated a [BLOCKER] when connect_list_payment_units returned an empty required_deliver_units — but that atom's HTML-scraped read path returns [] (and description: "") unconditionally, so the halt condition was a constant of the read path, not a fact about stored state, and a compliant agent would halt every healthy run. Observed on spark-facilitator/20260813-2126: create response required_deliver_units: [6617], list call [], opp activated normally. The #843 check's intent is preserved — it now asserts against the create response (Connect's own post-persist serialization), with connect_list_payment_units demoted to corroboration on id / payment_unit_uuid / name. |
ACE team |
| 2026-08-25 | Step 4's verify-after-create no longer asserts four fields it cannot read; new Step 6.6 verifies them post-activation (dimagi-internal/ace#1647). A never-activated opportunity renders only the edit-form half of connect_get_opportunity, so start_date, total_budget, learn_app.cc_app_id and deliver_app.cc_app_id were all absent at the moment Step 4 ran — four mandated comparisons passing vacuously on every run while the log read as verified. Same shape as ace#1449 (passing_score compared against a field the surface does not render). Step 4 now splits readable-here (name, short_description, description, end_date, is_test) from deferred, and requires an absent field to be recorded unreadable-at-this-point rather than counted as a match; Step 6.6 re-reads after /activate/ and [BLOCKER]s on disagreement OR on continued absence. Verified live on bednet-check-2-visit/20260825-1310 Phase 4 (same opp, same atom, either side of activation). Upstream half stays on #1637. |
ACE team |
| 2026-08-25 | Step 6: id dropped from the connect_list_payment_units corroboration allowlist (dimagi-internal/ace#1642). The listing's first column is the row #, so the PU that connect_create_payment_units returned as id: 2495 reads back as id: 1 — a per-opp display index, not the server PK, and passing it where a server id is required reproduces the du.id vs du.server_id rejection. Corroboration is now payment_unit_uuid / name only, with payment_unit_uuid named as the durable identifier. Atom description in mcp/connect-server.ts corrected to match. Observed on bednet-check-2-visit/20260825-1310 Phase 4. |
ACE team |
| 2026-05-10 | State consolidation PR a: retire connect-state.yaml; emit a single run_state.yaml.phases.connect-setup.products.connect block at end of Step 10. Step 7 holds invite metadata in memory rather than writing immediately. (Initial implementation dual-wrote to opp.yaml.connect; corrected on 2026-05-11 — runs are now independent. opp.yaml.connect.program is durable cross-run state written by connect-program-setup; opp.yaml.connect.opportunity / ace_test_user are no longer written here.) See docs/superpowers/specs/2026-05-10-state-consolidation.md. |
ACE team |
| 2026-09-08 | Step 4's single-active-opp block stops asserting an enforcement and a deactivation it cannot support (dimagi-internal/ace#2290). It read "Connect enforces one active managed opportunity per accepted ProgramApplication" and mandated a WARN reading "will deactivate prior active opp …", both generalized from a single 2026-05-06 observation (leep-paint-collection, jjackson/ace#106 finding 11). Counter-observed on bednet-check-2-visit/20260908-1544: ten opps on program efb8af66-fbfd-488f-bf99-66f864cea68b concurrently active: true, and a direct post-create re-read of 5fdee3b8-e859-4aa7-a4be-8ad6f727917a returned active: true / dashboard_read: "ok" — no deactivation, so the WARN that run emitted was a prediction written into the artifact as fact. Both observations are now recorded as observations; the accept-application short-circuit on a self-managed org is named as the leading HYPOTHESIS with upstream-regression-triage against dimagi/commcare-connect as the way to settle it. The scan and the hydrate: true read are unchanged — the WARN is now singular-to-N, reports the count plus ids, drops the "close prior opps first" remedy (it contradicts per-run opp accumulation by design), and says "MAY deactivate — unverified" instead of predicting. Not CI-gateable: it is a claim about another system's behaviour. |
ACE team |
| 2026-09-06 | Stop routing concerns to a gate brief that does not exist (dimagi-internal/ace#1884). 0.13.116 removed the per-skill gate-brief file class and the ace#1880 sweep removed the remaining *.md PATHS, but prose directives naming the gate brief as a DESTINATION survived in 15 files — a concern "surfaced in the gate brief" is surfaced nowhere. Repointed at the verdict YAML's auto_surfaced block, which is what the orchestrator actually renders the pause summary from. Gated by the new destination check in test/skills/gate-brief-removal-complete.test.ts. |
ACE team |
| 2026-09-11 | Step 8 ends connect-opp-setup.md with the Phase 4 half of the run's build memo (ace#2371). Every poverty-graduation PDD names a build memo as the review artifact, and the Connect-side skills mentioned it zero times — so where a PDD verification rule was actually applied (on Connect, in the CCZ, or nowhere) was recorded nowhere a reviewer would look. That mattered concretely because connect_set_verification_flags refuses duplicate / gps / gps_radius_meters (ace#1013), leaving a Targeting PDD's duplicates rule with no Connect home. The new section maps one row per rule across the WHOLE PDD set (component PDDs included) to a closed Where applied vocabulary in which "Not configurable on Connect" is a required statement, not an omission, plus opportunity-config [ACE] latitudes and [FIXED] ambiguities. skills/build-memo collates it verbatim; products.connect.build_memo is the one products.connect key this skill does not write. Enforced: test/skills/build-memo-contract.test.ts. |
ACE team |
| 2026-09-11 | Every Step 8 build-memo entry — each verification rule, [ACE] latitude and [FIXED] ambiguity — is also a decisions.yaml row (ace#2384). The mapping of "where is each PDD verification rule enforced" existed only as memo prose, so a reviewer could read it but not answer it on the run page. Rule rows draw ai-default from the closed Where applied categories; the specific form/field or Connect setting goes in reasoning as Spot-check: because ace-web drops unknown keys. The Phase 4 boundary fails a section with entries and no rows. Enforced: lib/build-phase-decisions.ts via verify_phase_artifacts, test/skills/build-phase-decision-rows.test.ts. |
ACE team |
| 2026-09-16 | Phase 4 creates ACE's build/QA opportunity, never the LLO's — the network-manager / program-manager model (operator decision 2026-09-16). Step 3a's third branch said of a distinct target_organization_slug: "Phase 9 already handled the round-trip; skip this sub-step." That precondition is impossible — connect-setup is Phase 4 and llo-onboarding is Phase 9, so no invite has been sent and none accepted when this skill runs; the create it waved through could only reject with Organization must have an accepted application for this program. Worse, had it succeeded the opportunity would be stranded: target_organization_slug is create-time only and connect_update_opportunity carries no org field, so it could never be handed to the LLO. That branch now HALTs with the remedy. Step 3 states the model: PM creates the program → invites the NM → NM accepts → PM creates the NM's opportunity (llo-onboarding § 2b, on freshly-copied cc_app_ids). Two opportunities is structural, not a workaround — it falls out of the ace#573 DeliverUnit.payment_unit FK constraint. | ACE team |
| 2026-09-26 | Phase 4 runs the real PM→NM flow when connect_orgs.nm_org is configured (operator decision, Jon). Step 3 was rewritten: 3.0 resolves pm_org / holding_org via phase4Orgs() (lib/connect-orgs.ts); 3a invites + accepts the HOLDING org (the NM org in pm-nm mode) and captures program_application_id from the invite POST; Step 4 passes target_organization_slug = holding_org and asserts the echo; Step 5 sets verification rules at pm_org, which Connect now serves because request org ≠ holding org (ace#2419 — a self-managed opp can never carry them; observed spark-facilitator/20260925-1536). Proved live 2026-09-26 on program 777f9060-8f26-43bb-aad1-b8f1d7df22db / opportunity 7bfcb845-015c-416a-a200-9795c68dfc55 (NM-held; rule saved at the PM-org URL, refused at the NM-org URL). Step 10 records pm_org_slug + holding_org_slug (+ legacy organization_slug). The partner LLO's opportunity is still created in Phase 9. Self-managed behaviour is unchanged when nm_org is unset. | ACE team |
| 2026-10-15 | Step 7: a match === null read-back now waits, re-reads and re-sends ONCE before it WARNs (dimagi-internal/ace#2946). On smallholder-seasonal/20261014-0859 (opp 30605010-4be8-49e3-954c-deeb0b4ad8d6) the invite sent right after /activate/ returned queued but created no row across three reads; one identical re-send produced both rows, and the requester row's invited_date equalled the re-send time — so the first send created nothing, it was not lag. The step previously went straight to WARN, so a run following it recorded invite_row_present: false and a Phase 6 blocker that one idempotent re-send would have cleared. Now: wait ~60 s → re-read → re-send once → re-read → only then invite_row_present: false + WARN; both sends are logged to comms-log/observations.md. Enforced: test/skills/connect-opp-setup-invite-resend.test.ts. | ACE team |
| 2026-10-16 | Step 4.0: a re-dispatched Phase 4 finishes the opportunity it already created instead of minting a second (dimagi-internal/ace#2083 fix (1)). A create with no payment unit yet is setup_incomplete in Connect (is_setup_complete needs one), and the skill had no resume path while products.connect landed only at Step 10 — so a dispatch killed between Steps 4 and 6 left an opportunity no read can open, and the re-dispatch created another. There is no delete API; Connect's delete_stale_opportunities purges visit-free opportunities only at six months, and each orphan costs one EXPECTED_OPP_BUDGET in every program-ceiling sum (11 of 71 ai-demo-space rows, 2026-09-06). Now Step 4.0 lists by run id and decides create / adopt / halt with planOpportunityCreate (lib/connect-opp-resume.ts), the id is recorded the moment the create returns (ace#2412), and connect_create_opportunity itself refuses an is_test create whose exact name exists (OPPORTUNITY_ALREADY_EXISTS) or whose listing is incomplete (OPPORTUNITY_EXISTENCE_UNKNOWN). Enforced: test/lib/connect-opp-resume.test.ts, test/mcp/connect/unit/opportunity-duplicate-guard.test.ts. | ACE team |