solicitation-create
Turn an approved PDD into a Connect solicitation with evaluation criteria and publish it. Needs an approved PDD.
Files1
View on GitHubSKILL.mdskills/solicitation-create/SKILL.md
GitHubSolicitation Create
Phase 8 default-run skill. Builds and publishes the solicitation in one shot — ACE always publishes, never drafts. The solicitation can be edited post-publish via the labs UI without affecting responses.
See skills/_solicitation-template.md for the shared
phases.solicitation-management.products.solicitation contract and
connect-labs MCP atom inventory.
Per-run solicitations are expected, not a bug
Every /ace:run publishes a FRESH solicitation under the same
Connect program — by design. This skill does NOT detect already-open
solicitations on the program and does NOT close-or-coordinate against
them. Two operational facts make multiple open solicitations correct:
- Solicitations are run-scoped audit trails. Each run's solicitation captures that run's intent (PDD wording, criteria, indicative budget, archetype, deadline). Different runs ship different PDD revisions; merging them under one solicitation would lose the audit trail.
- Launch is operator-coordinated, not skill-coordinated. The
typical opp will only have one solicitation actually launched
to candidate LLOs (the chosen release-candidate run's). The other
open solicitations live in the labs portal until the operator picks
one to drive Phase 9 from. Stale solicitations are
operator-cleaned-up via
connect-labs delete_solicitationor the labs UI when picking a release-candidate run.
Nor is an approaching deadline with zero responses. The same
doctrine, in the shape a TURN meets it rather than a run: a published
solicitation carries a real application_deadline and a real response
count, and an agent reading deadline in N days, 0 responses off the
labs API will reach for "this lapses unless someone acts" and escalate
it to a human. Do not. Zero responses is the CORRECT and expected state
— Phase 8 is publish-only by default and deliberately emails no
candidate LLOs (llo-invite is a no-op without an explicit operator
opt-in), so nobody has been invited and nothing is waiting. A lapsed
run-scoped solicitation costs nothing; it was an audit trail, not a
procurement. Surface it ONLY if an operator has already opted into
inviting candidates for that specific run. (Origin: 2026-09-04/05 — a
turn raised solicitation 17609's "deadline 12 Sep, zero responses" as a
decision needing a human three times across two days on
hh-poverty-targeting, a test opp. Jon: "stop talking about the
solicitation, this is all just test work and its fine." The doctrine
below already said these are audit trails; what was missing was that the
deadline and the response count are audit-trail fields too.)
If you (the agent reading this skill) notice multiple open
solicitations on the same Connect program and feel the urge to
"deduplicate" or "warn-and-prompt," resist. It's not a footgun; it's
how per-run independence is meant to look at the solicitation surface.
The same pattern applies to per-run Connect opportunities and OCS
chatbots — see agents/ace-orchestrator.md § Modes for the broader
"each run gets its own live entity; stale ones are operator-managed"
contract.
Inputs
The skill publishes a solicitation for a Connect program from a brief. Inside
/ace:run every parameter resolves from the run; outside a run (a live program,
a program project) pass them explicitly. No run_state.yaml is required when the
parameters are given.
| Parameter | Used for | Resolver |
|---|---|---|
--work-order <doc id> |
primary content source (scope, verification, payment band, timeline) | default: the run's 1-design/pdd-to-work-order.gdoc; outside a run, any work-order-shaped brief the caller supplies |
--pdd <doc id> |
archetype, problem narrative, geographic scope | default: the run's 1-design/idea-to-pdd.md; optional outside a run |
--program-uuid / --labs-program-id |
the Connect program the call is for (UUID + ConnectProd integer id) | default: opp.yaml.connect.program.{id, connect_int_id}; else the program context doc; else the labs_context lookup in Step 5 |
--org-slug |
the owning (PM) org that publishes and owns the call | default: the run's Phase 4 PM org (runConnectOrgs()); else the program context doc |
--decisions <file> |
decisions that change scope, band or ethics | default: the run's decisions.yaml; outside a run, the program context doc's Decisions |
--out-folder-id |
where the draft/published records are written | default: the run's 8-solicitation-management/ |
optional_steps.private_solicitation |
publish is_public: false + the demonstration banner |
default: the run's optional_steps block (PR2 #3166); outside a run, --private |
Write-back. Step 9's run_state.yaml write happens only when a run is
bound. Outside a run, record the solicitation id, URL, visibility and deadline
in the program context doc's Decisions (per partner-on-connect-program
§ Changing a live program) instead.
Default sources inside a run, in detail:
ACE/<opp-name>/runs/<run-id>/1-design/pdd-to-work-order.gdoc— primary content source. The Phase 1 work order is the comprehensive, opinionated program brief: scope (will / will not), per-unit verification criteria, roles + RACI, reporting cadence, ethics scope, data-handling, payment schedule, timeline. This skill transforms the work order into a public-facing solicitation: same comprehensive explanation, less prescriptive (rates become ranges, exact weeks become windows), with the LLO-evaluation framing layered on top.ACE/<opp-name>/inputs/operator-rulings.yaml(optional) — per-opp operator rulings that override a standing design assumption for this opp (lib/operator-rulings.ts); honour every ruling whoseapplies_toincludessolicitation(see the devices block under the design principles).ACE/<opp-name>/runs/<run-id>/decisions.yaml— run-level decisions log. Phase 1'spdd-to-work-orderwrites initialwo-*rows; later phases may add or amend decisions (e.g. operator overrides at gate reviews, Phase 4 budget reductions, archetype-specific clarifications). Read every row before composing — open/closed decisions that affect scope, payment band, geographic scope, or ethics must be reflected in the published solicitation.ACE/<opp-name>/runs/<run-id>/1-design/idea-to-pdd.md— the approved PDD. Source of truth forarchetype, the problem-statement / why-this- matters narrative (often more vivid than the work-order's contractual framing), and the geographic scope. Used to write the solicitation's opening — the foundation-pitch context an LLO needs before the work- order-derived scope makes sense.ACE/<opp-name>/opp.yaml—connect.program.id(Connect UUID), opp display name, organization_slug, optional cachedconnect.program.connect_int_id(ConnectProd's integer program id — ConnectProd predates UUIDs and exposes both; the labs/solicitation surfaces key off this integer. NOT a labs-minted id).ACE/<opp-name>/runs/<run-id>/4-connect/connect-program-setup.md— the Connect program name (used to resolve the ConnectProd integer program id vialabs_contextas a fallback, whenconnect_int_idwasn't captured at program-create time; see Step 5).ACE/<opp-name>/runs/<run-id>/4-connect/connect-opp-setup.md(optional but recommended) — the current run's Connect opportunity identifiers, payment unit, start/end dates as actually configured. When present these override PDD defaults (the work order's payment band may have been adjusted at Phase 4 if the program budget was capped — that adjusted band is the truth for this solicitation).
Products
8-solicitation-management/solicitation-create_draft.md— the composed payload pre-publish (audit trail for what was proposed)8-solicitation-management/solicitation-create_published.md— solicitation_id, public_url, manage_url, deadline, criteria as publishedrun_state.yaml.phases.solicitation-management.products.solicitationblock populated (id, public_url, deadline, status: open, labs_program_id, connect_program_id, connect_opportunity_id). Per-run only — each run of the opp publishes a fresh solicitation. Operator-cleaned-up when picking a release-candidate run.
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
Design principle: ACE owns composition; labs validates the schema. The labs MCP's
create_solicitation/update_solicitationtools validate the payload against labs's canonical solicitation schema (the same schemasolicitations/models.py's @property accessors read and the public-detail template renders) and reject drift withINVALID_SCHEMA+ per-field error details undererror.details.fields. Labs does NOT compose content — there is nocreate_solicitation_from_brieftool, no "standard 6 questions" server-side, no labs-side AI agent that this skill defers to. ACE owns the entire compose-the-content path: voice, archetype branching, scope-of-work transformation from the work order, question framings, evaluationscoring_guides, decisions-log integration. The labs contract is structural enforcement, not content delegation.Operational consequence: when labs surfaces an
INVALID_SCHEMAerror on publish, readerror.details.fieldsto find the offending field, fix the composition in this skill (or in the calling agent's prompt if the issue is content-shape vs schema), and retry. Do NOT work around schema errors by writing top-level fields outside the validated set — those fields silently drop at the labs persistence layer even when validation lets them through.Before each publish, re-read labs's canonical inputSchema via
tools/listif the SKILL.md hasn't been refreshed against a recent labs deploy. The schema is the source of truth for the field shape; this SKILL.md mirrors it.
Design principle: per-unit payment is negotiated, not declared. The labs solicitation
dataschema deliberately has noper_unit_paymentstructured field — and we should not push for one. Per-unit payment shape varies by archetype (per-visit / per-session / per-stage) and within each archetype the right rate is opp-and-LLO-specific. Solicitations express payment as a range with rationale inscope_of_workprose (e.g. "Per verified session: 80–120 USD-equivalent, facilitator + notetaker combined") and thequestionsblock asks the responding LLO to propose their actual rate + why (q6 in the default template). The awarded LLO's proposed rate becomes theconnect.deliver_unitpayment_unit amount at Phase 4 setup time. Do not embed a fixed per-unit number as the load-bearing economic; the range + the question + the LLO's response are the load-bearing parts.
Devices, data and system of record are ASSUMED, never asked (standing operator directive, Jonathan 2026-09-26 — see
skills/idea-to-pdd/SKILL.md§ Standing design assumptions). Do not ask the responding LLO to confirm that workers have smartphones, data or PersonalID, do not weight any evaluation criterion on it, and do not ask whether workers file the Connect record instead of or in addition to an existing tool — Connect is the system of record by design.Unless an operator ruling overrides it for this opp. Read
ACE/<opp-name>/inputs/operator-rulings.yaml(schema:lib/operator-rulings.ts; usually absent) and take the rulings whoseapplies_toincludessolicitation. Foroverrides: devices-assumed(spark-facilitator, owner ruling 2026-10-04: devices are costed separately and asked about), ask the respondent who provides devices and data and what they cost. Price devices OUTSIDE the all-in rate, as their own line. Every other cost item stays inside the rate. Pass{ devicesCostedSeparately: true }toscanRateScope(below) so the device line is not blocked. Foroverrides: system-of-record, the question about instead-of or in-addition-to may be asked. Record one decision row per ruling withrulingDecisionRow(ruling, { phase: '8-solicitation-management', skill: 'solicitation-create' })(idsol-ruling-<id>). Send it asai-default; the write boundary stampshuman-decided+decided_byfrom the file. Being asol-*row, it is also what the work order's commercial-terms check reads.
The rate is ALL-IN, and its composition is a required ask (standing operator directive, Jonathan 2026-09-08): "the LLO should propose an all-in rate per verified service delivery, and explicitly how much is paid to the worker vs. commodity." Two obligations follow, and a composed listing must satisfy both:
- All-in. The per-unit rate is the whole commercial relationship. There is no separately-funded line for supervision, transport, devices, connectivity or reporting time. Never invite one — no "funded separately", "reimbursed separately", "a separate budget line". Asking what the rate covers is fine and expected; implying that something sits outside it is not.
- Decomposed. Some question must ask how the all-in rate splits between what reaches the worker and what covers commodity. An undifferentiated total hides whether the worker is paid a defensible wage — the number the programme most needs to see.
Solicitation 19201 broke both at once: Q8's text invited a separately funded line while its own
framingand the linked evaluation criterion both said partner-absorbed, and nothing asked for the split. A respondent reading all three on one screen cannot price the engagement, and these listings carry nocontact_emailto ask through (ace#2265).
Read all source materials. Open in this order:
- Work order (
1-design/pdd-to-work-order.gdoc) viadocs_get— this is the primary content source. Pull the full body, including all sub-sections: Scope of Work (will / will not), Verification of Verified Units, Roles + RACI, Reporting, Ethics, Data Handling, Payment Terms (with the payment schedule sub-table), Timeline. - PDD (
1-design/idea-to-pdd.md) viadrive_read_file— forarchetype, the Problem Statement (the vivid "why malaria, why now, why this approach" narrative), the Intervention Design overview, the Target Population framing, and Geographic Scope. - decisions.yaml (
<run-folder>/decisions.yaml) — every row. Pay special attention to:status: openrows on payment / scope / language / ethics — these are explicit operator deferrals that should surface in the solicitation (typically by phrasing the relevant scope item as a band + asking the LLO to propose within it).- Rows tagged
phase: 4-connector later that AMEND a Phase 1 decision (e.g. budget cap was reduced at Phase 4 — the work order's NTE is stale; reflect the Phase 4 reality).
- opp.yaml for opp identity + program reference.
- Phase 4 outputs (
4-connect/connect-program-setup.md,4-connect/connect-opp-setup.md) for the labs program name + the run's Connect opportunity identifiers + actual payment unit configuration. The Phase 4 payment band overrides the work order's if they differ.
- Work order (
Build the solicitation payload using the labs canonical schema. The labs MCP's
tools/listis the canonical contract — it returns the liveinputSchemaforcreate_solicitationandupdate_solicitation. If this SKILL.md ever disagrees withtools/list,tools/listwins; re-read it and update the skill. See § Stale-schema gotcha below for why this matters.The deployed schema is flat — every solicitation field is a top-level property of the atom's argument object. There is no
data: {...}envelope. The canonical shape is:data-object field Type Source / composition titlestring Work order title, stripped of "Work Order —" prefix; e.g. "Connect ITN SBC Exploration — Barrier Diagnosis in Malaria-Endemic Households" solicitation_typelowercase "eoi"or"rfp"PDD ## Solicitation→Solicitation type(defaulteoi). Must be lowercase — the labs template literal-comparessolicitation_type == 'eoi';'EOI'renders as a fallback badge.descriptionstring (markdown) Comprehensive, 500-800 words. Opens with the PDD's problem framing (why this matters, what the gap is), transitions into what this opportunity does and why this approach, closes with what the dataset/output enables downstream. Foundation-pitch tone, not procurement-form. Not a one-paragraph summary. scope_of_workstring (markdown) Comprehensive, 600-1000+ words of structured markdown. Derived from the work-order body. See § Scope-of-work composition below for the exact section mapping + de-prescription rules. Must be a single markdown string with ##sub-headings and-bullets, NOT a JSON array (the labs template runs it through a markdown filter; an array string-coerces to Python repr).application_deadlinestring YYYY-MM-DD`(now() + (response_window_days expected_start_datestring YYYY-MM-DDAnchored to the deadline, not to the Phase 4 opp row. Must be strictly AFTER application_deadline:application_deadline + a contracting allowance(≥ 7 days; ~2 weeks is the ACE default — it has to cover response review, award, and contracting). Use the Phase 4 oppstart_date(or a PDD## Timelinestart) ONLY when that date already satisfies> application_deadline; otherwise derive it from the deadline and record the derivation in the draft + adecisions.yamlrow. NOTanticipated_start.expected_end_datestring YYYY-MM-DDexpected_start_date+ the PDD's POST-AWARD duration — NOT its whole## Timelinetotal. The row above putsexpected_start_dateafter the solicitation window, so adding the full total re-spends time the start date has already consumed. Compute the addend as the sum of the## Timelinerows describing work performed AFTER award — equivalently, the total less every row that has already ended byexpected_start_date. Two rules for deciding, in order: (1) solicitation-open row(s) are ALWAYS subtracted — they end atapplication_deadlineby construction. If the timeline has none — its first row is post-award work such as onboarding or recruitment — subtract nothing here, and carry that same reading into Step 7a's span ceiling, whose subtrahend is conditional on it (ace#2231). (2) An award / contracting row is subtracted only where it is purely contractual; where it also carries LLO delivery work (onboarding, worker registration, Learn setup), keep it — that work happens after award and the contracting allowance does not perform it. Where the PDD states only a total with no per-row breakdown, subtract the solicitation window actually used (application_deadline − publish date). Take the top of a band (## Timeline"~9–11 weeks" → 11 weeks, then subtract). Must be strictly afterexpected_start_date, and Step 7a bounds the span. Record the subtraction — which rows were dropped, which were kept and why, and the resulting week count — insolicitation-create_draft.mdand in theresponse-deadlinedecisions.yamlrow. Use the Phase 4 oppend_dateonly when the Phase 4 dates were adopted wholesale under the rule above. NOTanticipated_end.estimated_scalestring Human-readable summary of expected reach, e.g. "30–50 verified HH visits per LLO; 2–3 LLOs total (90–150 HH end-to-end)". Sourced from PDD ## Target Population→Expected reach. NOTsample_target. At most 200 characters — enforce it HERE, at composition time (ace#3049): the labs column isCharField(max_length=200)(connect_labs/solicitations/local_models.py:188) but the MCP schema declares nomaxLength, so an over-long value passes validation and fails in Postgres as a rawDataError: value too long for type character varying(200)traceback (the noora-health draft's value was 234 characters). Tighten the summary; never truncate mid-word and never move the overflow into another field.contact_emailstring (optional — omit entirely when not derivable) Point-of-contact shown on the published solicitation (candidate questions + responses land here). Source it ONLY from the PDD — set it when the PDD explicitly names a point-of-contact / program email (e.g. a ## Solicitationcontact line or a clearly-stated coordinating email). If the PDD does not make the contact email obvious, OMIT thecontact_emailfield entirely — do not send it, do not fall back to any env var or the shared ACE bot inbox.contact_emailis optional in the labscreate_solicitationschema (verified via livetools/list:requiredis justtitle/description/solicitation_type), so a solicitation with no contact email publishes cleanly and simply renders without a contact line. Emit a one-line[INFO]when omitting (no PDD-derived contact email; publishing without a contact line) — NOT a[WARN], since a missing contact on a public listing is expected, not a defect. Never invent an email or reuse a bot inbox as a stand-in: a wrong contact on a public listing routes real candidate questions to a mailbox nobody reads. (Supersedes the old${ACE_SOLICITATION_CONTACT_EMAIL}→${ACE_GMAIL_ACCOUNT}fallback; the env var was removed because the shared bot inbox was never a real point of contact. The hard-[BLOCKER]-on-unset was already removed in jjackson/ace#636.)evaluation_criteriaarray of {id, name, weight, description, scoring_guide, linked_questions}Composed locally — see Step 3. NOT rubric. NOT[{dimension, criterion, weight}]. The deployed schema requiresid,name,weight(verified via livetools/list); the ACE convention addsdescription,scoring_guide, andlinked_questionsas content-quality requirements.idis kebab-case (e.g.field-ops-realism), unique within the rubric — deriveslugify(name)if you're tempted to elide it; explicit ids are required because duplicate-named criteria would otherwise silently collide. Weights sum to 100 (integers).questionsarray of {id, text, type, framing, required, options}Composed locally — see Step 4. NOT response_questions. Field istext, notquestion. The deployed schema requiresid,text,type(verified via livetools/list);framingis an optional structured key that ACE always populates because it's the rubric anchorsolicitation-reviewconsumes when scoring responses.idis kebab-case (e.g.field-ops-realism).typeis one of"textarea"(default — use it for any prose answer),"text"(a single-line input; the right choice for a short factual answer like an org name, a headcount or a date),"multiple_choice", or"number". Verified against the livetools/list2026-08-29: the enum is exactly[multiple_choice, number, text, textarea](ace#1834 —texthad been omitted here). Emptyframingis a[BLOCKER]— the review path can't score responses against a missing anchor.statusstring 'active'(publishes immediately;'draft'for dry-run mode).is_publicbool trueby default — lists it on the labs marketplace, and the detail page/solicitations/<id>/is anonymously readable (200, observed 2026-10-19 on 1000001, ace#3254). Responding was not observed without a labs account, so do not promise a candidate that they can apply without one.falsewhen the run'soptional_steps.private_solicitationis true (isOptionalStepEnabled(runState, 'private_solicitation'),lib/optional-steps.ts): an unlisted call is readable only by the owning org's members and by anyone who responds.seed_responses,ace_winnerandsupply_chainforce it on, so fabricated responses never sit on a listed call. See § Private (demonstration) solicitations below.connect_opportunity_idint Phase 4 opp internal id (not the UUID). Stored on the record for downstream solicitation-review linkage. Why the Phase 4 opp dates are not the source of truth for these two fields. The Connect opportunity's
start_dateis an artefact of when the Phase 4 row was created, not a program commitment. Phase 4 runs before Phase 8 in the same/ace:run, so that date is always at or before the publish date whileapplication_deadlineis publish + ~14 days — copying it publishes a start date roughly two weeks before applications close, on a public partner-facing page. Nor does the PDD fallback rescue it: for any opp whose timeline is relative-to-award (the normal shape for a solicited engagement — the PDD gives only stage durations and the work order says "N weeks from execution, dates set at award"), there is no correct absolute start date to inherit at all. Derive it from the deadline. Step 7a asserts the ordering.Why the end date's ADDEND is scoped, with the arithmetic worked through. The same trap one field to the right: a PDD
## Timelinefor a solicited engagement normally starts its clock at solicitation-open, not at award, so its total already contains the windowapplication_deadlineconsumed. Adding that total toexpected_start_datespends it twice. Worked example —bednet-check-2-visit/20260828-0629(labs solicitation 17695, program 231), PDD## Timeline:Row Duration Subtract? Solicitation open 2 weeks yes — rule (1); it ends at application_deadlineAward, onboarding, worker registration 1 week no — rule (2); onboarding + worker registration is LLO work performed after award, which the contracting allowance does not do Learn completion 1 week no Field work ~13 weeks no Layer C audit and closeout 1 week no Total ~18 weeks addend = 16 weeks Published 2026-08-30 →
application_deadline2026-09-13 →expected_start_date2026-09-27 (deadline + 14d).- Whole total:
2026-09-27 + 18 weeks= 2027-01-31 — wrong; the 2-week solicitation window is counted twice. - Post-award addend (1 + 1 + 13 + 1 = 16 weeks):
2026-09-27 + 16 weeks= 2027-01-17 — correct, and what that run published after deviating deliberately.
The 14-day error was invisible: Step 7a asserted only
expected_end_date > expected_start_date, which an over-long date satisfies. Step 7a now also bounds the span (dimagi-internal/ace#1858).templates/pdd-template.mddoes not pin the## Timelineconvention, so whether a given PDD's total starts at solicitation-open is a per-PDD fact you must READ, not assume. Where the rows are labelled, subtract by row; where only a total is given, subtract the solicitation window actually used. Either way, state the subtraction in the draft.Fields ACE used to write that are NOT in the labs canonical schema and MUST be removed from the payload:
overview(usedescription)response_window_days(computeapplication_deadlineinstead)anticipated_start/anticipated_end(useexpected_*)sample_target(useestimated_scale)rubric(useevaluation_criteria)response_questions(usequestions)pass_bar,eligibility_criteria,geographic_scope,per_hh_payment_band_usd— these aren't rendered by the public- detail template. Roll their content intodescription/scope_of_workprose instead. Adding new top-level fields to the payload that aren't insolicitations/models.py's @property accessors silently does nothing — they sit in the JSON blob unread.
is_public: trueflips the server-side public ACL flag (the field the/solicitations/marketplace query actually filters on). That means the title, description, scope_of_work, and questions become readable by any logged-in labs user. Run this scan on a private call too: a private call can still get a public Google Form (optional_steps.google_form) that carries the same text. Before callingcreate_solicitation, scan the composed payload and confirm:descriptioncontains no names, dates of birth, phone numbers, addresses, or health datascope_of_workreferences the LLO target population in aggregate terms (e.g. "households in Kerala") rather than naming specific people, facilities, or identifiable program participantsquestionsask for capability self-disclosure, not for PII
If the PDD body itself contains PII that would propagate into the solicitation, halt and surface a
[BLOCKER]naming the offending field — do NOT publish a redacted version silently, because the PDD is the operator's source of truth and they need to know it needs scrubbing.Internal-vocabulary scan — run it in the same breath as the PII scan, and before
is_public: true. You composedescription,scope_of_work,questions[].text,questions[].framingandevaluation_criteria[].{description,scoring_guide}out of the PDD and work order — documents saturated with ACE's own build-time vocabulary. Nothing downstream catches a leak: Step 7a's round-trip verifier asserts only lengths, counts, types and date ordering, andsolicitation-create-evalsees the prose only AFTER publication.Run
scanSolicitationProsefromlib/applicant-facing-jargon.tsover the composed payload. On any hit, halt with the[BLOCKER]thatformatJargonScanrenders and rewrite the offending field in the reader's language. Do not suppress it and do not publish. The scan exempts exactly one LEADING demonstration banner (the Step 6 private-call line, which itself says "ACE") — so compose a private call with the banner in place and scan the whole payload; do not scan a banner-less copy (ace#3253).Rate-scope scan — same breath, same gate. Run
scanRateScopefromlib/rate-scope-consistency.tsover the composed payload (with{ devicesCostedSeparately: true }only when an operator ruling overridesdevices-assumedfor the solicitation — see the devices block above). It enforces the two obligations in the ALL-IN design principle above: no field may invite a separately-funded line, and some field must ask how the rate splits between worker and commodity. On any issue, halt with the[BLOCKER]thatformatRateScoperenders. Both halves failed on solicitation 19201, so treat a clean result as the exception until you've read the composed budget question yourself.Two things about this check that are load-bearing:
framingis respondent-facing. Labs's public-detail template renders it directly above the prompt (see line ~480 below), so a note-to-self written there ships to the applicant. That is exactly how solicitation 19201 published "This is the archetype-specific question…" —archetypeis ACE taxonomy and means nothing to an LLO (ace#2264).- Product vocabulary is NOT a leak.
CommCare HQ,Connect,payment unitanddeliver unitare terms a respondent must learn to do the work, and the denylist deliberately excludes them — the first draft flagged three such passages on the real record before they were removed. If you find yourself wanting to add a term, check it against a published listing first; the helper's header explains why each exclusion is there.
Scope-of-work composition — derive from the work order, NOT from PDD-section concatenation. The work order is the comprehensive, opinionated program brief; this skill transforms it into a public- facing scope. The transform is:
Pull the work-order sections directly. Map work-order sub-sections to scope_of_work
##sub-headings in the markdown output:Work-order section Scope-of-work ##heading§2 Scope of Work (will) ## What we're asking the LLO to do§2 Scope of Work (will not) ## What is NOT in scope§3 Roles + RACI ## Roles & responsibilities§4.1 Verified Unit ## What counts as a verified unit§4.2 Verification criteria ## Verification & quality bar§4.3 Reporting cadence ## Reporting cadence§5 Payment Terms (+ schedule sub-table) ## Payment structure(de-prescribed — see below)§6 Timeline ## Indicative timeline§7 Ethics scope ## Ethics & compliance§8.1 Permissions / data handling ## Data handlingDe-prescribe contractual specifics during the transform:
- Exact dollar amounts → ranges with rationale. The work order
says "USD $1,800 total NTE, $10/HH"; the solicitation says
"Per verified HH visit: USD $8–15 band; LLO proposes the exact
rate in their response with regional cost-of-living
justification." The PDD's payment-band block (§ FLW Requirements
→
Per-visit payment rate band) is the canonical source for the range. - Exact start/end dates → month windows in the prose body
(the structured
expected_start_date/expected_end_datefields carry the exact dates separately). E.g. "Field weeks start in early June 2026; closeout end of July 2026." - Specific calendar weeks → relative windows. "Week 4 launch / Week 6 checkpoint" → "approximately 3 weeks after award (launch); approximately 5 weeks after award (mid-pilot checkpoint)."
- Specific tooling versions or build IDs → omit entirely (the LLO doesn't pick those).
- Operator-internal language (CCC ticket numbers, ACE skill names, run-id references) → omit entirely.
- Exact dollar amounts → ranges with rationale. The work order
says "USD $1,800 total NTE, $10/HH"; the solicitation says
"Per verified HH visit: USD $8–15 band; LLO proposes the exact
rate in their response with regional cost-of-living
justification." The PDD's payment-band block (§ FLW Requirements
→
Preserve all explanatory framing. The work-order's "why this verification rule," "why this reporting cadence," "why this ethics scope" prose is exactly what an LLO needs to understand what they're committing to. Do NOT compress it. If a paragraph explains the rationale for a constraint, the paragraph stays in the solicitation.
Surface open decisions. For each
status: openrow indecisions.yamlthat affects scope (payment, language, ethics surface, geographic scope, etc.), add a one-sentence note to the relevant##sub-section noting the deferral and pointing to the matching question (e.g. "Working language(s) are LLO-proposed — see Q5 in the response template").
Length target: 600-1000+ words. A scope-of-work shorter than 500 words is a signal that the work-order content was over-compressed; re-expand before publishing.
Format invariant: single markdown string,
##sub-headings between sections,-bullets for enumerated items. Never an array of strings (the labs template will string-coerce the array to Python list repr and render['item1', 'item2', ...]literally — verified live on solicitation 3130, jjackson/ace bug surfaced 2026-05-21).Description composition — same comprehensive treatment, separate target:
- Open with the problem framing from the PDD's
## Problem Statement. Lead with the real-world stakes (malaria deaths, the access-vs-use gap, what the literature does NOT yet localize). - Bridge to what this opportunity does — the exploration framing, why exploration before intervention, what's intentionally NOT measured here (e.g. intervention effect — that's a later opportunity).
- Close with what the dataset/output enables downstream (the named downstream consumer if one exists — e.g. GiveWell EOI barrier- diagnosis section — but written in plain terms an LLO can evaluate, not as procurement jargon).
- Foundation-pitch tone, not procurement-form tone. The LLO is a potential partner deciding whether the program is one they want to be part of; the description has to sell that, not just list facts.
- 500-800 words. A description shorter than 300 words is a signal the PDD framing was under-extracted; re-expand.
- Whole total:
Compose evaluation criteria locally. Read the PDD's archetype, intervention summary, success criteria, and the work-order's verification + RACI sections. Draft a structured rubric inline using the same archetype-aware judgment that
solicitation-create-evalwould apply.Required shape per criterion:
- id: string # kebab-case identifier, e.g. "field-ops-realism" — REQUIRED name: string # short title, e.g. "Field operations realism" description: string # 1-2 sentence explanation of what this measures weight: int # 5-30, integer; all criteria weights sum to 100 scoring_guide: string # what makes a 10/10 vs 5/10 vs 0/10 — concrete and falsifiable linked_questions: [string] # one or more question `id`s from the questions block; each criterion links to ≥1 questionid,name,weightare required by the labs schema.description,scoring_guide, andlinked_questionsare ACE content-quality requirements layered on top.idis kebab-case and unique within the rubric — deriveslugify(name)if needed, but emit it explicitly (auto-derivation would silently collide on duplicate names).scoring_guideandlinked_questionsMUST be non-empty; an emptyscoring_guidemakes the rubric uninterpretable to responding LLOs and silently ships an unscoreable solicitation. Weights must sum to 100 (integers — the labs template formats them as<weight>%). 5-8 criteria total; more than 8 dilutes the signal, fewer than 4 misses dimensions.Every question must be SCOREABLE by the criterion it links to, not merely linked to one. A link is a pointer; the
scoring_guideis whatsolicitation-reviewactually scores against. So after composing both blocks, walk the questions one at a time and ask: if a respondent answered only this question, is there language in its linked criterion'sdescription+scoring_guidethat tells a reviewer how to score that answer? If not, the question is decorative — a respondent spends effort on it and it contributes nothing. Fix it by widening the criterion'sscoring_guideto name the signals that question asks for (and widening itsname/descriptionto match, so the rubric does not misrepresent itself), or by giving the question a criterion of its own.Two link failures to check for explicitly, both cheap:
- A question linked to no criterion at all — scores nothing.
- A question linked to a criterion that never mentions its
subject — the silent one, because every structural check passes.
Counts reconcile,
linked_questionsall resolve, weights sum to
Measured on
spark-facilitator/20260908-2215(labs solicitation 19363): a requiredlanguage-capacityquestion — asking for Chichewa/Tumbuka fluency by role, and for native-speaker capacity to review app content the design records as machine-authored and unreviewed — was linked to afacilitation-track-recordcriterion whose prose contained no occurrence of language, fluency, translation, Chichewa or Tumbuka. 8 of 9 questions had an anchor; that one had none, and no criterion in the rubric mentioned language at all. Caught bysolicitation-create-eval, i.e. after publication, and repaired in place viaupdate_solicitation(dimagi-internal/ace#2305). Run this check BEFORE publishing.scoring_guideshape — what a strong response looks like: Eachscoring_guideMUST describe (a) what a strong (8-10) answer looks like — concrete, falsifiable signals; (b) what a weak (3-5) answer looks like — common shortcuts or vague phrasing; (c) what counts as 0 — missing or refused. Example for a "Field operations realism" dimension:Strong (8-10): week-by-week schedule names supervisor:FLW ratio, mid-pilot checkpoint participation is explicit, photo-heavy visit logistics (storage, upload bandwidth) addressed concretely with named owners. Mid (5-7): schedule present but supervision model thin; logistics gestured at. Weak (1-4): generic timeline, no supervisor-ratio discussed, logistics unaddressed. Zero: no schedule provided.
atomic-visit(4-axis starter): FLW deployment scale, geographic-fit, supervision model, data-quality track record.focus-group(6-axis starter — research-stage opps need deeper rubric than CHW-deployment opps):- Qualitative-research experience (weight ~0.20) — prior FGD or in-depth-interview engagements; ability to produce usable session-level qualitative content.
- Facilitator skill & language fit (weight ~0.20) — named facilitators with matching local-language fluency + 2+ years community-research experience.
- Homogeneous-group recruitment (weight ~0.15) — ability to recruit separate mother / father / grandmother groups in the same community without selection bias toward LLO-program-favored families.
- Coordinator capacity for gdoc review (weight ~0.15) — rolling-basis review of facilitator gdocs against the PDD's Output Specification.
- Audio handling out-of-band (weight ~0.10) — minimum-45-min audio capture, secure Drive-based storage, consent-decline fallback to notetaker-only.
- Timeline + per-session payment economics (weight ~0.20) — ability to field within window, comfortable with per-attestation-form-submission payment structure.
multi-stage: emphasize stage-gate discipline, archetype fluency across stages, transition-management. For each stage with its own archetype, fold in 2-3 axes from that archetype's starter.
Note (0.13.3): the earlier 0.12.0 SKILL.md called
mcp__connect-labs__generate_criteriahere. That atom does not exist in the labs MCP today (the underlying/api/generate-criteria/HTTP endpoint exists but isn't surfaced as a tool). When labs does expose it, we can swap this local composition step for an MCP call without changing the rest of the skill.Question composition — every question MUST have a
framingfield (1-2 sentences explaining why we're asking) AND atextfield (the actual prompt). The framing field is what surfaces the "what makes a strong response" intent to the LLO without making them guess.Required shape per question:
- id: string # kebab-case, e.g. "field-ops-realism" — REQUIRED by labs schema text: string # the actual prompt — REQUIRED by labs schema type: string # "textarea" (default) | "text" | "multiple_choice" | "number" — REQUIRED by labs schema framing: string # 1-2 sentence "why we're asking" preface — optional in labs schema, ALWAYS populated by ACE required: bool # default true (optional in labs schema) options: [string] # required when type=multiple_choiceLabs's public-detail template renders
framingabove the prompt in a muted "Why we're asking" preface block;solicitation-reviewconsumesframingdirectly as the rubric anchor for response scoring. Example:- id: field-ops-realism framing: | A strong response names supervisor:FLW ratios, handles the photo-heavy visit logistics, and treats the mid-pilot checkpoint as a real planning anchor. text: | Propose a week-by-week schedule from award through Week 10 closeout, including LLO mobilization, Connect onboarding, Learn calibration, field launch, mid-pilot checkpoint, and closeout. required: true type: textareaEmpty
framingis a[BLOCKER]—solicitation-reviewcan't score responses against a missing anchor.Dedupe by intent. The PDD's
## Solicitation→Response templatemay list opp-specific questions that overlap with the default set. Combine them — never publish two questions that ask the same thing in different wording (e.g. don't ask about "language capacity" AND "language + translation effort" as separate questions — fold them into one prompt with framing that calls out both axes).Length budget: 7-9 questions total. Fewer than 6 leaves evaluation criteria un-linkable; more than 10 fatigues the LLO and produces shallower answers across the board.
Default 6-question response template, archetype-branched (used as the starting set; merge in PDD overrides per the dedup rule above):
For every archetype except
focus-group(atomic-visit,multi-stage,longitudinal-visits, …) — the CHW-deployment vocabulary is the default; onlyfocus-groupswaps it out (ace#1691):- Describe your prior experience deploying CHW programs in this archetype.
- How will you recruit and train FLWs for this scope?
- What is your timeline for fielding once awarded?
- What is your supervision model?
- Do you have local-language capacity matching the target geography?
- Propose your all-in rate per verified service delivery, and state how much of it is paid to the worker vs. commodity. (ALL-IN — do NOT ask which costs would be "funded separately"; see the design principle above.)
For
focus-group(CHW-deployment vocabulary is wrong; swap to qualitative-research vocabulary):- Describe your prior qualitative-research experience (FGDs, in-depth interviews) — topic, segment counts, working language, and what synthesis output you produced.
- How will you recruit homogeneous mother / father / grandmother groups without overweighting households with prior LLO program history?
- What is your timeline from award to first practice-session-pass certification, and from award to first live FGD?
- Describe your coordinator capacity to review facilitator gdocs against an Output Specification rubric on a rolling basis.
- What local-language fluency do your named facilitators have, and what audio-recording equipment do you have available?
- Provide a per-session budget breakdown (facilitator + notetaker + participant compensation + venue + coordinator review amortized).
Write the draft for traceability. Save the full payload + the AI-derived rubric to:
ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_draft.mdInclude all fields from the payload as a structured YAML-frontmatter + prose body, so the
solicitation-create-evalrubric can re-read it.Compose it to a LOCAL FILE first, then pass
localFilePath— do not emit the document inline (ace#1918). Write the composed markdown to an absolute scratch path, then calldrive_create_file({name, localFilePath, parentFolderId}); the server reads the bytes off disk, so the write costs ~zero context regardless of size. Both this draft and thepublished.mdof step 8 carry the whole payload plus the derived rubric and routinely clear 40,000 chars — measured 49,531 (draft) and 51,920 (published) onbednet-check-2-visit/20260825-1310, both re-confirmed 2026-09-06. The inlinecontentparam still works and is fine for a short EOI.Compose the payload to
payload.jsonon disk in THIS step, before the Step 6 publish — not after it (ace#2261). The publish body, this draft and the Step 8published.mdare the same bytes; writing them once and pointing all three consumers at that one file makes them identical by construction rather than by diligence. Step 6 then sends that file directly (see § Step 6 → Large payloads), and steps 4 and 8 render their markdown from it.Resolve the labs program_id (integer). The labs MCP expects the labs integer program ID, not the Connect program UUID. Despite the schema's
program_id: string, labsint()-parses it internally and rejects UUIDs withValueError: invalid literal for int().Resolve in this order:
Fast path: if
opp.yaml.connect.program.connect_int_idis set (cached at program-create time byconnect-program-setup, or backfilled by a priorsolicitation-createrun), use it directly. This is the durable opp-level cache.Lookup: call
mcp__connect-labs__labs_context(). Find the organization by the org recorded inopp.yaml.connect.program.url(/a/<org>/program/<id>/— the program's own org, authoritative), elseopp.yaml.organization_slug, else the configured PM org (connect_orgs.pm_orgfrom preflight); within it, find the program whosenamematches the Connect program name fromruns/<run-id>/4-connect/connect-program-setup.md(the markdown summary written byconnect-program-setup). Capture the program's integerid.Cache: write the result to
opp.yaml.connect.program.connect_int_idviaupdate_yaml_file(merge: 'deep'— a partial patch ofconnect.program;two-levelwould replace theprogramsub-object wholesale and drop the existingprogram.id/program.url). This is opp-level state (the program is reused across runs, so its ConnectProd integer id is also opp-level). Also carry the value into this run'sphases.solicitation-management.products.solicitation.labs_program_idvia Step 9's consolidated write so the run state is self-contained.Register it in the opp's tenancy whenever this step resolved the id by lookup (sub-step 2) —
connect-program-setupStep 7 could not, because Connect never returns the integer to ACE and labs hides a fresh program for up to an hour (ace#3213). Run it BEFORE any program-scoped labs write (Step 5a onward), or the tenancy guard treats those writes as outside the opp:"$CLAUDE_PLUGIN_ROOT/bin/ace-bind" --add-program <connect_int_id> [<workspace>/<opp>]Idempotent, so run it on the fast path too when you cannot see that Phase 4 registered it. A failure does not halt this step: record a residual naming the command.
Halt with a
[BLOCKER]if no name match — but first rule out the cache: labs caches the org tree per user for an hour (_ORG_DATA_CACHE_TTL = 3600,dimagi-internal/connect-labsconnect_labs/labs/integrations/connect/oauth.py), so a program created under an hour ago can be missing. If the Connect program is that young, wait out the remainder and look up once more. Past that, the program likely exists but was never mirrored to labs. Surface the Connect program name and the list of labs programs the caller can see.
5a. Make the PM org the call's owner — register it as a labs organisation
first (ace#3049). Since connect-labs#2567 (merged 2026-10-16) a
solicitation must be owned by a labs organisation (LabsOrg) the caller
is a member of. program_id alone no longer names an owner: it resolves
only to a programme with a running organisation or to "an organisation labs
knows", and ACE's per-run Connect PM orgs are neither until they are
registered. Measured on noora-health/noora-health/20261015-1129
(2026-10-16, JSON-RPC tools/call create_solicitation), every form of the
call without a registered org was refused:
org_slug: noora-health-pm-test→org_slug: no organisation 'noora-health-pm-test' in labsorganization_id: "397"→no organisation '397' in labsprogram_id: "355"only →program 355 has no running organisation and no organisation labs knows, so it cannot own this call. Name the owning organisation with org_slug.
What worked, and what this step does on every run. The upsert is keyed by slug and binds by Connect id, but it is not a no-op on an org that already runs a labs programme: labs refuses even an identical upsert of one ("<org> runs a program on labs, so only platform support changes or merges its record", live on this instance's PM org, programme 361, 2026-10-20, ace#3261). So read first and skip a row that is already right:
The owner is the program's PM org — the org Step 5 resolved the program under (the
/a/<org>/program/<id>/segment ofopp.yaml.connect.program.url, else the run'sproducts.connect.pm_org_slug, else the configuredconnect_orgs.pm_org). Never the holding (NM) org and never the partner LLO: the call is the PM org's.Read its Connect integer org id from the same
labs_context()reply Step 5 used (the organization entry'sid). Do not guess it from a slug.Read
supply_chain_org_list({program_id}). If the PM org's row is there with the samename,connect_organization_idandconnect_organization_slugyou would send, skip the upsert and go to- Otherwise register it:
mcp__connect-labs__supply_chain_org_upsert({ data: { slug: <pm org slug>, name: <the org's display name from labs_context>, connect_organization_id: <Connect org integer id>, connect_organization_slug: <pm org slug>, }, })On the noora-health repro this returned labs org 349 with
is_linked: true. Binding byconnect_organization_idis what makes membership flow from Connect (supply_chain/runner.is_member), so the caller — a member of the PM org on Connect — is a member of the LabsOrg with no further grant.Pass
org_slug: <pm org slug>ANDprogram_id: <Connect program integer id>on the Step 6 call.org_slugnames the owner;program_idstill links the call to its programme. With both, the same repro published as labs solicitation 1000001.
Branch on the call result, not on a read-back: if the upsert errors, halt
with a [BLOCKER] naming the org slug and the error verbatim — publishing
without a registered owner is refused by labs anyway, and guessing a
different org_slug would file the call under the wrong organisation.
Publish. Call
mcp__connect-labs__create_solicitationwith the flat-fields shape from § Step 2 (every solicitation field as a top-level property — nodata: {...}envelope). Labs validates the canonical schema server-side and rejects drift withINVALID_SCHEMA+ per-fielderror.details.fields(JSON-path keyed). Read the error and fix the composition; do not retry with the same payload, and do not stuff extras into a free-form field.Stale-schema gotcha. If you see
INVALID_SCHEMAerrors that reference fields you swear are valid per the labs canonical schema (or vice versa — a payload that should be wrapped works when sent flat, or fields you expected to be optional are flagged required), the MCP subprocess in this session is probably holding a staletools/listview of the labs schema. The labs MCP schema can evolve faster than this skill's documentation, and the proxy's schema gets cached at MCP-subprocess startup. Recovery:Curl the live
tools/listdirectly:curl -sS -X POST https://labs.connect.dimagi.com/mcp/ \ -H "Authorization: Bearer $LABS_MCP_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'Both the
Acceptheader and the SSE-shaped response are load-bearing and neither is guessable. WithoutAccept: application/json, text/event-streamthe endpoint answers HTTP 406 with no body — measured 2026-09-08 onbednet-check-2-visit/20260907-1126, where the snippet as previously printed here simply did not work. The reply is then Server-Sent Events, not plain JSON: take the line beginningdata:and JSON-parse the remainder.Treat the response as truth. If it disagrees with this SKILL.md, this SKILL.md is wrong — re-read the schema and update.
Restart Claude Code (full process restart, not
/reload-plugins) so the MCP subprocess re-fetchestools/listat startup.Update this SKILL.md if the live schema has moved.
Call:
mcp__connect-labs__create_solicitation( org_slug: <pm org slug registered in step 5a>, # REQUIRED in practice since connect-labs#2567 — the owning LabsOrg program_id: <resolved labs_program_id as string>, title: ..., solicitation_type: 'eoi', # lowercase description: ..., # markdown string, 500-800 words scope_of_work: ..., # markdown string, 600-1000+ words, NOT an array application_deadline: 'YYYY-MM-DD', # date string, NOT response_window_days expected_start_date: 'YYYY-MM-DD', expected_end_date: 'YYYY-MM-DD', estimated_scale: 'human-readable string', # <= 200 chars (labs CharField(max_length=200)) contact_email: ..., # OPTIONAL — include ONLY if the PDD names a contact; else OMIT this line entirely (no env fallback, no bot inbox) evaluation_criteria: [ {id, name, weight, description, scoring_guide, linked_questions: [qid, ...]}, ... ], # weights sum to 100; id is REQUIRED questions: [ {id, text, type, framing, required}, ... ], # 7-9 items; framing is structured, not inlined in text status: 'active', is_public: <true; false when optional_steps.private_solicitation>, connect_opportunity_id: <int>, )Private (demonstration) solicitations. When
optional_steps.private_solicitationis true:Send
is_public: false.Prefix
descriptionwith this banner, verbatim, on its own line, before the composed body:> **[Demonstration — not a live call.]** Created by ACE to exercise the Labs workflow; responses are synthetic.The banner is mandatory. A private call is still readable by every member of the owning org and by anyone who responds, and a public Google Form for it (
google_form) reaches anyone with the link. Without the banner, a reader cannot tell a demonstration from a real call for partners.Record
visibility: privatein the Step 9 products block (publicotherwise). An absentvisibilityreads aspublic, which is the legacy behaviour.
Reads of an unlisted call need no
program_id. Labs authorizes them by owning-org membership: since connect-labs#2567,get_solicitation,list_responses,get_response,update_solicitationandaward_responseall describeprogram_idas "Accepted for compatibility; not needed" (livetools/list, 2026-10-18). ACE is a member of the owning PM org through the Step 5a registration, so the read-backs below work for a private call as written.Large payloads: send the file, don't retype it (ace#2261). The atom above takes inline arguments only — the live labs schema has no file-handle parameter (verified 2026-09-08 against
tools/list: the 21 declared properties contain nothing matchingfileorpath), andcreate_solicitationis a labs-side tool, so this cannot be fixed atom-side. A composed solicitation body routinely runs 33–50 KB (33,557 bytes onbednet-check-2-visit/20260907-1126; §§ 4 and 8 measure the same bytes at 49,531 / 51,920 on20260825-1310). Emitting that as tool arguments spends its full size in context a second time — the exact cost ace#1918 removed from this skill's two documents — and makes the published bytes and the draft two independent generations that can silently diverge.So above roughly 20 KB, publish by sending the Step-4
payload.jsondirectly, via the same server and the same server-side schema validation:python3 -c "import json; p = json.load(open('payload.json')); json.dump( {'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call', 'params': {'name': 'create_solicitation', 'arguments': p}}, open('rpc.json', 'w'))" curl -sS -X POST https://labs.connect.dimagi.com/mcp/ \ -H "Authorization: Bearer $LABS_MCP_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ --data-binary @rpc.jsonSame
Accept-header and SSE-response caveats as thetools/listsnippet above — plus the reply envelope, which is four levels deep and where the record is NOT the obvious one. Readresult.structuredContent: on bothcreate_solicitationandget_solicitationthat key holds the decoded solicitation record directly (id,questions,evaluation_criteria, the three dates,is_public, …).Do NOT reach through
result.content. It looks like the payload and is double-JSON-encoded:result["content"][0]["text"]is a JSON string that parses to another[{type, text}]list, whose[0]["text"]is a second JSON string that finally parses to the record —json.loads(json.loads(result["content"][0]["text"])[0]["text"]). An agent that stops at "JSON-parse thedata:line" gets{content, isError, structuredContent}and then raisesAttributeError/KeyErroron a publish that already succeeded (HTTP 200, record created), which is the worst possible moment to be unsure whether the call landed. Measured onspark-facilitator/20260908-2215(labs solicitation 19363, program 210): 3 calls burned on the unwrap depth, during the Step 7a round-trip, i.e. after the external write had landed (dimagi-internal/ace#2302).This is not a workaround for a schema problem — labs validates the payload identically on both routes and returns the same
INVALID_SCHEMA+error.details.fieldson drift; it is the same call made without laundering the body through the model. Step 7a'sget_solicitationround-trip is required either way and is what actually settles that the publish landed (it already says "whether via MCP atom or direct JSON-RPC"). Record the route insolicitation-create_draft.md. For a short EOI that fits comfortably inline, the atom is simpler — use it.Second-order reason this matters. A 33–50 KB generated argument object is the single largest generation in Phase 8, and a generation cut mid-call is precisely what raises
InputValidationError: <tool> was called with input that could not be parsed as JSON— a CLIENT-side error (seeCLAUDE.md§ Gotchas), where the tool never ran and the server never saw a byte. Reading the bytes off disk removes that failure mode instead of retrying into it.All solicitation fields are flat at the top of the atom's argument object. There is no
data: {...}envelope. Verified against the livetools/list: top-leveladditionalProperties: false, nodataproperty declared.Do NOT include
overview,response_window_days,anticipated_start,anticipated_end,sample_target,rubric,response_questions,pass_bar,eligibility_criteria,geographic_scope,per_hh_payment_band_usd, orbudget— none of these are read by the labs public-detail template. After labs's 2026-05-22 deploy, the MCP itself rejects unknown top-level fields withINVALID_SCHEMA+ per-field error details undererror.details.fields(JSON-path keyed, e.g.evaluation_criteria[0].linked_questions). Read the error and fix the composition; do NOT retry with the same payload, and do NOT work around schema rejections by stuffing extras into a free-form field. Step 7a'sget_solicitationround-trip remains the structural double-check.Capture the returned
id(the labs record id) intosolicitation_id. The response also includesexperiment(echo of the program_id) and the data object as written. Public URL pattern:${LABS_BASE_URL}/solicitations/<id>/. Manage URL pattern:${LABS_BASE_URL}/solicitations/<id>/edit/. NO/labs/prefix —connect-labs/config/urls.pymounts the solicitations app at/solicitations/, not/labs/solicitations/. The/labs/prefix is reserved for the authenticated Labs UI (overview, login, explorer).Check the route before recording the URL — for a listed call only. Labs answers an anonymous client differently by visibility (observed 2026-10-19, ace#3254, after connect-labs#2567):
Call Anonymous /solicitations/<id>/Observed on listed ( is_public: true), active200 1000001 listed, closed 302 → /solicitations/past/<slug>/1000002 unlisted ( is_public: false)404 — same as a missing record 1000004 (round-trips via get_solicitation); 9999999 (no record)- Listed call: issue a HEAD against the constructed URL and
expect 200 (a 302 to
/solicitations/past/…is fine too). A 404 on a listed call means the route or the record is wrong — surface it as a[BLOCKER]rather than writing a broken URL intorun_state.yaml. - Private (unlisted) call: skip the anonymous probe. Labs hides an
unlisted record from anyone outside its owning org, so a 404 is
the correct answer and tells you nothing. Never
[BLOCKER]on it. Step 7a'sget_solicitationround-trip is the check.
Either way, whether the record persisted with the visibility you sent is settled by Step 7a's
get_solicitationround-trip, not by this probe.
7a. Verify the round-trip via get_solicitation. Immediately after
publish (whether via MCP atom or direct JSON-RPC fallback), call
mcp__connect-labs__get_solicitation(solicitation_id=<returned>) and assert every load-bearing field
round-trips intact. Treat the response as the canonical view of
what labs persisted — if it diverges from what you sent, that's
either silent-drop (an extra field labs rejected without surfacing)
or silent-mutation (labs reformatting something).
If you read this back over raw JSON-RPC rather than the MCP atom, the
record is result.structuredContent — not result.content, which is
double-JSON-encoded and needs four unwraps. See the envelope note under
Step 6 (dimagi-internal/ace#2302). Assert against the decoded record, not
against whatever the first json.loads handed you.
Required round-trip assertions:
titlematches what was sent.descriptionlength is non-empty and ≥ 80% of the sent length (small whitespace/markdown normalization is OK; 20%+ shrinkage is silent-drop).scope_of_workis a string (not an array) AND length ≥ 80% of sent.application_deadlineparses asYYYY-MM-DDAND matches the computed deadline.expected_start_date+expected_end_dateboth parse and match.Ordering:
expected_start_date > application_deadlineANDexpected_end_date > expected_start_date. A violation is a[BLOCKER]— it is not a round-trip miss but an incoherent payload: the published page would tell candidate LLOs the work starts before their application is even due. Recompute per Step 2 and re-publish viaupdate_solicitationbefore writingpublished.md.Engagement span (ace#1858, scoped by ace#2231):
(expected_end_date − expected_start_date)must be ≤ the PDD's## Timelinetotal − the solicitation window the total ITSELF contains. Over-long is a[BLOCKER]: it is the signature of adding the PDD's whole timeline to a start date that already sits past the solicitation window — see the worked example under Step 2. This is a deliberately LOOSE ceiling — it does not additionally subtract any purely-contractual award row — so it catches the whole-total error without demanding the subtraction be exact.> startalone does not catch it: onbednet-check-2-visit/20260828-0629the wrong value was 2027-01-31 against a correct 2027-01-17 and passed the ordering check cleanly.The subtrahend is CONDITIONAL, on the same reading Step 2 already performs — do not subtract a window the total never contained. Resolve it exactly as rule (1) does, then apply:
The PDD's ## TimelineCeiling carries a solicitation-open row total − (application_deadline − publish date)states only a total, no row breakdown total − (application_deadline − publish date)— the window is assumed included, matching Step 2's fallbackstarts at award (its first row is post-award work — onboarding, recruitment, mobilisation) total, subtract nothingSubtracting unconditionally makes the ceiling
[BLOCKER]the value Step 2 computed CORRECTLY, and the two disagree by exactly the solicitation window. Measured onpoverty-graduation/20260908-0510(labs solicitation 19188, program 265): PDD § 17's first row is Partner onboarding and FLW recruitment, so its 18-week total holds no solicitation window; Step 2 correctly subtracts nothing and publishes 2027-02-09 (span 126d), while the unconditional ceiling reads126 − 14= 112d and rejects it. Complying would have published 2027-01-26 — two weeks short of the programme's own declared post-award stages, on a partner-facing listing. That is the exact inverse of the #1858 error this ceiling was added to catch, andtemplates/pdd-template.md § Timelineasks a PDD to state where its clock starts precisely because both shapes are legal.estimated_scaleis non-empty and at most 200 characters — check this on the composed payload BEFORE the Step 6 publish too, not only on the round-trip: the MCP schema has nomaxLength, so an over-long value is accepted by validation and dies in Postgres as a rawDataErrortraceback (ace#3049).contact_email: assert only if you sent one — when sent, it matches; when omitted (no PDD-derived contact), assert it is absent/empty on the round-trip (an omitted optional field is correct, not a miss).len(questions)matches sent; every question hasid,text,type,framing(non-empty), plusrequiredif sent.len(evaluation_criteria)matches sent; every criterion hasid,name,weight,scoring_guide,linked_questions(non-empty);sum(weights) == 100.is_publicmatches what was sent (true, orfalsefor a private call);status: 'active';connect_opportunity_idmatches sent.For a private call,
descriptionstarts with the demonstration banner.
Any miss → [BLOCKER] naming the missing or mutated field +
the probable cause (e.g. "scope_of_work returned as an array — labs
string→array coerced; check the payload was sent as a single
markdown string and not Python-list-coerced upstream"). Do NOT
proceed to write published.md against a half-persisted solicitation.
Why round-trip and not curl-the-public-URL. An anonymous curl
sees only a listed call's rendered page. An unlisted call answers 404,
the same as a missing one (§ Step 6), and neither tells you whether
every field persisted as sent. The get_solicitation round-trip is
the canonical structural check at the persistence layer.
This catches the class of bugs where the labs MCP accepts the
create cleanly but persisted state diverges from sent payload.
Verified live on solicitation 3130 (malaria-itn-app/20260521-1400)
where all 6 sections were broken simultaneously because the entire
payload schema had drifted — round-trip would have caught it at
write time instead of human-eye time.
7b. Verify the round-trip. Immediately after publish, call:
mcp__connect-labs__get_solicitation(
solicitation_id: <returned id>,
)
This catches the silent-misconfig class where the create succeeds but
the record is unreachable on subsequent reads. A private
(is_public: false) call is readable here because ACE belongs to the
owning org. Labs authorizes reads by owning-org membership, and the live
schema marks program_id "Accepted for compatibility; not needed". The
older rule, that an unscoped read sees only listed calls, described the
pre-connect-labs#2567 ConnectProd LabsRecord API, which no longer
serves these calls. If this read returns
"not found" for a private call, the cause is ownership, not a missing
program_id: check that the call's owner (org_slug) is the Step 5a
LabsOrg and that ace@ is a member of it. If the verification call
returns no record or a different id, halt and surface the mismatch —
do not proceed to write published.md or mutate opp.yaml.
Write
published.md. Save:ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_published.mdBody: full payload as written, returned IDs/URLs, deadline in absolute ISO-8601 form, the AI-derived
evaluation_criteria(sosolicitation-reviewandsolicitation-monitorhave the rubric without re-fetching from labs).Same rule as step 4: compose to a LOCAL FILE and pass
localFilePath(ace#1918). This is the larger of the two documents (51,920 chars measured). Reuse the step-4 scratch file with the returned IDs/URLs appended rather than re-composing the payload from scratch — the two documents share most of their body, and re-emitting it is exactly the cost this rule exists to avoid.Write the consolidated solicitation outputs block (only when a run is bound; outside a run see § Inputs → Write-back) to the current run's
run_state.yaml.phases.solicitation-management.products.solicitationviaupdate_yaml_file+merge: 'deep':phases: solicitation-management: products: solicitation: solicitation_id: <returned> labs_program_id: <integer resolved in step 5> owner_org_slug: <pm org slug registered in step 5a> visibility: <public | private> # private iff optional_steps.private_solicitation public_url: <returned> manage_url: <returned> type: <EOI|RFP> published_at: <now ISO-8601> deadline: <computed ISO-8601> status: open awarded: response_id: null awarded_at: null awarded_org_slug: null awarded_org_name: null awarded_contact_email: null award_amount: nulldeepmergesproducts.solicitationwhile preserving sibling keys on thesolicitation-managementphase block (status,steps, andproducts.selected_llo) at every depth —two-levelwould replace the whole phase block wholesale (#572/#587). This skill is the sole writer ofproducts.solicitationwithin the run;solicitation-reviewupdates the same block in place at award time (within the same run).selected_llois stubbed bysolicitation-reviewon award, not here.selected_llolives atphases.solicitation-management.products.selected_llo.No write to
opp.yaml.solicitation. Solicitations are per-run — every/ace:runpublishes a fresh solicitation; stale ones from prior runs are operator-cleaned-up when picking a release-candidate run.
Error handling
- Labs MCP unreachable (proxy returns transport error): halt with a
doctor-style message pointing at
/ace:doctor's[Connect Labs]section. create_solicitationreturns 4xx: preservedraft.md, halt, surface the error verbatim. Do not retry — most 4xx is a payload schema mismatch or the program_id is wrong.create_solicitationrefuses withno organisation '<x>' in labsor... cannot own this call. Name the owning organisation with org_slug.: Step 5a was skipped or registered a different slug. Run thesupply_chain_org_upsertof Step 5a for the PM org and retry with thatorg_slug(ace#3049). Do not switch toorganization_id— it resolves through the same registry and is refused the same way.create_solicitationfails withDataError: value too long for type character varying(200):estimated_scaleis over 200 characters. Shorten it in the payload and re-publish; nothing was written.generate_criteriareturns degenerate output (empty list, single criterion): write what was returned, markevaluation_criteriaasneeds-reviewinpublished.md, still publish. Criteria are editable post-publish via labs UI without losing responses.opp.yaml.program_idmissing: halt with "run Phase 4 (connect-setup) first to register a Connect program." The Connect UUID is the upstream evidence that a labs-side program will exist; without it there is nothing to look up inlabs_context.labs_contextreturns no name match for the Connect program: halt. Surface the Connect program name we tried to match plus the list of labs programs visible under the org. The remediation is usually one of: (a) labs's program shadow was created with a different display name (rename via the labs UI); (b) the Connect program was created in an org the caller can't see in labs_context (PAT scope mismatch); (c) the program was created so recently that labs hasn't synced yet — wait a minute and re-run. Do not publish a solicitation under a guessed labs_program_id.
Output
ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_draft.md(audit)ACE/<opp-name>/runs/<run-id>/8-solicitation-management/solicitation-create_published.md(live state)run_state.yaml.phases.solicitation-management.products.solicitation.{solicitation_id, public_url, deadline, status: open, labs_program_id, ...}populated (per-run only).opp.yaml.connect.program.connect_int_idcached on first resolution (durable opp-level — ConnectProd's integer id for the same program row as the UUID; reused across runs).selected_llois left untouched here (populated bysolicitation-reviewon award atproducts.selected_llo).
MCP Tools Used
connect-labs:labs_context(resolve Connect program name → labs integer program_id, when not cached; also the PM org's Connect integer id for step 5a),supply_chain_org_upsert(register the PM org as the owning LabsOrg — step 5a, ace#3049),create_solicitation(withorg_slug),get_solicitation(round-trip verification bysolicitation_id;program_idis not needed, even for a private call)ace-gdrive:drive_create_file,drive_read_file,drive_update_file,update_yaml_file(writephases.solicitation-management.products.solicitationtorun_state.yaml; cacheopp.yaml.connect.program.connect_int_idon first resolution)
Mode Behavior
- Auto: Publish in one pass.
- Review: Pause after Step 6, present
published.mdfor human approval before mutatingrun_state.yaml. (The publish itself already happened — review-mode is about the local state mutation, not the external call. If review rejects, the human can call labs'supdate_solicitationto draft or close the solicitation.) - Dry-run: Steps 1-4, skip steps 5-7. Verdict with
dry_run: true.
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. 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 8
| ID | Question | Map to surface |
|---|---|---|
solicitation-type |
Which solicitation kind? Options: EOI · RFP · custom |
solicitation-create-eval; affects who applies and at what fidelity |
response-deadline |
Days from publish to deadline (default 14)? | solicitation-create schema; gates Phase 8→9 timing |
response-template-choice |
Stock template vs opp-custom response form? | solicitation-create content; downstream solicitation-review rubric input |
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: 8-solicitation-management and
skill: solicitation-create.
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-10-20 | Step 5a reads supply_chain_org_list before the PM-org upsert and skips it when the row already matches: labs refuses even an identical upsert of an org that runs a programme (dimagi-internal/ace#3261). |
ACE team |
| 2026-10-16 | connect-labs#2567 contract: the call needs a registered owning org, and estimated_scale is capped at 200 chars (dimagi-internal/ace#3049). connect-labs#2567 (merged 2026-10-16) moved solicitations into the labs DB and made every call owned by a labs organisation (LabsOrg) the caller belongs to. ACE's per-run Connect PM orgs are not LabsOrgs, so on noora-health/noora-health/20261015-1129 all three forms were refused (org_slug → no organisation 'noora-health-pm-test' in labs; organization_id: "397" → no organisation '397' in labs; program_id: "355" alone → ... cannot own this call. Name the owning organisation with org_slug.). New § Step 5a registers the PM org unconditionally with supply_chain_org_upsert (bound by connect_organization_id, so membership flows from Connect; labs org 349, is_linked: true), and Step 6 passes org_slug + program_id — which published labs solicitation 1000001. estimated_scale is CharField(max_length=200) upstream with no schema maxLength, so a 234-char value surfaced as a raw Postgres DataError; the cap now lives in the § Step 2 field table, the pre-publish check and the Step 7a round-trip. owner_org_slug is recorded in the products block. The same upstream PR removed the ConnectProd /export/labs_record/ forward, so the CloudFront ~8 KB body block of ace#2993 is gone (a 26,689-byte payload published unchanged); this skill never carried a shortened-payload fallback for it, so there was nothing to retire here. |
ACE team |
| 2026-09-08 | § Step 3 required every criterion to link a question but never required the linked criterion to be able to SCORE it, so a required question could contribute nothing (dimagi-internal/ace#2305). The shape block asked for non-empty linked_questions and non-empty scoring_guide and stopped there — both are satisfied by a link that points at a criterion whose prose never mentions the question's subject. That failure is silent by construction: counts reconcile, every linked_questions entry resolves, weights sum to 100, and solicitation-review then scores responses against a scoring_guide with nothing in it about the thing that was asked. Measured on spark-facilitator/20260908-2215 (labs solicitation 19363): a required language-capacity question — Chichewa/Tumbuka fluency by role, plus native-speaker capacity to review app content the design itself records as machine-authored and unreviewed — was linked to facilitation-track-record, whose description and scoring_guide contained no occurrence of language, fluency, translation, Chichewa or Tumbuka; no criterion in the rubric mentioned language at all. 8 of 9 questions had a lexical anchor, that one had none. Caught by solicitation-create-eval after publication (verdict still pass at 9.16, but it drove the sole criteria_alignment deduction) and repaired in place with update_solicitation — safe because the record had zero responses. § Step 3 now requires a per-question scoreability walk before publishing, and names the two link failures to check: linked-to-nothing, and linked-to-a-criterion-that-never-mentions-the-subject. |
ACE team |
| 2026-09-08 | § Step 6 documented how to send the JSON-RPC request and stopped one unwrap into the REPLY, so the record could not be found on a publish that had already succeeded (dimagi-internal/ace#2302). The route itself was added hours earlier (ace#2261) and its response guidance ended at "take the line beginning data: and JSON-parse the remainder" — which yields {content, isError, structuredContent}, not the solicitation. The record is four levels down, and the middle two are unguessable: result["content"][0]["text"] is a JSON string that parses to another [{type, text}] list, whose [0]["text"] parses to the record. An agent following the doc literally calls .keys() or ["id"] on the first parse and raises AttributeError/KeyError after the external write landed (HTTP 200, record created) — the worst moment to be unsure whether a call worked. There is a one-liner the doc never mentioned: result.structuredContent holds the decoded record directly, on both create_solicitation and get_solicitation. Measured on spark-facilitator/20260908-2215 (labs solicitation 19363, program 210): 3 calls burned on the unwrap depth during the Step 7a round-trip. Same class as the Accept-header/406 fix in the entry below — request shape fixed, response shape still under-documented — and it now bites every Phase 8 above ~20 KB, which this skill measures as the normal case (33–50 KB). Step 6 gains the envelope note; Step 7a points at it for the read-back. |
ACE team |
| 2026-09-08 | Step 6 routed a >40 KB payload through the model's context while §§ 4/8 of the same skill mandated localFilePath for the SAME bytes (dimagi-internal/ace#2261). The draft, the published doc and the publish body are one payload — §§ 4 and 8 say so ("reuse the step-4 scratch file") and measure it at 49,531 / 51,920 chars — but only two of the three consumers were routed off disk. The third, the publish itself, was pinned to mcp__connect-labs__create_solicitation, whose arguments are inline-only; the labs schema has no file-handle param (verified against the live tools/list) and it is a labs-side tool, so it cannot be fixed atom-side. The JSON-RPC route that solves it was already acknowledged — once, as an unexplained "fallback", in Step 7a rather than Step 6 — so nothing told an agent it was allowed or that a large payload was the case for it. Measured on bednet-check-2-visit/20260907-1126 (labs solicitation 19201, program 231): 33,557-byte payload published via curl --data-binary @rpc.json, HTTP 200, Step 7a round-trip green on every assertion. Step 4 now composes payload.json BEFORE the publish; Step 6 documents the JSON-RPC route for bodies above ~20 KB and names the InputValidationError failure mode a large generated argument object invites. Also fixed the § Step 6 tools/list curl, which 406s as printed — it omits Accept: application/json, text/event-stream, and its reply is SSE rather than plain JSON. Both cost a round of rediscovery on the same run. |
ACE team |
| 2026-09-08 | Step 7a's span ceiling subtracted the solicitation window UNCONDITIONALLY, so a PDD whose ## Timeline starts at AWARD tripped a [BLOCKER] on the end date Step 2 had computed correctly (dimagi-internal/ace#2231). Step 2 conditions its subtraction on reading the rows — rule (1) subtracts a solicitation-open row that exists — while the ceiling added in #1858 subtracted the window whether or not the total contained it. The two therefore disagree by exactly the solicitation window on any PDD whose clock starts post-award, and the ceiling flags the right answer. Measured on poverty-graduation/20260908-0510 (labs solicitation 19188, program 265): § 17's first row is Partner onboarding and FLW recruitment, total 18 weeks with no solicitation row; Step 2 gives 2027-02-09 (span 126d) and the unconditional ceiling reads 126 − 14 = 112d and rejects it. Complying publishes 2027-01-26, two weeks SHORT of the programme's declared post-award stages — the exact inverse of the #1858 overshoot. That run deviated deliberately and recorded it in the draft, the published doc and the sol-response-deadline decisions row, but a compliant agent would have halted a completed publish at the last step of Phase 8. The ceiling's subtrahend is now a three-case table keyed on the same reading rule (1) performs, and rule (1) states the has-none case explicitly. Both shapes are legal by design — templates/pdd-template.md § Timeline asks a PDD to say where its clock starts. Enforced: test/skills/solicitation-end-date-addend.test.ts. |
ACE team |
| 2026-09-01 | expected_end_date re-spent the solicitation window the start date had already consumed (dimagi-internal/ace#1858). Step 2 said expected_start_date + the PDD's stated duration band, one row below a definition placing expected_start_date after the solicitation window and after award/contracting — while a PDD ## Timeline total for a solicited engagement normally starts its clock at solicitation-open. The two rows therefore double-count the window and the award step. Measured on bednet-check-2-visit/20260828-0629 (labs solicitation 17695, program 231): published 2026-08-30 → deadline 2026-09-13 → start 2026-09-27; the rule applied literally gives +18 weeks = 2027-01-31, against a correct post-award remainder of +16 weeks = 2027-01-17 — 14 days long on a public partner-facing listing. That run deviated deliberately and recorded the derivation in the draft plus the response-deadline decisions row, but a compliant agent would have published the overshoot and nothing would have caught it: Step 7a asserted only expected_end_date > expected_start_date, which an over-long date satisfies. Sibling of ace#1685, which re-anchored the start date in the same change but never scoped the end date's addend. The addend is now explicitly the PDD's post-award duration — total less the rows the deadline + contracting allowance have already passed — with the subtraction recorded in the draft; Step 7a gained a span ceiling (span ≤ total − solicitation window); and templates/pdd-template.md § Timeline now asks the PDD to state where its clock starts, since it pinned no convention and the ambiguity was structural. |
ACE team |
| 2026-08-26 | expected_start_date was derived from the Phase 4 opp row, publishing a start date BEFORE the solicitation's own application_deadline (dimagi-internal/ace#1685). Step 2 said "Phase 4 opp start_date if available", but Phase 4 runs before Phase 8 in the same /ace:run, so that date is always at or before the publish date while the deadline is publish + 14 days. Observed on hh-poverty-targeting/20260824-1404 (labs solicitation 17041, program 189): Phase 4 opp start_date 2026-08-26 vs computed application_deadline 2026-09-09 — following the rule literally publishes a work start 14 days before applications close. That run deviated deliberately (published 2026-09-21 → 2026-12-07, recorded in decisions.yaml row solicitation-date-window), but a compliant agent would have published the contradiction and nothing would have caught it: Step 7a asserted only that the dates round-tripped, and solicitation-create-eval dim 3 graded the deadline in isolation. The PDD fallback does not save it either — for a relative-to-award timeline (the normal shape here) there is no absolute start date to inherit. Both date rows are now anchored to application_deadline + a contracting allowance and the PDD's duration band, with the Phase 4 dates usable only when they already satisfy the ordering; Step 7a gained a [BLOCKER] ordering assertion, and the eval rubric grades the ordering. |
ACE team |
| 2026-08-19 | Deleted two superseded paragraphs that Step 6 still contradicted itself with (dimagi-internal/ace#1523). Surfaced on bednet-check-2-visit/20260817-1720 Phase 8 (labs solicitation 14796). The 2026-05-22 correction below rewrote Step 6's headline guidance but left the retracted prose sitting underneath it, so Step 6 gave opposite instructions on the only two things it exists to specify. (1) A paragraph claiming "the atom requires data (object) … flat top-level fields get dropped by the labs adapter" survived 19 lines below the bolded "All solicitation fields are flat … There is no data: {...} envelope". The flat shape is correct — re-verified against the live tools/list this run: additionalProperties: false, no data property, required: [title, description, solicitation_type] — so a payload built per the stale paragraph sends an unknown top-level key and is rejected INVALID_SCHEMA. Deleted. (2) "Verify reachability … confirm 200 … otherwise [BLOCKER]" contradicted Step 7a's own "Why round-trip and not curl-the-public-URL", which states that the detail page 302s to the labs login by design because is_public is marketplace listing visibility, not anonymous readability. Measured on 14796: /solicitations/14796/ → 302 → /labs/login/?next=…, never 200 — so the rule as written raised a [BLOCKER] on every correctly-published solicitation ACE will ever produce. Rewritten to check for 404 specifically (route missing → [BLOCKER]) and to state the 302 as expected, with get_solicitation named as the thing that actually settles whether the public flag flipped. Also fixed the stale "Step 7a's public-page verifier" phrase — Step 7a has been a persistence-layer round-trip since 2026-05-22. Class: docs/learnings/2026-04-28-mcp-vs-skill-doc-drift.md, same shape as the closed #1043 / #1055, except both leftovers here sat in the same section as the text superseding them. |
ACE team |
| 2026-07-29 | Fixed five drifts between skills/_solicitation-template.md and lib/artifact-manifest.ts (dimagi-internal/ace#1055), plus this skill's own self-contradicting § Products line. Surfaced during hh-poverty-targeting/20260728-0705 Phase 8 (labs solicitation 10687). The shared reference doc every Phase 8 skill points at had: (1) public_url: .../grants/solicitation/<id>/ — a route that does not exist; canonical is /solicitations/<id>/, which this skill's § Step 6 already documented including the "NO /labs/ prefix" trap. Verified live on solicitation 10687: /solicitations/10687/ → 302 (the route exists and redirects to the labs login, which § Step 6 documents as by-design — the detail page is not anonymously readable), while /grants/solicitation/10687/ → 404, i.e. the route the template documented genuinely does not exist; (2) prose calling the state block outputs at two places while its own code block and the rest of the file said products — products is correct and is what lib/phase-products-schema.ts and verify_phase_products enforce; (3) a Drive-paths table where two of four rows were wrong — solicitation-create_summary.md (no such artifact; it's _draft.md + _published.md) and llo-invite_outbound-emails/<llo>.md (it's llo-invite_invitations.md) — the table is now the full manifest set with each entry's real required flag; (4) awarded.awardee_org_slug where the writer emits awarded_org_slug, a silently-wrong key, plus an invented awarded_by and three missing fields; (5) id: <labs solicitation UUID> where the record id is an integer and the key is solicitation_id. This skill's § Products also still named the retired solicitation-create_summary.md while its own § Output correctly named the draft/published pair. Class: docs/learnings/2026-04-28-mcp-vs-skill-doc-drift.md — test/skill-atom-references.test.ts catches atom renames but not artifact paths or state keys restated in a shared skills/_*.md reference doc, the same blind spot ace#1043 hit two days earlier. |
ACE team |
| 2026-05-21 | Work-order-as-primary-input + canonical-schema field names + comprehensive-content shape. Three bundled rewrites prompted by solicitation 3130 on malaria-itn-app/20260521-1400 where the public page rendered blank Description, "TBD" timeline, "No deadline," Python-list-repr Scope, and zero questions / zero rubric simultaneously. (1) Inputs now read Phase 1's work order (1-design/pdd-to-work-order.gdoc) as the primary content source + decisions.yaml for later run decisions, alongside the PDD (now used for problem-framing only, not for scope). (2) Field names migrated to the labs canonical schema (description not overview, application_deadline not response_window_days, expected_start_date/_end_date not anticipated_*, estimated_scale not sample_target, questions[].text not response_questions[].question, evaluation_criteria[].name/.scoring_guide/.linked_questions not rubric[].dimension/.criterion; solicitation_type: 'eoi' lowercase). Top-level fields not in solicitations/models.py (pass_bar, eligibility_criteria, geographic_scope, per_hh_payment_band_usd, budget) folded into description/scope_of_work prose. (3) Content shape demands comprehensive prose: description 500-800 words foundation-pitch tone; scope_of_work 600-1000+ words derived section-by-section from the work order with explicit de-prescription rules (exact dollars → ranges, exact weeks → windows); every question has a required framing field; every evaluation criterion has a required scoring_guide + linked_questions. (4) Added Step 7a — a curl-the-public-URL structural verifier that catches field-name drift at write time instead of at human-eye time. |
ACE team |
| 2026-05-22 | (superseded — see 2026-05-22 correction below) Align with current labs reality + document the ideal end-state (jjackson/connect-labs#212). Three labs-side gaps surfaced during the malaria-itn-app 20260521-1400 Phase 8 republish required inline workarounds. Three of the four "labs-side gaps" I diagnosed turned out to be ACE-side stale-schema reads, not labs bugs — see the correction entry. |
ACE team |
| 2026-05-22 | Correction: 3 of 4 "labs gaps" in #212 were actually ACE reading a stale schema. The labs maintainer triaged jjackson/connect-labs#212 against the live tools/list and found: (a) wire-shape "drift" — NOT a labs bug; deployed schema is flat (no data envelope), the wrapped shape this skill called out came from ACE's stale view; (b) evaluation_criteria[].id undocumented — NOT a labs bug; live schema declares evaluation_criteria.items.required: [id, name, weight], ACE was reading a stale schema; (c) public-detail page 302s to login — WORKING AS INTENDED; is_public controls marketplace listing for logged-in users, NOT anonymous readability. Only #2 (framing on questions) was a real ask, and it turns out the deployed schema already has framing as an optional property — the live MCP accepts it as a structured key. Skill rewritten to match the live schema: dropped Step 6's wire-shape fallback paragraph (call the atom as documented, flat fields); dropped the criterion-id hedge (it's documented + required); emit framing as a structured key (drop the Why we're asking: <framing>\n\n<text> inline anchor convention); dropped the "Step 7a-bis curl-the-public-URL once #212 ships" plan (won't work — is_public isn't anonymity). Added new Stale-schema gotcha under Step 6 explaining the recovery: curl tools/list against the live labs MCP, treat that as truth, restart Claude Code (full process restart, not /reload-plugins) to pick up the fresh schema. Added "live tools/list is the canonical contract; if this SKILL.md disagrees, the SKILL.md is wrong" at the top of Step 2. The underlying lesson: when an MCP server can evolve faster than this skill's documentation, treat the live tools/list as the source of truth — never trust a cached view in the local subprocess. |
ACE team |
| 2026-05-22 | Architecture decision: ACE owns composition; labs validates. PR #396 had floated a future labs-side create_solicitation_from_brief MCP tool that would compose content server-side via labs's solicitation_agent. Walked back — operator chose to keep composition in ACE so this skill retains full control over voice, archetype-branched scope, framing/scoring_guide quality, and decisions-log integration (all of which are ACE-context that labs would have to learn). Labs's tightened MCP (forthcoming deploy: create_solicitation + update_solicitation now validate the canonical schema and fail loudly with INVALID_SCHEMA + error.details.fields on drift) is the right server-side contribution: schema enforcement, not content generation. This skill is the long-term home for solicitation composition; Step 6's payload shape is bound to labs's tools/list inputSchema rather than to a future composer call. Removal of the prior "Removal criteria" line. |
ACE team |
| 2026-09-06 | Large artifacts are composed to a LOCAL FILE and written with localFilePath (dimagi-internal/ace#1918). Both solicitation-create_draft.md (49,531 chars) and solicitation-create_published.md (51,920) were measured over 40,000 on bednet-check-2-visit/20260825-1310 and re-confirmed 2026-09-06. Step 8 now reuses step 4's scratch file rather than re-composing the shared body. Enforced: test/skills/large-artifact-localfilepath.test.ts. |
ACE team |