supply-chain-setup
Set up a labs programme's supply chain: who runs it, the team, commodities, items, stores and suppliers. Needs a program id.
Files1
View on GitHubSKILL.mdskills/supply-chain-setup/SKILL.md
GitHubSupply-chain setup
Prepares a labs programme so procurement can happen in it. It settles who runs the programme's
supply chain, puts ace@ on that org's team, and builds the registry: commodities, trade items,
supply points (a central store, a regional store, one point per field worker) and suppliers.
supply-chain-procure (tenders → contract), supply-chain-fulfil (shipments → payments) and
supply-chain-operate (stock, per week) build on what this writes.
Platform reference: playbook/integrations/connect-labs-supply-chain.md.
In /ace:run this is the optional supply_chain step (spec
docs/superpowers/specs/2026-10-18-optional-labs-e2e-steps.md § F2); it runs only in Phase 9
simulated mode.
Inputs
| Parameter | Used for | Resolver |
|---|---|---|
program_id |
the labs programme | explicit arg; default on the simulated track: run_state phases.execution-management.products.simulation.program_id (the labs-only programme simulated-execution Step 3 created for the visits, ace#3265); otherwise (a real programme's supply chain) phases.connect-setup.products.connect.program.connect_int_id, else opp.yaml.connect.program.connect_int_id. On the simulated track never the Connect program: labs reads visits into a worker's stock only on one synthetic programme, so a supply chain on the Connect program can never move stock from visits (connect-fi/20261018-1440 built it on 361 and the visits on 10168) |
opportunity_id |
the opportunity the workers deliver, stamped on every worker's point | explicit arg; default: run_state products.simulation.synthetic_opportunity_id on the simulated track |
geography |
store coordinates for Map | explicit arg; default: the geography block of simulated-execution's plan (PDD-cited coordinates) |
pm_org |
the Connect org that manages the program | explicit arg; default: run_state products.connect.pm_org_slug, else connect_orgs.pm_org (lib/connect-orgs.ts) |
llo_org |
the ACE-owned winner org, on the ace_winner path only |
explicit arg; default: run_state phases.solicitation-management.products.selected_llo.org_slug when synthetic_winner: true, else connect_orgs.llo_org |
ace_winner |
whether the winner runs procurement | explicit arg; default: isOptionalStepEnabled(run_state, 'ace_winner') (lib/optional-steps.ts) |
commodity_needs |
what the programme distributes, with quantities | explicit arg (a list of {commodity, unit, quantity, basis, issue_event, count_form?}); default: derived in Step 2 from the PDD (run_state phases.idea-to-design.products.pdd) |
flw_usernames |
the field workers who will hold stock | explicit arg; default: the synthetic FLWs of run_state phases.execution-management.products.simulation.synthetic_opportunity_id (synthetic_local_records_count / the generation manifest); outside simulation, the opportunity's workers |
scope |
the source.ref scope |
the run id inside /ace:run; outside one, programScope(program_id) (lib/supply-chain-ref.ts) |
simulated |
label every write as simulated and backdate it | true when Phase 9 mode: simulated; explicit arg otherwise |
sim_week0_start |
the Monday every setup write is dated in | run_state products.simulation.calendar.weeks[0].start (lib/sim-calendar.ts, PR10); explicit arg otherwise |
No run_state.yaml is needed when every parameter is passed. Run-state write-back (Step 9) happens
only when a run is bound.
Products
9-execution-manager/supply-chain-setup_manifest.yaml: what this run means to create, written in Step 3 before any write (runner choice, orgs, commodities, items, supply points, suppliers, quantities with their PDD citation). Outside a run, the same file in the program project folder.9-execution-manager/supply-chain-setup_record.md: the ids labs returned, the runner and standing as read back, and the labs page links for the programme.- run_state
phases.execution-management.products.supply_chain(inside a run):program_id,runner {org_id, role, buyer_of_record, fallback_reason?},labs_org_ids {pm, llo},commodities,items,supply_points {central, regional, flw},suppliers. This skill is the block's first writer; later supply-chain skills add their own keys under it.
Process
- Settle the runner (spec § F1). Read
supply_chain_programme_runner_get({program_id}).- A labs-only synthetic programme (id ≥ 10000, the simulated track) has no Connect manager
and labs never names a labs-only org as runner, so the winner and PM orgs are not eligible:
declare Dimagi (labs org slug
dimagi; read its id fromsupply_chain_org_list) withsupply_chain_programme_runner_set({program_id, org_id: <dimagi>, note, source}). ace@ is a Dimagi member, so its standing isprogramme, and Step 5 is skipped. The winner org stays the buyer on every contract:supply-chain-procurerecords it asbuyer_of_record: partner_orgwithbuyer_org_id= the winner's labs org id. Before connect-labs allowed a Dimagi member to declare Dimagi on a registered synthetic programme, this call was refused ("program 10168 has no running organisation yet, and only a member of the organisation that manages it in Connect (or platform support) declares the first one", live 2026-10-21, ace#3265); that refusal now means labs regressed, so halt quoting it. - Any other programme: decide with
planRunner(lib/supply-chain-runner.ts): - Who: on the
ace_winnerpath, the winner's labs org (llo_org) runs the supply chain, because the owner's intent is that the winner procures. Otherwise the PM org runs it. - No runner yet → declare one. Register both orgs first (Step 4's upserts; run them now if
they have not run), then call
supply_chain_programme_runner_set({program_id, org_id, note: "ACE <scope>: <role> runs procurement", source})for each candidate in order:- accepted → stop;
- refused, and
classifyRunnerRefusal(message)isnot_eligible→ try the next candidate (the PM org). Recordfallback_reasonwith the refusal text; - refused as
support_only→ halt with[BLOCKER] program <id> has no running organisation and labs lets only platform support declare one. Needed: supply_chain_programme_runner_set({program_id: <id>, org_id: <labs org id>}) by labs platform support (labs is answering with the pre-#2605 support-only gate).That gate was the live state until connect-labs #2605 deployed on 2026-10-18; since then a member of the programme's managing org declares it (verified live on program 308:your_standing: "programme"after the call). A support-only refusal today means labs regressed or the caller does not manage the programme. Never work around it; - refused for any other reason → halt, quoting the message.
- A runner already exists → never move it. Records already attributed to it cannot be
deleted (§ F6). Proceed under it if ACE's standing is
programmeoroperator; otherwise halt with theplanRunnerblocker text. - Verify. Re-read
programme_runner_get.your_standingmust beprogramme(oroperator). Anything else halts. The contract buyer of record follows from the runner:buyerOfRecord(role)givesprogramme_orgwhen the winner runs it andpartner_orgwhen the PM org does.
- A labs-only synthetic programme (id ≥ 10000, the simulated track) has no Connect manager
and labs never names a labs-only org as runner, so the winner and PM orgs are not eligible:
declare Dimagi (labs org slug
- Derive the commodity plan (when
commodity_needswas not passed). Read the PDD's distribution and payment sections. Each physical good a field worker hands out becomes one commodity, with the quantity the PDD states and the sentence it came from. Never invent a quantity: a need with no PDD number goes in the plan asquantity: null, basis: "not stated", andsupply-chain-procurewill refuse to tender it.- Categories.
commodity_upsert.categoryhas a closed, health-shaped list. Map to the nearest honest one and say so in the plan: a durable device →equipment; a single-use kit →consumable. A missing category is an upstream ask to connect-labs, not a blocker. - Stock class. On
item_upsert, a device isstock_class: durable; a kit used once isconsumable. - Issue event (required per commodity, ace#3216). Name the event that hands one over, as
issue_event(theIssueEventtype inlib/supply-chain-issue-events.ts). It is whatsupply-chain-operatedistributes by, so a missing one halts the week:kind: form_answer— the PDD's form (name or xmlns), the field as aform_jsonpath, the answers that mean "handed over",quantity_per_issue,eligible_share(the PDD's share of enrolees who get one, as a decimal string;nullif the PDD gives none),later_visit(the PDD says it usually happens on a later visit), andbasis(the PDD sentence).kind: per_enrolment—per_personandbasis. Only when the PDD says every enrolee gets one; never as a default for a commodity the PDD gates on eligibility.
- Count form (optional). When the Deliver app has a stock-count form, name it as
count_form: {form, field};supply-chain-operatereads worker counts from it. - Example (Connect FI): a basic feature handset (
equipment, durable), given only to enrolled adults with no working phone AND an ID card on record. Itsissue_eventisform_answer:form: "Follow-up Contact"(an UNPAID form),field: form.phone_obtained,values= the "handed over today" and "voucher redeemed" answers,quantity_per_issue: "1",eligible_sharefrom the PDD's 55-75% (cite the sentence),later_visit: true. Itscount_formis the weekly "Phone stock count" form. A SIM starter kit (consumable) isper_enrolmentonly if the PDD says every enrolee gets one. The PDD's enrolment targets cap the quantities.
- Categories.
- Write the plan (Products, first file). Every later step writes what the plan says, and the QA reads the plan back against labs.
- Register the orgs.
labs_context()gives each Connect org's integer id; never guess it from a slug. Forpm_org, and forllo_orgon the winner path, readsupply_chain_org_list({program_id})first and find the row by slug:- Row present and right (its
name,connect_organization_idandconnect_organization_slugall equal what you would send): skip the upsert and record its labs org id. Do not re-send it. An org that already runs a programme refuses even an identical upsert: "<pm org> runs a program on labs, so only platform support changes or merges its record: who it is decides who acts as that program" (live, programme 361, 2026-10-20, ace#3261; the PM org of any ACE instance that ran a supply chain before). - Row absent, or present and different:
supply_chain_org_upsert({program_id, data: {slug, name, connect_organization_id, connect_organization_slug}, source})and record the returned labs org id. If labs refuses it because the org runs a programme, the row is wrong and frozen: halt with a[BLOCKER]naming the slug, the field that differs and the refusal verbatim. Only platform support can edit or merge that record; never register a second slug to get round it. Binding byconnect_organization_idis what makes ace@'s Connect membership count as membership of the labs org. Get the row right before Step 1 declares it: once an org runs a programme its LabsOrg row is support-edit-only (connect-labs #2605), so a wrong name or Connect binding cannot be corrected afterwards.
- Row present and right (its
- Team. Skip this step when the runner is Dimagi (labs org slug
dimagi): Dimagi's members are everyone with a Dimagi address, so ace@ is already one, andprogramme_team_listrefuses with "Dimagi's team is everyone with a Dimagi address; it is not kept as a list" (live, program 10602, 2026-10-18). For any other runner, readsupply_chain_programme_team_list({program_id}). If ace@ (config/agent.jsonemail) is not anadmin, callsupply_chain_programme_team_add({program_id, email, role: "admin", source}). The call needs platform support or an existing admin of the runner; on a runner ACE just declared, ace@ is the declarer, and a refusal here halts with the refusal quoted. - Commodities and items. Read
supply_chain_commodity_listandsupply_chain_item_listfirst (the registries are programme-scoped). Thensupply_chain_commodity_upsert({program_id, data, source})per commodity andsupply_chain_item_upsert({program_id, data, source})per trade item, keyed by slug / sku. Givebase_per_packas the (simulated) manufacturer states it. A GTIN is optional; if you give one it must pass its GS1 check digit, so never make one up. - Supply points.
supply_chain_supply_point_upsert({program_id, data, source}), withsource: "we_recorded"indata:- one
central_store,managed_by_org_id= the winner's labs org id on the simulated track (the LLO keeps the store), else the runner's; - one
regional_store,parent_supply_point_id= the central store; - both stores with
latitude/longitude(andadmin_area) fromgeography, so Map draws them where they are. connect-fi/20261018-1440's stores were created without coordinates; - one
user_heldpoint per field worker:slug: user-<opportunity_id>-<connect_username>,connect_username= that worker,opportunity_id= the visit opportunity,parent_supply_point_id= the regional store,source: "connect_visit", and no coordinates (labs places a worker at the median of their visits' GPS). Withoutopportunity_id, labs refuses a distribution line naming the worker,worker_stockfiltered to the opportunity returns nobody, and the dispensing rule cannot find whose stock a visit drew on (ace#3265). Setmin_months_of_stock/max_months_of_stockas decimal strings (money()inlib/supply-chain-ref.ts) when the PDD gives a resupply band; otherwise leave them unset.
- one
- Suppliers. Read
supply_chain_supplier_listfirst to avoid duplicates. Thensupply_chain_supplier_create({program_id, data: {name, type, country, status: "identified"}, source})for each supplier in the plan. On the simulated track every supplier is invented, so every name goes throughsyntheticName()and must not be a real company's name. A supplier is a company shared across programmes; a real name would attach ACE's invented quotes to it everywhere. - Record. Write the registry file (Products, second file), then, inside a run, the
products.supply_chainblock withupdate_yaml_fileandmerge: 'deep'. - Hand to QA. Dispatch
supply-chain-setup-qa. Do not mark the step done on the write responses alone.
Every write carries source = buildSource({scope, skill: "supply-chain-setup", step, key, sender: "ACE", sentOn, simulated, today})
from lib/supply-chain-ref.ts. The ref is deterministic, so a re-run replays instead of writing
twice. On the simulated track sentOn is simulatedDate(sim_week0_start, 0).
MCP Tools Used
- connect-labs:
labs_context,supply_chain_programme_runner_get,supply_chain_programme_runner_set,supply_chain_org_upsert,supply_chain_org_list,supply_chain_programme_team_list,supply_chain_programme_team_add,supply_chain_commodity_list,supply_chain_commodity_upsert,supply_chain_item_list,supply_chain_item_upsert,supply_chain_supply_point_list,supply_chain_supply_point_upsert,supply_chain_supplier_list,supply_chain_supplier_create - ace-gdrive:
drive_create_file(plan + registry),update_yaml_file(run write-back only)
Existing objects
- The runner is never moved once declared (Step 1). Every tender, award, contract and payment is attributed to it and none of them can be deleted.
- Organisations and suppliers are labs-wide, not per programme. Upserting an org or creating a
supplier changes a row other programmes may also use. Bind real orgs only to the Connect org
they are; name invented suppliers with the
[Synthetic]marker. - The commodity, item and supply-point registries are programme-scoped, and upserts are keyed by slug / sku. Re-running on a programme that already has them updates in place.
- Never run this against a programme ACE does not own. The records it starts cannot be removed (spec § F6).
Mode Behavior
- Auto: runs Steps 1-10. Every halt in Step 1 is final for the run.
- Review: shows the Step 3 plan and waits before Step 4.
Dry-Run Behavior
When --dry-run is active, run Step 1's read (not the setter), Steps 2-3, and the read-first
*_list calls. Write the plan. Make no supply_chain_* write.
Change Log
| Date | Change | Author |
|---|---|---|
| 2026-10-21 | Simulated track builds on the visits' labs-only programme (products.simulation.program_id), declares Dimagi as runner there, gives stores coordinates and worker points the visit opportunity_id (owner audit of connect-fi/20261018-1440, ace#3265) |
ACE |
| 2026-10-20 | Step 4 reads supply_chain_org_list first and skips the upsert when the row already matches: labs refuses even an identical upsert of an org that runs a programme, and a mismatched running org is a halt (ace#3261) |
ACE |
| 2026-10-19 | Step 2 declares each commodity's issue_event (form + field + issue answers + eligibility share, or per_enrolment when the PDD says so) and optional count_form (ace#3216) |
ACE |
| 2026-10-18 | Initial version (optional-track PR6): runner gate with self-serve declaration and winner-runs-procurement, registry build, deterministic source refs | ACE |