pdd-to-work-order
Draft a contractual Work Order as a Google Doc from an approved PDD; partner details stay placeholders unless an LLO is given. Needs an approved PDD.
SKILL.mdskills/pdd-to-work-order/SKILL.md
GitHubPDD to Work Order
Take the approved PDD and decisions.yaml and produce a contractual Work Order draft, rendered as a clean Google Doc suitable for human review and signature.
Inputs
| Source | Artifact | Used for |
|---|---|---|
| Phase 1 producer | 1-design/idea-to-pdd.md |
scope, deliverables, timeline, target population, success metrics, evidence model |
| Phase 1 producer | decisions.yaml |
load-bearing values (rate, FLW count, working language, candidate LLO, etc.) — read as-is |
| Run-root | inputs-manifest.yaml |
optional reference for partner identity if it was supplied as input |
| Opp inputs (optional) | inputs/operator-rulings.yaml |
per-opp operator rulings that override a standing design assumption (lib/operator-rulings.ts) — honour every ruling whose applies_to includes work-order (step 3, commercial terms) |
| Operator (optional) | --llo <slug> flag |
overrides partner-name placeholder |
| Skill-local reference | references/writing-style.md |
Required reading before synthesizing any prose token. Dimagi voice, modal verbs, partner-naming convention, bold-use rules, soft commercial language, canonical terminology (LLO/FLW/POC/verified visit), sentence-level templates, what to avoid. |
| Skill-local reference | references/style-guide.md |
Visual spec for the rendered Google Doc — consult when updating templates/work-order-template.md or the published WORK_ORDER_TEMPLATE_ID gdoc, not per-run. |
Products
ACE/<opp-name>/runs/<run-id>/1-design/pdd-to-work-order.gdoc— the work-order Google Doc (re-runs createpdd-to-work-order-2.gdoc,pdd-to-work-order-3.gdoc, etc.)run_state.yaml.phases.idea-to-design.products.work_order—{title, file_id}typed handoff. This skill is the sole writer.- Appended
wo-*rows inACE/<opp-name>/runs/<run-id>/decisions.yaml(merge-only — never overwrites existing rows).
Process
Read inputs in parallel. Issue one
drive_read_fileblock for the PDD, decisions.yaml, and inputs-manifest. Then read the skill-localreferences/writing-style.mdonce — it governs every prose token you synthesize below. Trust context across subsequent steps (do not re-read).Determine archetype from the PDD's frontmatter (
archetype: atomic-visit | longitudinal-visits | focus-group | multi-stage). The archetype branches the Scope of Work, Verification, Roles RACI, and Payment per-unit sections.Resolve contractual fields. For each work-order field, apply the inference order:
(a) If an existing
decisions.yamlrow from an earlier skill covers it (e.g.,payment-rate,flw-count,working-language,budget-plausibility), use that value as-is. Never duplicate or rename. Resolve to the row that is NOT superseded — a row carryingsuperseded_byis history, kept for the audit trail, and its value is wrong by construction. The log is append-only, so a mid-run correction cannot edit the original in place; it lands as a new row and the write boundary stampssuperseded_byon the one it replaced. UseresolveDecision(log, id)fromlib/decisions-schema.tsrather than a bare id lookup — a bare lookup on the canonical id is exactly what returns the stale value. On bednet-check-2-visit/20260814-2019,payment-rateheld a per-visit band that a later row corrected to a per-day band (ace#1420); following this step literally would have put the wrong rate into the Phase 4 payment unit and a contractual document (ace#1421).(b) If inferable from PDD body (Timeline → period of performance; Success Metrics + Budget → NTE; etc.), use the inference and emit a new
wo-*row capturing it.(c) If genuinely unknowable (partner name absent, WO# unknown, MSA date unknown), insert a bracketed placeholder like
[Partner Name]in the gdoc and emit awo-*row withstatus: open+notestelling the human what to fill in.The bracket convention does not COMPOSE inside a cell whose checker parses the whole cell. It brackets the unknown cell, not each unknown part of it. A field that reads as two things — Period of Performance is "start + end dates" — still gets ONE pair of brackets covering the entire value:
[Start and end dates set on contract execution], not[Start date on contract execution] to [Start + 10 weeks].pdd-to-work-order-qa § period_of_performance_completeaccepts a whole-cell single-bracket span (/^\[[^\]]+\]$/) and deterministically rejects two spans joined by "to" — an interior]fails the regex. A work order drafted before a partner is selected is the normal Phase 1 case, so this is the common path, not an edge (ace#1781; mirror of ace#1092, which fixed the same trap on the reader side).Commercial terms follow the solicitation. When a live (not superseded) decision row with an id starting
sol-orsolicitation-fixes rate framing or devices — e.g.solicitation-all-in-rate-and-devices,sol-devices-and-system-of-record-<run>— the work order follows it. It is the same partner reading both documents; a work order that says "cost devices separately, in addition to the not-to-exceed" next to a listing that asks for an all-in rate with devices inside it cannot be priced. Do not write the contradiction and defer it ("to be reconciled at contract execution") — that is howspark-facilitator/20261001-2208shipped it, with the deviation logged in both rows and nothing acting on it.When no such row exists yet (the normal Phase 1 order — this skill runs before Phase 8), apply the same standing operator directives the solicitation applies, so the two converge by construction: the per-unit rate is all-in — devices, connectivity, transport, supervision and reporting time sit inside it, never in a separately-funded line or "in addition to the not-to-exceed" (Jonathan 2026-09-08, ace#2265;
skills/solicitation-create/SKILL.md § The rate is ALL-IN) — and smartphones and data are assumed, so no device go/no-go, device-confirmation milestone or device-funding clause (Jonathan 2026-09-26;skills/idea-to-pdd/SKILL.md § Standing design assumptions). A programme whose subject IS device provision is scope, not this assumption.An operator ruling overrides the standing directive for this opp. When
inputs/operator-rulings.yamlcarries a ruling whoseapplies_toincludeswork-orderand whichoverrides: devices-assumed(spark-facilitator, owner ruling 2026-10-04: devices are costed separately and asked about), the work order states that devices and data are costed separately — outside the all-in rate, priced in the partner's response — while every other cost item stays inside the rate. Foroverrides: system-of-record, follow the ruling's wording. Record one decision row per applicable ruling withrulingDecisionRow(ruling, { phase: '1-design', skill: 'pdd-to-work-order' })(idwo-ruling-<id>), sent asai-default— the write boundary stampshuman-decided+decided_by/decided_atfrom the file. The commercial-terms check judges device sentences against the solicitation's device stance separately from its overall rate stance, so an all-in rate with devices outside it is consistent (lib/commercial-terms-consistency.ts).Enforced:
pdd-to-work-order-qa § commercial_terms_match_solicitationfails a work order whose rate/device terms disagree with a livesol-*/solicitation-*row, quoting both (logic:lib/commercial-terms-consistency.ts).
Common
wo-*rows to emit when load-bearing:ID Question Map to surface wo-numberSequence number for this WO under the MSA Header (placeholder if unknown) wo-period-of-performanceStart + end dates, as ONE cell value: either YYYY-MM-DD to YYYY-MM-DD/Mon DD, YYYY to Mon DD, YYYY, or a SINGLE bracketed placeholder spanning the whole cell (e.g.[Start and end dates set on contract execution]) — never two bracketed spans joined by "to", whichpdd-to-work-order-qa § period_of_performance_completerejectsHeader + Timeline section wo-total-not-to-exceed-usdTotal NTE budget cap Payment Terms section wo-payment-schedule-splitMilestone payment percentages (e.g., 40/60) Payment Schedule sub-table wo-mobilization-advance-pctMobilization advance % of cap Payment Schedule row 1 wo-reporting-cadenceFrequency of progress reports (default: weekly) Reporting sub-section wo-ethics-scopeWhat kind of data does the work touch? Options: operational-no-personal-data·operational-with-personal-data·patient-level-clinicalEthics section wo-data-storage-regionServer region for data storage (default: united-states). A qualifier such as "provisional, pending confirmation" goes inparams.caveat. Options:united-states·european-union·in-countryData Handling section Append
wo-*rows todecisions.yamlvia thedecisions_append_rowsMCP atom (ace-decisions server). Do not hand-construct YAML and do not useupdate_yaml_filefor this file — the dedicated atom validates each row againstlib/decisions-schema.tsv6 at the call boundary and is idempotent on re-runs.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. Contract values fixed at signature arevalue_set_by: external; say so inplain("set when the contract is signed"). The atom rejects a partner-facing row withoutplain, or with jargon in it. Rules, a worked row and thereview_asksemantics:skills/_decisions-review-fields.md; field contract:docs/decisions-contract.md.Tool call:
decisions_append_rows({ runFolderId: <run-folder file_id>, opportunity: <opp-slug>, run_id: <run-id>, rows: [ { id: "wo-period-of-performance", phase: "1-design", skill: "pdd-to-work-order", question: "what dates bound the work", "ai-default": "2026-05-22 to 2026-07-31", options: ["2026-05-22 to 2026-07-31"], source: "pdd-timeline", status: "ai-default", evidence_basis: "stated", plain: "The work runs from 22 May to 31 July 2026.", value_set_by: "external" }, ... ] })Field shape:
phase: "1-design"(ordinal-prefixed, notidea-to-design),skill: "pdd-to-work-order", andstatus: "ai-default"on every row this skill writes.decision,rationale,default,options_considered, andnotesare NOT valid keys — the schema isid/phase/skill/question/ai-default/options/source/status/evidence_basis/value_set_by, with optionalreasoning(andconflict_signals, REQUIRED whenevidence_basisisconflicting). The atom rejects any row that doesn't match.evidence_basisis mandatory on every new row (schema v5,lib/decisions-schema.ts):stated(the value is directly in a source),inferred(extrapolated beyond any source — say so inreasoning), orconflicting(resolves disagreeing sources; thenconflict_signalsmust enumerate at least two competing readings).DecisionRowStrictSchemarejects a row without it at the MCP boundary before any Drive write, so a row missing it never lands. Full contract:skills/idea-to-pdd/SKILL.md § The evidence_basis contract. (This section documented the v3 shape until ace#1485 — its own worked example was rejected verbatim.)value_set_byis mandatory on every new row (schema v5):acewhen the value is ACE's judgment to make from the source material,externalwhen the real value gets fixed later by someone else — a rate negotiated in a solicitation response, dates set on contract execution, an FLW count fixed at deployment. Nearly everywo-*row isexternal: the work order states ACE's best estimate, and the contract sets the real number.value_set_byis not an escalation path and does not gate anything. ACE still picks a value, still writes it asai-default, and the run still proceeds — exactly as before. The flag exists so a downstream phase does not cite a projection as a settled commitment, and so a later run re-deriving it differently reads as expected rather than as drift. Measured across 22 runs of two opps,wo-period-of-performance,wo-total-not-to-exceed-usdandpayment-rateproduced a different confident-looking answer on nearly every run, and the first hh-poverty run had originally recorded them correctly as "Deferred to deployment (Annex B); negotiated via solicitation response" — inside theai-defaultstring, where nothing could read it.When a load-bearing field is genuinely unknowable (partner name absent, WO# unknown), insert a bracketed placeholder like
[Partner Name]in the gdoc and pass the placeholder asai-default(e.g."ai-default": "[Partner Name]") plus areasoningline telling the human what to fill in.statusis still"ai-default"—"open"is not a valid status, andvalue_set_by: "external"is how you say "someone else sets this," not a status. A placeholder default isevidence_basis: "inferred"unless a source actually states it. One pair of brackets per cell — see step 3(c): a multi-part field likewo-period-of-performancegets a single span covering the whole value ("[Start and end dates set on contract execution]"), never per-part spans joined by "to".Canonical worked fixture with
wo-*rows:test/skills/pdd-to-work-order-qa/fixtures/good-decisions.yaml.Render the work-order template to a Google Doc.
The render path is not optional
docs_copy_templatefromWORK_ORDER_TEMPLATE_IDis the ONLY sanctioned way to produce this artifact. If the copy fails, retry once, and then HALT and report. Do not compose the document by any other route.Specifically forbidden, however reasonable it looks in the moment:
drive_create_file,drive_create_doc_from_markdown, or any other write that puts synthesized contractual prose into a document. A work order is a contractual artifact; an unbranded plain-text approximation of one is not a degraded deliverable, it is a different thing wearing the same filename.A provenance note is not a substitute for failing. On
turmeric-market-study/20260828-1108the copy failed transiently — the template was briefly unreachable by the service account — and the producer improvised: it synthesized the prose, wrote it to a blank doc, and explained itself inside the contract:"NOTE (render provenance): ... The styled Google Docs template (WORK_ORDER_TEMPLATE_ID) was not accessible to the service account at render time, so this document was produced directly from the synthesized contractual content rather than the branded template."
That paragraph is honest and it changed nothing.
pdd-to-work-order-qareturnedverdict: pass, 8/8 — correctly, because every other check in that suite is deliberately format-agnostic — the phase summary reported success, and a plain document went to the operator as the run's work order. Prose inside an artifact is not a gate. Only a halt is a halt.The failure was transient, which is the point. The template was verified copyable on 2026-09-09 (real copy, tables intact, tokens substituting), so nothing about the template or the environment prevents a recurrence — only this rule does.
Enforced:
pdd-to-work-order-qa § rendered_from_templatefails the artifact whether or not the fallback confesses (it also checks for template-only boilerplate). Preflight:/ace:doctor'swork_order_template_copyableprobe attempts a real copy before a run starts, so an unreachable template surfaces as a red doctor line rather than as a mid-run improvisation.- Resolve-or-create the
1-design/phase subfolder first —drive_create_folder({name: '1-design', parentFolderId: <run-folder file_id>, findOrCreate: true})— and pass its id (NOT the run-folder id) asparentFolderIdbelow. Passing the run-folder id lands the work order flat at the run root, which fails the Phase boundary'sverify_phase_artifacts(it walks1-design/; jjackson/ace#623). docs_copy_template(templateDocId=<WORK_ORDER_TEMPLATE_ID from env>, parentFolderId=<1-design folder id>, title="pdd-to-work-order.gdoc", replacements={...}). Pass all token replacements directly todocs_copy_template— it runs a singlereplaceAllTextbatch under the hood, no separatedocs_batch_updateneeded. If the run already has apdd-to-work-order.gdoc, title the new onepdd-to-work-order-2.gdoc, etc.- After the copy returns, call
docs_finalize_bullets(documentId=<new-doc-id>)(required). This atom applies real Google Docs bullet styling to paragraphs enclosed in<<<BULLETS_<NAME>_START>>>/<<<BULLETS_<NAME>_END>>>anchor pairs, deletes the anchors, and returns the count of pairs processed. The liveWORK_ORDER_TEMPLATE_IDtemplate carries FIVE anchor pairs (the two §2 scope blocks, §4.2 verified-unit criteria, §4.3 reporting, §8.1 permissions), so the expected result isanchorsProcessed: 5(a non-zeroemptyBulletsRemovedis normal when some bullet lines go unused). Aprocessed: 0result now means the anchors were destroyed (e.g. by a bad token replacement) — investigate before shipping the doc; it is no longer the expected no-op it was pre-anchor-landing (history: jjackson/ace#614; doc staleness caught as jjackson/ace#819). - Body tokens for bulleted regions (
{{scope_will_body}},{{scope_will_not_body}},{{verified_unit_body}},{{reporting_body}},{{permissions_body}}) take a\n-separated string with one bullet item per line.replaceAllTexthonors\nas paragraph breaks;docs_finalize_bulletsthen bullet-styles each resulting paragraph inside the anchor pairs. - Then call
docs_finalize_bold(documentId=<new-doc-id>)(required, and AFTERdocs_finalize_bullets). It converts the literal**bold**in your prose tokens into real Google Docs bold and deletes the markers, walking table cells recursively (§2 scope, §4.2 criteria and §8.1 permissions are all inside tables). It sets only theboldfield, so the template's fonts, colours and sizes survive. Ordering matters:docs_finalize_bulletsdeletes anchor paragraphs and therefore shifts document indices, so running bold first would style stale ranges.spansBolded: 0on a document whose tokens contained**means the markers were destroyed upstream — investigate rather than shipping flattened emphasis. Idempotent: a re-run finds no markers and reports 0. - Token-coverage check (class-level preventer, jjackson/ace#819). After
docs_copy_template+docs_finalize_bullets+docs_finalize_bold, read the rendered doc back (drive_read_fileordocs_get) and scan for any surviving{{— a leftover token means the skill's replacement keys and the live template's tokens have drifted (exactly how{{scope_will_body}}/{{scope_will_not_body}}leaked into a contractual artifact when this doc still described a single{{scope_body}}). On a hit, fix viadocs_batch_updatereplaceAllText and file the drift before proceeding. - Read
unmatchedReplacementson thedocs_copy_templateresult — it is not decoration (ace#2126). The scan above looks for a token the template kept; this covers the OPPOSITE direction — a key YOU pass that the template does not contain. That one leaves nothing behind to find, so the scan is structurally blind to it and the atom reports it instead: a non-emptyunmatchedReplacements(plus awarning) means those values were silently dropped from the rendered document. It is a report, not a rollback — the doc exists and looks fine. Treat any entry as a halt:{{partner_first_reference}}was dropped exactly this way onspark-facilitator/20260907-1120, and the Work Order shipped using "the partner" ~30 times with no antecedent whilepdd-to-work-order-qareturned 14/14 pass. Do not work around it by folding the value into a neighbouring token (this run folded the partner definition into the head of{{background_body}}; that renders correctly once and teaches the next run nothing). A dropped key means the live template gdoc andtemplates/work-order-template.mdhave drifted, and the fix is to add the slot to both./ace:doctor'swork_order_template_driftprobe names which side is missing it. - Do NOT write a
pdd-to-work-order.source.md. Sibling skills that publish markdown (idea-to-pdd, the fivetraining-*producers) persist their composed markdown sorun-surface-audit'sDOC-FIDELITY-UNVERIFIEDhas something real to diff (ace#1687 half 2,sourcePersistedinlib/artifact-manifest.ts). This skill is deliberately excluded and must stay that way:docs_copy_templateisdrive.files.copyplus areplaceAllTextbatch — Doc to Doc, with no markdown importer anywhere on the path — so the content-dropping regression that check guards cannot occur here, and there is no composed markdown to persist in the first place. Manufacturing one would hand the auditor a file this document was never produced from, i.e. a green comparison that means nothing. The drift that CAN happen here is a leftover template token, and the check directly above is its preventer. Pinned bytest/lib/source-persisted-artifacts.test.ts. - Tokens use
{{...}}snake_case: The template has SIX real Google Docs tables (header, timeline, payment schedule, RACI, data handling, signatures). Each cell that varies per work-order contains ONE{{snake_case}}token. The skill replaces tokens viareplaceAllText— one cell-sized value per token. Token groups:
Prose-token synthesis — apply
references/writing-style.mdto every body token. The most load-bearing rules: active voice;will/may/must(nevershall); "(henceforth, referred to as 'partner')" on first reference thenthe partnerthroughout; no marketing language; spell out acronyms on first use (Insecticide-Treated Net (ITN),Knowledge, Attitudes, Practices (KAP),Locally-Led organization (LLO), etc.). See the reference doc for sentence-level templates worth reusing verbatim (e.g., the NTE-cap pattern, the verified-deliverable definition, the timeline-risk-flag clause).Bold now renders — emit it, and apply the writing-style guide's bold rules (ace#2335).
replaceAllTextis still plain-text substitution and carries no formatting, butdocs_finalize_bold(Step 5) converts literal**markdown bold**into real Google Docs bold and deletes the markers. So the previous instruction here — "Do NOT emit**asterisks**in prose tokens … strip all markdown bold" — is retired: it was a workaround for a missing post-processor, and it costpdd-to-work-order-eval § writing_stylea guaranteed strike on every rendered work order, because the reference doc specifies bold rules the renderer could not honour.Two things the finalizer does not do, so they remain your job:
- A
**you did not mean as emphasis is still a marker. It pairs within a single paragraph only, and skips****and all-asterisk runs, but a literal asterisk in prose (a footnote mark, a multiplication sign) may pair with another one in the same paragraph. Write×or a real footnote instead. - Bold is the only style it applies. Italic, underline and headings are the template's business; do not emit
_underscores_or#headings in a prose token.
Header + narrative (prose tokens):
{{wo_number}},{{opp_title}},{{wo_date}}(today, ISO),{{wo_period_of_performance}}{{partner_first_reference}}— § 1's first-reference DEFINITION of the partner, which every laterthe partnerin the document depends on. Emit<Partner name> (henceforth, referred to as "partner")when an LLO is known, and[Partner Name] (henceforth, referred to as "partner")when one is not — the bracketed form is the sanctioned pre-partner placeholder from step 3(c) above, and a Work Order drafted before a partner is selected is the normal Phase 1 case. Do not emit a bare org name. The template supplies the rest of the sentence ("… is the implementing partner for the work described in this Work Order"), so the value is the party plus the parenthetical and nothing else. Everything after § 1 uses lowercasethe partner; neverPartner,the Partner,the vendororthe subcontractor(ace#2126; the definition had nowhere to land, sopdd-to-work-order-eval § writing_styletook a guaranteed strike on every rendered work order).{{background_body}}(synthesized from PDD's Problem Statement + Intervention Design + any named downstream consumer){{scope_intro}}(one-sentence framing of the work, archetype-branched){{geographic_coverage_body}}(from PDD Target Population;[Geographic Coverage — Partner to propose]if not specified){{primary_deliverable_body}}(from PDD Success Metrics){{verified_unit_closing}}(the closing paragraph after the verified-unit bullets, in the ACTIVE voice: "Dimagi verifies units via…" — never "Verification will be performed via…", whichpdd-to-work-order-eval § writing_stylenames as its canonical passive strike; ace#2585){{payment_unit_closing}}— § 6.2's closing sentence. The rate unit comes from the PDD's grain, NOT from the archetype.Read the PDD's § Program Parameters first and take
entity_id_grain(andpayment_rate_unit, when present). Connect resolves payable units byentity_id, so the grain — not the archetype, not the shape of the field work — decides what a partner is being quoted a price per. Write the sentence in the grain's units:- Grain resolves ONE payable unit per visit → "Dimagi will pay only
for verified units at the per-visit rate proposed in the partner's
solicitation response." On
longitudinal-visits, extend it so the repeat is unambiguous: "…for each verified visit in the follow-up sequence." - Grain is day-scoped (e.g.
entity_id_grain= worker username + encounter date) → the payable unit is a worker-DAY, so quote a per-day rate: "…at the per-day rate proposed in the partner's solicitation response, for each verified follow-up day." Never a per-visit rate. But the day grain does not by itself make one payment per day: several same-day visits share ONE entity row, and Connect pays each approved visit on it (ace#2512 — a repeated key is reset topendingand auto-approved). The per-day quote is true only when the payment unit'smax_dailyis 1 or aform_field_rulesrow rejects same-day repeats; say which one in § 6 (from the PDD's Program Parameters), and if the PDD names neither, carry it as an open item rather than asserting per-day payment. - Grain resolves per session (typical of
focus-group) → "…at the per-session rate proposed in the partner's solicitation response." multi-stage→ name the stage that is payable, per the PDD's payable-stage declaration, in that stage's own grain.
Pick ONE, and make § 6 internally consistent with it — the caps and the not-to-exceed total must be stated in the same units as the rate.
Why this is grain-driven and not archetype-driven. On bednet-check-2-visit/20260902-1555,
entity_idwas source-pinned to worker identity + encounter date, so the payable unit was a worker-day.idea-to-pdd-qa § payment_unit_matches_entity_grain(ace#1420) caught the per-visit quote in the PDD and the money was correctly re-derived per worker-day — and then this step, read literally as an archetype branch, put the per-visit sentence back into the Work Order, the document that actually gets signed. The rendered § 6.2 said both "at the per-visit rate" and "the payable unit under this Work Order is a worker-day, not an individual visit"; the overstatement was up to 6x.pdd-to-work-order-qareturned 9/9 with the contradiction present, because it had no counterpart to the PDD-side check. Enforced:pdd-to-work-order-qa § payment_unit_matches_entity_grain(ace#1946), which shareslib/payment-grain.tswith the PDD-side check.Until ace#1004 this sentence was hardcoded in the template as "…at the per-visit (or per-session, per archetype) rate…", and the parenthetical — an instruction to the renderer — rendered verbatim into a signed contract, telling a partner their payment unit depends on an "archetype" defined nowhere in the document. Because it was not a
{{token}}and not a<<marker>>,no_scaffolding_markerspassed it, the ace#819 token-coverage scan passed it, and QA returned 8/8 with the defect present (hh-poverty-targeting/20260728-0705). The rate itself is negotiated via the solicitation response and is NOT set here. Enforced:pdd-to-work-order-qa § no_renderer_instructions.- Grain resolves ONE payable unit per visit → "Dimagi will pay only
for verified units at the per-visit rate proposed in the partner's
solicitation response." On
{{wo_total_not_to_exceed_usd}}— bare number, in the programme's own currency (see directly below)The template's literal
USDis not the programme's currency — relabel it, never convert (ace#2583). The token's name ends_usdfor historical reasons only. Every payment figure in the PDD is denominated inopportunity_currency, the LOCAL currency ofopportunity_country(templates/pdd-template.md:255, "Never default to USD", gated byidea-to-pdd-qa § launch_parameters_present), and the template hardcodesUSDbefore this token in § 6.1 plus anAmount (USD)column header in § 6.2. So whenopportunity_currencyis anything other thanUSD, pass these TWO literal replacements todocs_copy_templateinstead of the bare{{wo_total_not_to_exceed_usd}}key (replacementsis a plain find→replace map; the keys need not be tokens, and the composite key consumes the token so the ace#819 leftover-{{scan stays clean):"USD {{wo_total_not_to_exceed_usd}}": "GHS 11,000" "Amount (USD)": "Amount (GHS)"Then restate every other money figure in § 6 in the same code. Do NOT apply an exchange rate. The PDD's number is already denominated correctly, so this is a relabel: writing the GHS figure after the letters
USDoverstates a Ghana cap ~15x in a document that gets SIGNED. ACE does not pick exchange rates. If the cap genuinely must be capped in USD (a funder constraint, recorded in a decision row), keepUSDand add the conversion rule to § 6 — the rate source and the date it is struck — naming the local code explicitly;pdd-to-work-order-qa § total_nte_presentaccepts that shape and rejects a bare USD cap beside local-currency rates. Verified live onturmeric-market-study-ghana/20261012-1749, where the hardcoded USD made the QA gate un-passable for a correct contract (14/16, both failures demanding a false figure). Enforced:pdd-to-work-order-qa § total_nte_present.{{ethics_body}}— prose{{annexures_body}}— the annexure list, oneAnnexure <letter>: <title>per line.N/Awhen there is nothing to annex, which is the common case.NEVER annex a Dimagi-internal document — the work order is sent to the partner (ace#2906). The § Annexures heading always renders (
pdd-to-work-order-qa § all_required_sections_presentrequires all 11 sections, and an absent heading fails the gate), so an empty annexure list is expressed as the single lineN/A, never by deleting the section. What must never appear there — by link or by title:- the Program Design Document (PDD) — ACE's internal design artifact
- internal design documents, technical documents, architecture or build notes
run_state.yaml,decisions.yaml, eval/QA verdicts, phase summaries, the run's ace-web page
Annex only documents the partner is a party to or must act on: their own costed solicitation response, a sampling protocol they will follow, a data-sharing agreement. If in doubt, leave it out —
N/Acosts nothing, and a leaked internal link cannot be recalled once the mail is sent.Until 2026-10-14 this template hardcoded an
Annexure A: Program Design Documentline carrying apdd_linktoken, so every work order ACE had ever produced carried a live link to its own internal PDD. That token no longer exists in the template and must not be reintroduced. Caught ontekzal/20261013-2314when the operator struck the line in review: "this doesn't need to be shared with the implementing partner. This is Dimagi internal only." Enforced:pdd-to-work-order-qa § annexures_exclude_internal_documents.
Bulleted-region tokens (newline-separated; bullet-styled via
docs_finalize_bullets— the anchor pairs are live in the template, see Step 5):{{scope_will_body}}— the in-scope bullet lines ONLY, one item per line (no leading dashes, no intro paragraph, no "Will Do:" header — the template supplies the "For each engagement, the partner will:" lead-in above the token).{{scope_will_not_body}}— the out-of-scope bullet lines ONLY, one item per line (the template supplies the "The partner will not:" lead-in).- There is NO single
{{scope_body}}token in the live template. Passing one silently drops all scope content AND leaks the two real tokens verbatim into § 2 Scope of Work (ano_scaffolding_markers-class QA defect) — the exact failure from bednet-spot-check/20260701-1644 (jjackson/ace#819). {{verified_unit_body}}— criteria a unit must meet to be "verified" (one bullet per line){{reporting_body}}— required reporting deliverables (one bullet per line){{permissions_body}}— required permissions (one bullet per line)
Timeline table (9 rows × 3 cols, header + 8 weeks):
{{week_N_dates}},{{week_N_activities}}for N=1..8 (from PDD Timeline)
Payment Schedule table (3 rows × 6 cols, header + 2 milestones):
Milestone 1:
{{wo_mobilization_advance_pct}},{{wo_mobilization_amount}},{{wo_mobilization_trigger}},{{wo_mobilization_timing}}Milestone 2:
{{wo_reconciliation_pct}},{{wo_reconciliation_amount}},{{wo_reconciliation_trigger}},{{wo_reconciliation_timing}}If
wo-total-not-to-exceed-usdis a bracketed placeholder rather than a number,{{payment_unit_closing}}MUST state that nothing is payable yet. Keep the percentage split — a 40/60 structure pending a cap is an ordinary pre-award shape, andpayment_schedule_sums_to_100requires the percentages to stay readable. What is not ordinary is presenting a percentage of an undefined base as a commitment: § 6.1 readsUSD [TBD]while § 6.2 reads "40% mobilization advance" with an amount cell of "[Amount derived from the agreed cap]". Add one sentence, e.g.:These percentages are indicative of the agreed split only. No amount is payable under this schedule until the total not-to-exceed in § 6.1 is fixed at contract execution.
Enforced:
pdd-to-work-order-qa § advance_contingent_when_cap_unresolved. Measured onpoverty-graduation/20260905-0924, wherecommercial_realismscored this strike 1 of 3 and called it sufficient on its own to make the draft unsignable (ace#2007). This is the ONLY commercial-realism strike that is ACE's to fix. The other two are not defects: the bracketed Period of Performance is the sanctioned pre-partner form (step 3(c) above), and the absent termination / liability / IP / data-ownership clauses are absent BY DESIGN — the work order is annexed to an MSA (templates/work-order-template.md:147), which is where they live. ace#1481 claimed otherwise and was closed NOT_PLANNED with the premise disproved; do not "fix" it by inventing clauses.Reconcile the two payment models, state the declared cap, and define acceptance (ace#2023). § 6 carries three obligations beyond the split itself. All three were
commercial_realismstrikes onpoverty-graduation/20260905-1345, and each is now enforced by apdd-to-work-order-qacheck, so a miss fails QA rather than surfacing as an eval strike two steps later:- Reconcile the models (
payment_models_reconciled). A milestone table pays against the cap; a per-unit rate pays against delivery. Both may appear — real contracts carry both — but only if § 6.2 says how they relate: "The mobilization advance is recoverable: verified units accrue against it until it is fully drawn down, after which units are paid at the agreed rate." Silence is what a reviewer cannot sign. - Transmit any declared per-FLW cap (
declared_cap_reaches_contract). Ifdecisions.yamlcarriesdaily_cap_per_flw, state it in § 6: "No more than N verified units per field worker per day are payable." With a per-unit rate against a[TBD]NTE it is the only volume bound the contract has. Transmit only caps the design DECLARES — never infer one from a neighbouring figure. The eval judge on that run readexpected_reach_min: 300as atotal_cap_per_flwthat exists nowhere in the PDD, whose owncap_rationalesays "no source states a programme-wide total-per-FLW cap, so none is asserted". - Define acceptance if any milestone pays on it (
acceptance_defined). Criteria, a review window measured from submission, and a deemed-acceptance default. "Within 30 days of acceptance" is a payment term, not a definition — it says when money moves once acceptance happens and nothing about whether it ever does, which is the rubric's own named failure mode ("payment triggers that aren't tied to an objectively determinable event").
- Reconcile the models (
PCT tokens are bare numbers (no
%suffix). The live template's percent cells already have%pre-suffixed (e.g. cell text reads{{wo_mobilization_advance_pct}}%), so emitting"40%"produces"40%%". Pass"40"(or"40.0"if you need decimals) — the template adds the%glyph. Same rule for{{wo_reconciliation_pct}}. (Surfaced in bednet-spot-check Phase 1 finding.)
RACI table (12 rows × 3 cols, header + 11 responsibility rows):
{{raci_N_responsibility}},{{raci_N_dimagi}},{{raci_N_partner}}for N=1..11. Archetype-branched — take the rows from the###branch for this run's archetype in## Archetypesbelow rather than from a list here, so the branches stay in one place (ace#2312). Use—or✓orLead/Supports/Reviews/Producesfor the responsibility-owner columns. If the archetype needs fewer than 11 rows, fill trailing rows with empty strings.
Data Handling table (9 rows × 2 cols, header + 8 fields):
{{data_project_overview}},{{data_subjects}},{{data_personal_info}},{{data_purpose}},{{data_security}},{{data_partner_measures}},{{data_storage_location}},{{data_protection}}
Signatures table (2 rows × 2 cols, header + signer blocks):
{{partner_signatory_name}},{{partner_signatory_title}},{{partner_address}}(left cell — Subcontractor)- Dimagi cell is hardcoded in the template (Lucina Tse, COO, Cambridge MA address) — no tokens for the right cell.
- Resolve-or-create the
5b. Share it at creation (ace#1843) — anyone-with-link commenter, UNLESS
the opp is confidential (ace#3191). On the fileId docs_copy_template
returned, before step 6, get the share plan exactly as idea-to-pdd step 6c
does. The one signal, defined in lib/opp-confidentiality.ts: the opp is
confidential when ACE/<opp>/inputs/confidentiality.yaml exists (the same
file pdd-to-work-order-qa step 3a reads for the deny list — read it with the
default text/plain export and save it locally):
node "$ACE_ROOT/node_modules/tsx/dist/cli.mjs" "$ACE_ROOT/scripts/design-doc-share-plan.ts" <local confidentiality.yaml | none>
anyone-with-link(no confidentiality file):drive_set_anyone_with_link(fileId: <workOrderDocId>, role: 'commenter')named-reviewers:drive_share_with_person(fileId: <workOrderDocId>, email: <e>, role: 'commenter')for each listed reviewer; never anyone-with-link.unshared: do NOT share.
Record the mode as products.work_order.share_mode in step 6. On
connect-fi/20261018-1440 the operator brief said "nothing is public" and only a
careful agent's one-off decision kept this document private; the plan makes
that the skill's behaviour, not the agent's.
commenter, not reader — this is a CONTRACTUAL draft, the document a
counterpart is most likely to mark up, and a Drive reader physically cannot
leave a comment (skills/feedback-ledger's channel: gdoc-comments
assumes they can).
At creation, not as a cleanup step, and not left to
/ace:share-run-access. docs_copy_template creates UNSHARED and
nothing downstream tests reachability — the doc exists, the token-coverage
check above passes, verify_phase_artifacts finds it, and the recipient
still gets You need access. Measured on three independent runs in three
days (hh-poverty-targeting/20260828-0702, bednet-check-2-visit/20260828-0629,
spark-facilitator/20260828-0703): this document and the PDD 401'd
anonymously on all three, taking the run-summary page's entire DESIGN
section with them, while every Phase 6 training document — whose producers
already do this — was readable. The split tracks the producing skill.
Worse than a dead link: ace-web MEASURES the access tag of each Drive
link (_read_design → LinkAccessReader.tag), so a private Work Order
renders as ADMIN ONLY — the partner reads deliberate withholding rather
than an oversight.
(Declared recipientFacing + shareRole: 'commenter' in
lib/artifact-manifest.ts; enforced by
test/lib/recipient-facing-artifacts.test.ts.)
Write
run_state.yaml.phases.idea-to-design.products.work_orderviaupdate_yaml_filewithmerge: 'deep'(partial phase-child patch —two-levelwould replace thedesignblock wholesale and clobber the siblingproducts.pddwritten byidea-to-pdd, plusstatus/steps; #572/#587):phases: design: products: work_order: title: "Work Order — <opp-title>" file_id: <gdoc-id> share_mode: anyone-with-link # step 5b plan: anyone-with-link | named-reviewers | unshared (ace#3191)Invoke
decisions-renderso the human-readabledecisions.gdocrefreshes with the newwo-*rows.
Archetypes
atomic-visit (default)
- Scope: per-visit data capture with photo + GPS standardization.
- Verification: photo + GPS Layer A on the deliver-app form.
- Payment unit: per visit (rate from existing
payment-ratedecision) — iff the PDD'sentity_id_grainresolves one payable unit per visit. The archetype describes the field work, not the payment grain; when the grain is coarser (e.g. worker + encounter date), quote the rate in the grain's units instead. See § Process step 5{{payment_unit_closing}}(ace#1946). - Roles: Dimagi configures app + verification audit; Partner recruits FLWs, runs field ops, transports samples (if applicable).
longitudinal-visits
- Scope: per-visit data capture against a followed entity over time — the same visit-shaped unit as
atomic-visit, but the Scope of Work must name what is being followed (the case / household / participant / cohort) and its cadence (phase, sequence, follow-up interval, orvisit 1..n). A scope that reads identically to an atomic-visit one has lost the longitudinal half between the PDD and the contract — the ace#1462 failure, where the PDD prose was longitudinal-aware and the payment predicate was not. - Verification: photo + GPS Layer A on the deliver-app form, as
atomic-visit; PLUS the visit's position in the sequence must be recoverable from the submitted record. Any per-entity cap ("at most N paid meetings per step") must name its Connect-side enforcement — thepayable_slotverification rule or the payment-unit caps — never "Connect pays each key/slot once": Connect pays a repeated key again (ace#2512), and the partner signs this document. - Payment unit: per visit (rate from existing
payment-ratedecision) — iff the PDD'sentity_id_grainresolves one payable unit per visit. Where it does, each qualifying visit in the sequence is separately payable — state this explicitly, because "per visit" against a followed entity otherwise reads as one payment per entity. Where the grain is day-scoped, several same-day follow-ups are ONE payable unit and the rate must be quoted per day (ace#1946). - Roles: as
atomic-visit, plus Partner owns the follow-up schedule and re-contact of enrolled entities.
focus-group
- Scope: per-session facilitation with attestation form submission and gdoc write-up.
- Verification: attestation submission Layer A + gdoc receipt Layer B; coordinator-graded practice-session-pass gates payment.
- Payment unit: per session (facilitator + notetaker rate from existing
per-session-ratedecision); facilitator training stipend on practice-session-pass. - Roles: Dimagi configures OCS chatbot + attestation form + gdoc template; Partner recruits facilitators + notetakers, runs sessions, completes gdoc.
multi-stage
- Scope: per-stage sub-section, each with its own archetype-shaped scope.
- Verification: per-stage criteria reflecting the stage's archetype.
- Payment: may mix per-visit and per-session units; stage-gate criteria from PDD.
- Roles: per-stage RACI.
MCP Tools Used
- Google Drive:
drive_read_file,update_yaml_file,drive_set_anyone_with_link(role: 'commenter'— § Process step 5b; ace#1843; NOT on a confidential opp, ace#3191),drive_share_with_person(eachreviewers:email on a confidential opp,role: 'commenter'— step 5b),drive_list_folder(findinputs/confidentiality.yaml) - Google Docs:
docs_copy_template,docs_finalize_bullets,docs_finalize_bold
Mode Behavior
- Default (auto): infer all fields, draft the gdoc, append
wo-*rows, writeproducts.work_order, proceed. - Review: after the gdoc is written, pause and surface the gdoc URL for human approval before proceeding to the next phase.
Dry-Run Behavior
When --dry-run is active:
- Write the work-order gdoc as normal (Drive writes are reversible).
- State tracks as
dry-run-success.
Terminology
The platform is Connect, never CommCare Connect — in every word this skill
puts in front of a human. The full rule, including the carve-outs for CommCare the
mobile app, CommCare HQ and code-level identifiers, is
skills/_terminology.md. Read it before writing prose; it is
binding on this skill's output.
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-05-21 | Add references/writing-style.md + references/style-guide.md, adapted from sarvesh-tewari/ace-skills-stewari; wire writing-style.md into step 1 + prose-token synthesis |
ACE team |
| 2026-05-21 | Drop bold-span rule from prose-token synthesis preamble + add explicit "do not emit markdown bold" warning (template uses plain-text replaceAllText; no bold finalizer yet). Track docs-finalize-bold post-processor as backlog. | ACE team |