Deploy the Promotion BO API
What this does
Turns on the Consumer Goods Cloud TPM Promotion Business Object (BO) API in a
Salesforce org, interviews the user for one custom Workflow Step to add to
the promotion pipeline, generates and deploys that Apex class, registers it
against a BO API entity, wires it into the customer's chosen subset of
{create, update, copy} workflows, then proves the whole path end-to-end via
three REST endpoints:
POST /services/apexrest/<prefix>/promotions/initialize
POST /services/apexrest/<prefix>/promotions/ingest
GET /services/apexrest/<prefix>/promotions/status?importId=...
It is meant for headless delivery: point it at an org, answer the interview,
and the skill installs the step and verifies it. The Promotion BO API ships in
the CGCloud managed package (namespace cgcloud released, cgcloud_dev
dev/beta). Every Apex identifier, object/field API name, and REST URL is derived
from the detected package prefix at runtime — this skill never hard-codes it.
derive is out of scope here (a follow-up skill covers it). Detailed runbooks
for the write and smoke phases live under references/:
references/conventions-and-payload-rules.md — sales-org partitioning, schema-first contract derivation, and the eight cross-workflow payload rules R1–R8.
references/generate-and-wire.md — Phase 5a (generate + deploy the class) and Phase 5b (register + wire the step), with the full register-step.apex.
references/smoke-and-verify.md — Phase 6 (contract derivation, payload materialization, create/update/copy legs + verification).
references/reference-example-set-comment-value.md — the shipped SetCommentValue preset (assets at assets/set-comment-value/).
Inputs to collect first
Ask before starting; do not guess.
- Org alias — the
sf alias of the target org. Required.
- Sales org — required pre-parameter, default
0001. Must be 4 chars, uppercase (mirrors TPMSetupData.validateSalesOrg). Every downstream lookup (promotion template, tactic template, anchor account, product filter criteria, SKUs) is scoped to this sales org — see references/conventions-and-payload-rules.md ("Sales-org partitioning").
- Yes to seeding? — Phase 3a fetches the packaged BO API seed and asks for confirmation before inserting ~232 rows across 8 objects. Offer
--yes for CI callers.
- Dry run? —
--dry-run runs Phases 1, 2, 3a-preview, the Phase 5 interview, and a validate-only deploy. Writes nothing.
What the customization is gets collected in Phase 5's interview, once the
framework state is known.
Find the namespace first
The prefix depends on the installed build — detect it, do not guess. Released
package → cgcloud; dev/beta → cgcloud_dev; source-deployed → no prefix.
sf data query --target-org <alias> \
--query "SELECT NamespacePrefix FROM ApexClass WHERE Name = 'TPMSetupData'" --json
Read records[0].NamespacePrefix and bind three variables used everywhere below:
PREFIX_DOT — Apex references: cgcloud. / cgcloud_dev. / empty.
PREFIX_UNDER — object/field API names: cgcloud__ / cgcloud_dev__ / empty.
URL_NS — REST paths: /cgcloud / /cgcloud_dev / empty.
Every Apex call is ${PREFIX_DOT}ClassName.method(...); every SOQL object/field is
${PREFIX_UNDER}Object__c; every REST URL is
/services/apexrest${URL_NS}/promotions/<endpoint>. Substitute at run time.
Phase 1 — Preflight
Run these and stop on the first failure.
sf --version; node --version (only if running shipped scripts).
sf org display --target-org <alias> --json — confirm reachable, capture instanceUrl + org id (for display/logging only). Every REST call in later phases goes through sf api request rest, which uses the CLI's stored session — this skill never extracts the access token.
- Validate the sales org locally: length 4, uppercase, non-blank (mirrors
TPMSetupData.validateSalesOrg). Fail fast instead of surfacing an Apex stack trace.
- TPM entitlement (Permission Set License) — the TPM app is licensed through the
CGCRetailAndTPMMgmtPsl Permission Set License ("CGC Retail and Trade Promotion Management"); there is no TPM App UserLicense. Check the running user's PSL assignment:SELECT Id FROM PermissionSetLicenseAssign
WHERE Assignee.Username = '<running-user-name>'
AND PermissionSetLicense.DeveloperName = 'CGCRetailAndTPMMgmtPsl'
On scratch/dev-hub the PSL may be legitimately unassigned; detect env with SELECT OrganizationType, IsSandbox FROM Organization:
OrganizationType = 'Developer Edition' OR IsSandbox = true and empty → warn ("permset gate below is the real check"), continue.
- Any other org shape and empty → stop ("TPM Permission Set License
CGCRetailAndTPMMgmtPsl not assigned to the running user").
- Running-user permission set (TPM Admin persona) — the setup needs a permission set granting the TPM Master Data Admin persona. The shipped permset is
TPM_Master_Data_Admin (packaged; PermissionSet.Name is the bare name regardless of namespace), but customers frequently clone it under their own name — so treat a miss as "ask", not "fail". Take <running-user-name> from sf org display ... .result.username:SELECT PermissionSet.Name, PermissionSet.Label FROM PermissionSetAssignment
WHERE Assignee.Username = '<running-user-name>'
AND (PermissionSet.Name = 'TPM_Master_Data_Admin'
OR PermissionSet.Label LIKE '%TPM%Master Data Admin%')
Match → proceed. No match → do not hard-fail on the name: show sf org assign permset --name TPM_Master_Data_Admin --target-org <alias> as the default option, but also ask the user to confirm whether they already hold an equivalent (possibly cloned/renamed) permission set for the TPM admin persona. Pause, wait for a cloned-permset confirmation or the assignment, then re-query. Proceed only once admin access is confirmed.
- Namespace discovery (above).
Phase 2 — Verify BO API framework state
Read-only; tells you whether Phase 3 needs to run. All three counts MUST be
scoped to <salesOrg> — the framework rows are sales-org-partitioned.
# 1. Workflow rows for this sales org (expect 4: create/update/copy/derive)
sf data query --target-org <alias> --json --query "
SELECT Name FROM ${PREFIX_UNDER}BO_API_Workflow__c
WHERE ${PREFIX_UNDER}BO_API__r.Name = 'Promotion'
AND ${PREFIX_UNDER}BO_API__r.${PREFIX_UNDER}Sales_Org__c = '<salesOrg>'"
# 2. Step rows for this sales org
sf data query --target-org <alias> --json --query "
SELECT COUNT(Id) FROM ${PREFIX_UNDER}BO_API_Workflow_Step__c
WHERE ${PREFIX_UNDER}Sales_Org__c = '<salesOrg>'"
# 3. Junction rows whose parent workflow belongs to a Promotion BO API for this sales org
sf data query --target-org <alias> --json --query "
SELECT COUNT(Id) FROM ${PREFIX_UNDER}BO_API_Workflow_Workflow_Step__c
WHERE ${PREFIX_UNDER}BO_API_Workflow__r.${PREFIX_UNDER}BO_API__r.Name = 'Promotion'
AND ${PREFIX_UNDER}BO_API_Workflow__r.${PREFIX_UNDER}BO_API__r.${PREFIX_UNDER}Sales_Org__c = '<salesOrg>'"
- Zero workflow rows → fresh for this sales org, Phase 3 will seed.
- Full state (4 workflow rows
create/update/copy/derive; >0 step + junction rows) → Phase 3 is a no-op for this sales org (still run for idempotency; 3a shows zero net-new).
- Partial state → error; stop, report which rows are missing. A human inspects first.
Phase 3a — Preview the default BO API seed (fetch + confirm)
The workflows/steps/junctions/entities/input-structures ship as CSVs inside the
packaged TPMSetupData static resource; TPMSetupData.setupBOApi reads and
upserts them. Before running it, show the user what will land.
- Locate the resource:
sf data query --target-org <alias> --json --query "
SELECT Id, Name, NamespacePrefix, SystemModStamp, BodyLength
FROM StaticResource WHERE Name = 'TPMSetupData'"
Zero rows → stop ("TPMSetupData static resource not found — is the CGCloud package installed?"). Multiple rows → prefer the one whose NamespacePrefix matches Phase 1; if prefixes disagree, stop and ask.
- Retrieve + expand the resource cross-platform via SFDX — use
sf project retrieve, which unpacks a zip StaticResource into a folder on every OS (no curl, no unzip — unzip isn't present on Windows by default). Retrieve into a dedicated setup-data/ subproject so the read-only managed resource never mixes into the Phase-5a deploy tree (./.promotion-bo-api-deploy/force-app, which is what gets deployed back):mkdir -p ./.promotion-bo-api-deploy/setup-data/force-app/main/default
printf '{ "packageDirectories": [{ "path": "force-app", "default": true }], "sourceApiVersion": "60.0" }' \
> ./.promotion-bo-api-deploy/setup-data/sfdx-project.json
( cd ./.promotion-bo-api-deploy/setup-data && sf project retrieve start \
--metadata "StaticResource:${PREFIX_UNDER}TPMSetupData" --target-org <alias> --json )
Non-success → stop, print result errors.
- SFDX expands the zip to
./.promotion-bo-api-deploy/setup-data/force-app/main/default/staticresources/${PREFIX_UNDER}TPMSetupData/BOApi/. ls it. Expected: 0_BO_API__c.csv, 0_BO_API_Entity__c.csv, 0_BO_API_Output_Entity__c.csv, 0_BO_API_Workflow__c.csv, 0_BO_API_Workflow_Entity__c.csv, 0_BO_API_Workflow_Step__c.csv, 0_BO_API_Workflow_Workflow_Step__c.csv, 0_BO_API_Step_Input_Structure__c.csv, import.json. Any missing → stop, print the delta.
- Confirm each target sObject exists:
sf sobject describe --sobject '${PREFIX_UNDER}BO_API_Workflow__c' --target-org <alias> > /dev/null (etc.). Any describe failure → stop (package partially installed).
- Show a summary (row counts per object; total ~232 rows across 8 objects; "upsert on Unique_Key__c — re-running is idempotent"), plus the first 3 rows of
0_BO_API_Workflow__c.csv and 0_BO_API_Workflow_Step__c.csv with {{NS}}/{{SALES_ORG}} substituted.
- Wait for confirmation unless
--yes. "no"/blank → abort clean, point at ./.promotion-bo-api-deploy/setup-data/force-app/main/default/staticresources/${PREFIX_UNDER}TPMSetupData/BOApi/.
- On
--dry-run → stop here; do not run 3b.
Phase 3b — Apply the default seed
Only after 3a confirmation.
PHASE3_START=$(date -u +%FT%TZ).
- Run metadata-wizard setup via anon Apex:
${PREFIX_DOT}TPMSetupData.setupMetadataWizard('<salesOrg>'); → sf apex run --target-org <alias> --file ./.promotion-bo-api-deploy/setup-metadata-wizard.apex.
- Wait for the whole batch chain.
GenericDemoSetupDataBatch self-chains via finish(); polling a single job id misses children. Poll by class:sf data query --target-org <alias> --json --query "
SELECT COUNT() FROM AsyncApexJob
WHERE ApexClass.Name = 'GenericDemoSetupDataBatch'
AND CreatedDate >= ${PHASE3_START}
AND Status NOT IN ('Completed','Failed','Aborted')"
Sleep 5s between polls; break when count is zero for two consecutive polls (covers the gap between a parent's finish() and the child's AsyncApexJob row appearing).
- Final check — every child succeeded (
SELECT Id, Status, NumberOfErrors, ExtendedStatus FROM AsyncApexJob WHERE ApexClass.Name = 'GenericDemoSetupDataBatch' AND CreatedDate >= ${PHASE3_START}). Any Failed/Aborted/NumberOfErrors > 0 → stop, print ExtendedStatus.
- Repeat 2–4 for
${PREFIX_DOT}TPMSetupData.setupBOApi('<salesOrg>');.
Phase 4 — Verify the BO API framework is on
Re-run the Phase 2 queries. Expect 4 BO_API_Workflow__c rows
(create/update/copy/derive, uniqueness on Unique_Key__c), ≥46
BO_API_Workflow_Step__c rows, ≥54 BO_API_Workflow_Workflow_Step__c junction
rows. Anything short → stop, print what's missing. Never claim success while a
component failed.
Phase 5 — Interview: what does the user want to customize?
The framework is now on. The customization is user-supplied — do not skip
the interview; do not invent an answer. Print:
The Promotion BO API framework is installed. To add a custom step, tell me:
- Which BO API entity does it target? (e.g. Promotion or Tactic — I'll list the exact entities registered in your org)
- Which workflows should it fire in? (create, update, copy)
- What does it read from the ingest input?
- What does it write on the SObject or elsewhere?
- Any preconditions or side effects I should know about?
If you want a worked example, say "use the SetCommentValue reference" —
it copies the tactic input Comment onto Tactic.Comment__c.
5.1 — Answers to collect
| Field |
Required |
Notes |
stepName |
yes |
PascalCase Apex class name + BO API Workflow Step Name. Unique across ApexClass. |
entity |
yes |
BO API entity Name — MUST match a row queried live from ${PREFIX_UNDER}BO_API_Entity__c (shared, not sales-org-partitioned); do not assume a fixed list. In a current org these include Promotion, Tactic, ProductFilter, ManualInputs, CustomState (plus the TPM_Promotion.* structures). Query the org and offer the actual rows. |
workflows |
yes |
Non-empty subset of {create, update, copy}. derive is out of scope. |
actionName |
yes |
Symbolic action string the class receives in call(String action, ...). Convention: lowerCamelCase of stepName. |
inputPaths |
yes (≥1) |
JSON paths to read from currentInput, e.g. ["Comment"]. |
inputPathType |
yes |
JSON type of the input paths — String, Array, Boolean, Number, Object. Drives the RecordType on each SIS row; a wrong value causes TransformationError: Expected <Type> at ingest. Different types → run Phase 5b once per type group. |
outputWrites |
yes (≥1) |
{field, source} pairs; field is the target API name without the namespace prefix (skill adds ${PREFIX_UNDER}), source references an inputPaths value / literal / computed expression. |
description |
yes |
One-sentence step description → ${PREFIX_UNDER}Description__c + class docstring. |
preconditions |
optional |
Free-text guards before writing. |
sortAfter |
optional |
Named packaged step to sort after; default max(Sort__c) + 10. |
5.2 — Interview flow
- Preset shortcut. If
--preset set-comment-value or the user says "use the SetCommentValue reference", load the answers from references/reference-example-set-comment-value.md (assets/set-comment-value/interview-answers.json) and skip the interactive interview. --interview-file <path> loads answers from a JSON file mirroring the 5.1 table (wins over --preset).
- Ask the questions one at a time (or as a block); do not proceed until every required field has a concrete value.
- Show the entity's writeable fields after the user names the entity:
sf sobject describe --target-org <alias> --sobject '${PREFIX_UNDER}<Entity>__c' --json \
| jq -r '.fields[] | select(.updateable == true and .createable == true) | .name'
Any outputWrites field must appear here; otherwise stop and ask for a different one (FLS is caller-scoped).
- Confirm the plan (recap Class/Entity/Workflows/Reads/Writes/Sort/Description → "Deploy + wire? [y/N]").
n/blank → save answers to ./.promotion-bo-api-deploy/interview.json and exit. y → Phase 5a.
Phases 5a / 5b — Generate + deploy, register + wire
Runbook in references/generate-and-wire.md. In short: guard the class name for
idempotency; confirm target-field writeability; generate a namespace-agnostic
Callable from the interview (reads currentInput/currentOutput, derives the
prefix from getSObjectType() at runtime); show + confirm; sf project deploy start. Then, in one anon-Apex transaction, upsert one BO_API_Workflow_Step__c
(bare class name in Classname__c), one BO_API_Workflow_Workflow_Step__c
junction per chosen workflow (never assume all three; derive dropped), and the
BO_API_Step_Input_Structure__c rows (RecordType per inputPathType). Both
upserts key on Unique_Key__c (idempotent).
Phase 6 — Smoke test through the REST endpoints
Runbook in references/smoke-and-verify.md. Derive the payload contract per
invoked workflow from BO_API_Step_Input_Structure__c (schema-first), apply
rules R1–R8 from references/conventions-and-payload-rules.md, resolve every
reference in-sales-org, materialize ./out/smoke/{create,update,copy}.json, then
run initialize → ingest → poll status for each chosen workflow. Verify via a
direct BO_API_Transaction_Log__c query (any row != 'Calculated' → fail) plus
an outputWrites field assertion against ./out/smoke/expected.json. Update and
copy legs run only if the interview included them.
Phase 7 — Report
Short status: org, sales org, dry-run flag; namespace prefix; BO API seed rows
before/after (Phases 2 & 4); interview (preset name or resolved stepName,
entity, workflows, inputPaths, outputWrites); class deploy result +
workflow-step id + junction ids (1–3); smoke import ids per invoked workflow, all
Calculated with matching write assertions — or exactly which failed (SOQL,
expected, actual); manual follow-ups (none if clean).
Rules
- Never claim success while any
BO_API_Transaction_Log__c row for the smoke import ids has Status__c != 'Calculated'. Calculated is the terminal success state (R2); Processed does NOT exist in the picklist.
- Never claim success while any
outputWrites assertion fails, or while any GenericDemoSetupDataBatch AsyncApexJob since the phase start is still running or failed.
- Stop on the first failed preflight or verify check; report the exact failure.
- Never hard-code the namespace prefix — compose every Apex/SOQL/REST reference from the Phase-1 detection.
- Never invent Apex method signatures — use what the packaged
TPMSetupData / BO_API_* objects and the shipping REST endpoints declare.
- The seeded BO API metadata is packaged content — preview it and get user confirmation before invoking
TPMSetupData.setupBOApi.
- Never invent an interview answer. Absent a user and any
--preset/--interview-file, stop and print the questions.
- Never wire a workflow the interview did not include.
create/update/copy are individually opt-in; derive is always excluded.
- Never compose a smoke payload from a hard-coded template — derive accepted paths per workflow from
BO_API_Step_Input_Structure__c and validate user input against that contract first.
- Never resolve a reference value without the sales-org filter. A record under a different sales org is not valid for this invocation.
1---2name: consumer-goods-promotion-bo-api-deploy3description: Use to enable the Consumer Goods Cloud TPM Promotion Business Object (BO) API framework in a Salesforce org, interview the user for the custom Workflow Step they want to add, generate and deploy that Apex class, wire it into the create/update/copy workflows, and prove it end-to-end via headless REST ingest. Ships a worked reference example (SetCommentValue) but the customization is user-supplied per org. Triggers on "deploy the promotion BO API", "set up promotion BO API", "headless promotion create", "add a BO API workflow step".4---5
6# Deploy the Promotion BO API
7
8## What this does
9
10Turns on the Consumer Goods Cloud TPM Promotion Business Object (BO) API in a
11Salesforce org, interviews the user for **one custom Workflow Step** to add to
12the promotion pipeline, generates and deploys that Apex class, registers it
13against a BO API entity, wires it into the customer's chosen subset of
14`{create, update, copy}` workflows, then proves the whole path end-to-end via
15three REST endpoints:
16
17- `POST /services/apexrest/<prefix>/promotions/initialize`
18- `POST /services/apexrest/<prefix>/promotions/ingest`
19- `GET /services/apexrest/<prefix>/promotions/status?importId=...`
20
21It is meant for **headless delivery**: point it at an org, answer the interview,
22and the skill installs the step and verifies it. The Promotion BO API ships in
23the CGCloud **managed package** (namespace `cgcloud` released, `cgcloud_dev`
24dev/beta). Every Apex identifier, object/field API name, and REST URL is derived
25from the detected package prefix at runtime — this skill never hard-codes it.
26
27`derive` is out of scope here (a follow-up skill covers it). Detailed runbooks
28for the write and smoke phases live under `references/`:
29
30- `references/conventions-and-payload-rules.md` — sales-org partitioning, schema-first contract derivation, and the eight cross-workflow payload rules R1–R8.
31- `references/generate-and-wire.md` — Phase 5a (generate + deploy the class) and Phase 5b (register + wire the step), with the full `register-step.apex`.
32- `references/smoke-and-verify.md` — Phase 6 (contract derivation, payload materialization, create/update/copy legs + verification).
33- `references/reference-example-set-comment-value.md` — the shipped `SetCommentValue` preset (assets at `assets/set-comment-value/`).
34
35## Inputs to collect first
36
37Ask before starting; do not guess.
38
391. **Org alias** — the `sf` alias of the target org. Required.
402. **Sales org** — required pre-parameter, default `0001`. Must be 4 chars, uppercase (mirrors `TPMSetupData.validateSalesOrg`). **Every** downstream lookup (promotion template, tactic template, anchor account, product filter criteria, SKUs) is scoped to this sales org — see `references/conventions-and-payload-rules.md` ("Sales-org partitioning").
413. **Yes to seeding?** — Phase 3a fetches the packaged BO API seed and asks for confirmation before inserting ~232 rows across 8 objects. Offer `--yes` for CI callers.
424. **Dry run?** — `--dry-run` runs Phases 1, 2, 3a-preview, the Phase 5 interview, and a validate-only deploy. Writes nothing.
43
44What the customization *is* gets collected in Phase 5's interview, once the
45framework state is known.
46
47## Find the namespace first
48
49The prefix depends on the installed build — detect it, do not guess. Released
50package → `cgcloud`; dev/beta → `cgcloud_dev`; source-deployed → no prefix.
51
52```bash
53sf data query --target-org <alias> \
54 --query "SELECT NamespacePrefix FROM ApexClass WHERE Name = 'TPMSetupData'" --json
55```
56
57Read `records[0].NamespacePrefix` and bind three variables used everywhere below:
58
59- `PREFIX_DOT` — Apex references: `cgcloud.` / `cgcloud_dev.` / empty.
60- `PREFIX_UNDER` — object/field API names: `cgcloud__` / `cgcloud_dev__` / empty.
61- `URL_NS` — REST paths: `/cgcloud` / `/cgcloud_dev` / empty.
62
63Every Apex call is `${PREFIX_DOT}ClassName.method(...)`; every SOQL object/field is
64`${PREFIX_UNDER}Object__c`; every REST URL is
65`/services/apexrest${URL_NS}/promotions/<endpoint>`. Substitute at run time.
66
67## Phase 1 — Preflight
68
69Run these and stop on the first failure.
70
711. `sf --version`; `node --version` (only if running shipped scripts).
722. `sf org display --target-org <alias> --json` — confirm reachable, capture `instanceUrl` + org id (for display/logging only). Every REST call in later phases goes through `sf api request rest`, which uses the CLI's stored session — this skill never extracts the access token.
733. Validate the sales org locally: length 4, uppercase, non-blank (mirrors `TPMSetupData.validateSalesOrg`). Fail fast instead of surfacing an Apex stack trace.
744. **TPM entitlement (Permission Set License)** — the TPM app is licensed through the **`CGCRetailAndTPMMgmtPsl`** Permission Set License ("CGC Retail and Trade Promotion Management"); there is **no** `TPM App` UserLicense. Check the running user's PSL assignment:
75 ```sql
76 SELECT Id FROM PermissionSetLicenseAssign
77 WHERE Assignee.Username = '<running-user-name>'
78 AND PermissionSetLicense.DeveloperName = 'CGCRetailAndTPMMgmtPsl'
79 ```
80 On scratch/dev-hub the PSL may be legitimately unassigned; detect env with `SELECT OrganizationType, IsSandbox FROM Organization`:
81 - `OrganizationType = 'Developer Edition'` OR `IsSandbox = true` and empty → **warn** ("permset gate below is the real check"), continue.
82 - Any other org shape and empty → **stop** ("TPM Permission Set License `CGCRetailAndTPMMgmtPsl` not assigned to the running user").
835. **Running-user permission set (TPM Admin persona)** — the setup needs a permission set granting the *TPM Master Data Admin* persona. The shipped permset is `TPM_Master_Data_Admin` (packaged; `PermissionSet.Name` is the bare name regardless of namespace), but **customers frequently clone it** under their own name — so treat a miss as "ask", not "fail". Take `<running-user-name>` from `sf org display ... .result.username`:
84 ```sql
85 SELECT PermissionSet.Name, PermissionSet.Label FROM PermissionSetAssignment
86 WHERE Assignee.Username = '<running-user-name>'
87 AND (PermissionSet.Name = 'TPM_Master_Data_Admin'
88 OR PermissionSet.Label LIKE '%TPM%Master Data Admin%')
89 ```
90 Match → proceed. No match → **do not hard-fail on the name**: show `sf org assign permset --name TPM_Master_Data_Admin --target-org <alias>` as the default option, but also ask the user to confirm whether they already hold an equivalent (possibly cloned/renamed) permission set for the TPM admin persona. Pause, wait for a cloned-permset confirmation or the assignment, then re-query. Proceed only once admin access is confirmed.
916. Namespace discovery (above).
92
93## Phase 2 — Verify BO API framework state
94
95Read-only; tells you whether Phase 3 needs to run. All three counts MUST be
96scoped to `<salesOrg>` — the framework rows are sales-org-partitioned.
97
98```bash
99# 1. Workflow rows for this sales org (expect 4: create/update/copy/derive)
100sf data query --target-org <alias> --json --query "
101 SELECT Name FROM ${PREFIX_UNDER}BO_API_Workflow__c
102 WHERE ${PREFIX_UNDER}BO_API__r.Name = 'Promotion'
103 AND ${PREFIX_UNDER}BO_API__r.${PREFIX_UNDER}Sales_Org__c = '<salesOrg>'"
104
105# 2. Step rows for this sales org
106sf data query --target-org <alias> --json --query "
107 SELECT COUNT(Id) FROM ${PREFIX_UNDER}BO_API_Workflow_Step__c
108 WHERE ${PREFIX_UNDER}Sales_Org__c = '<salesOrg>'"
109
110# 3. Junction rows whose parent workflow belongs to a Promotion BO API for this sales org
111sf data query --target-org <alias> --json --query "
112 SELECT COUNT(Id) FROM ${PREFIX_UNDER}BO_API_Workflow_Workflow_Step__c
113 WHERE ${PREFIX_UNDER}BO_API_Workflow__r.${PREFIX_UNDER}BO_API__r.Name = 'Promotion'
114 AND ${PREFIX_UNDER}BO_API_Workflow__r.${PREFIX_UNDER}BO_API__r.${PREFIX_UNDER}Sales_Org__c = '<salesOrg>'"
115```
116
117- Zero workflow rows → fresh for this sales org, Phase 3 will seed.
118- Full state (4 workflow rows `create/update/copy/derive`; >0 step + junction rows) → Phase 3 is a no-op for this sales org (still run for idempotency; 3a shows zero net-new).
119- Partial state → **error**; stop, report which rows are missing. A human inspects first.
120
121## Phase 3a — Preview the default BO API seed (fetch + confirm)
122
123The workflows/steps/junctions/entities/input-structures ship as CSVs inside the
124packaged `TPMSetupData` static resource; `TPMSetupData.setupBOApi` reads and
125upserts them. Before running it, show the user what will land.
126
1271. Locate the resource:
128 ```bash
129 sf data query --target-org <alias> --json --query "
130 SELECT Id, Name, NamespacePrefix, SystemModStamp, BodyLength
131 FROM StaticResource WHERE Name = 'TPMSetupData'"
132 ```
133 Zero rows → stop ("TPMSetupData static resource not found — is the CGCloud package installed?"). Multiple rows → prefer the one whose `NamespacePrefix` matches Phase 1; if prefixes disagree, stop and ask.
1342. **Retrieve + expand the resource cross-platform via SFDX** — use `sf project retrieve`, which unpacks a zip StaticResource into a folder on every OS (no `curl`, no `unzip` — `unzip` isn't present on Windows by default). Retrieve into a **dedicated `setup-data/` subproject** so the read-only managed resource never mixes into the Phase-5a deploy tree (`./.promotion-bo-api-deploy/force-app`, which is what gets deployed back):
135 ```bash
136 mkdir -p ./.promotion-bo-api-deploy/setup-data/force-app/main/default
137 printf '{ "packageDirectories": [{ "path": "force-app", "default": true }], "sourceApiVersion": "60.0" }' \
138 > ./.promotion-bo-api-deploy/setup-data/sfdx-project.json
139 ( cd ./.promotion-bo-api-deploy/setup-data && sf project retrieve start \
140 --metadata "StaticResource:${PREFIX_UNDER}TPMSetupData" --target-org <alias> --json )
141 ```
142 Non-success → stop, print `result` errors.
1433. SFDX expands the zip to `./.promotion-bo-api-deploy/setup-data/force-app/main/default/staticresources/${PREFIX_UNDER}TPMSetupData/BOApi/`. `ls` it. Expected: `0_BO_API__c.csv`, `0_BO_API_Entity__c.csv`, `0_BO_API_Output_Entity__c.csv`, `0_BO_API_Workflow__c.csv`, `0_BO_API_Workflow_Entity__c.csv`, `0_BO_API_Workflow_Step__c.csv`, `0_BO_API_Workflow_Workflow_Step__c.csv`, `0_BO_API_Step_Input_Structure__c.csv`, `import.json`. Any missing → stop, print the delta.
1444. Confirm each target sObject exists: `sf sobject describe --sobject '${PREFIX_UNDER}BO_API_Workflow__c' --target-org <alias> > /dev/null` (etc.). Any describe failure → stop (package partially installed).
1455. Show a summary (row counts per object; total ~232 rows across 8 objects; "upsert on Unique_Key__c — re-running is idempotent"), plus the first 3 rows of `0_BO_API_Workflow__c.csv` and `0_BO_API_Workflow_Step__c.csv` with `{{NS}}`/`{{SALES_ORG}}` substituted.
1466. Wait for confirmation unless `--yes`. "no"/blank → abort clean, point at `./.promotion-bo-api-deploy/setup-data/force-app/main/default/staticresources/${PREFIX_UNDER}TPMSetupData/BOApi/`.
1477. On `--dry-run` → stop here; do not run 3b.
148
149## Phase 3b — Apply the default seed
150
151Only after 3a confirmation.
152
1531. `PHASE3_START=$(date -u +%FT%TZ)`.
1542. Run metadata-wizard setup via anon Apex: `${PREFIX_DOT}TPMSetupData.setupMetadataWizard('<salesOrg>');` → `sf apex run --target-org <alias> --file ./.promotion-bo-api-deploy/setup-metadata-wizard.apex`.
1553. Wait for the whole batch chain. `GenericDemoSetupDataBatch` self-chains via `finish()`; polling a single job id misses children. Poll by class:
156 ```bash
157 sf data query --target-org <alias> --json --query "
158 SELECT COUNT() FROM AsyncApexJob
159 WHERE ApexClass.Name = 'GenericDemoSetupDataBatch'
160 AND CreatedDate >= ${PHASE3_START}
161 AND Status NOT IN ('Completed','Failed','Aborted')"
162 ```
163 Sleep 5s between polls; break when count is zero for **two consecutive** polls (covers the gap between a parent's `finish()` and the child's `AsyncApexJob` row appearing).
1644. Final check — every child succeeded (`SELECT Id, Status, NumberOfErrors, ExtendedStatus FROM AsyncApexJob WHERE ApexClass.Name = 'GenericDemoSetupDataBatch' AND CreatedDate >= ${PHASE3_START}`). Any `Failed`/`Aborted`/`NumberOfErrors > 0` → stop, print `ExtendedStatus`.
1655. Repeat 2–4 for `${PREFIX_DOT}TPMSetupData.setupBOApi('<salesOrg>');`.
166
167## Phase 4 — Verify the BO API framework is on
168
169Re-run the Phase 2 queries. Expect 4 `BO_API_Workflow__c` rows
170(`create`/`update`/`copy`/`derive`, uniqueness on `Unique_Key__c`), ≥46
171`BO_API_Workflow_Step__c` rows, ≥54 `BO_API_Workflow_Workflow_Step__c` junction
172rows. Anything short → stop, print what's missing. Never claim success while a
173component failed.
174
175## Phase 5 — Interview: what does the user want to customize?
176
177The framework is now on. The customization is **user-supplied** — do not skip
178the interview; do not invent an answer. Print:
179
180```text
181The Promotion BO API framework is installed. To add a custom step, tell me:
182 - Which BO API entity does it target? (e.g. Promotion or Tactic — I'll list the exact entities registered in your org)
183 - Which workflows should it fire in? (create, update, copy)
184 - What does it read from the ingest input?
185 - What does it write on the SObject or elsewhere?
186 - Any preconditions or side effects I should know about?
187If you want a worked example, say "use the SetCommentValue reference" —
188it copies the tactic input Comment onto Tactic.Comment__c.
189```
190
191### 5.1 — Answers to collect
192
193| Field | Required | Notes |
194|---|---|---|
195| `stepName` | yes | PascalCase Apex class name + BO API Workflow Step `Name`. Unique across `ApexClass`. |
196| `entity` | yes | BO API entity `Name` — MUST match a row queried live from `${PREFIX_UNDER}BO_API_Entity__c` (shared, not sales-org-partitioned); do not assume a fixed list. In a current org these include `Promotion`, `Tactic`, `ProductFilter`, `ManualInputs`, `CustomState` (plus the `TPM_Promotion.*` structures). Query the org and offer the actual rows. |
197| `workflows` | yes | Non-empty subset of `{create, update, copy}`. `derive` is out of scope. |
198| `actionName` | yes | Symbolic action string the class receives in `call(String action, ...)`. Convention: lowerCamelCase of `stepName`. |
199| `inputPaths` | yes (≥1) | JSON paths to read from `currentInput`, e.g. `["Comment"]`. |
200| `inputPathType` | yes | JSON type of the input paths — `String`, `Array`, `Boolean`, `Number`, `Object`. Drives the `RecordType` on each SIS row; a wrong value causes `TransformationError: Expected <Type>` at ingest. Different types → run Phase 5b once per type group. |
201| `outputWrites` | yes (≥1) | `{field, source}` pairs; `field` is the target API name **without** the namespace prefix (skill adds `${PREFIX_UNDER}`), `source` references an `inputPaths` value / literal / computed expression. |
202| `description` | yes | One-sentence step description → `${PREFIX_UNDER}Description__c` + class docstring. |
203| `preconditions` | optional | Free-text guards before writing. |
204| `sortAfter` | optional | Named packaged step to sort after; default `max(Sort__c) + 10`. |
205
206### 5.2 — Interview flow
207
2081. **Preset shortcut.** If `--preset set-comment-value` or the user says "use the SetCommentValue reference", load the answers from `references/reference-example-set-comment-value.md` (`assets/set-comment-value/interview-answers.json`) and skip the interactive interview. `--interview-file <path>` loads answers from a JSON file mirroring the 5.1 table (wins over `--preset`).
2092. **Ask the questions** one at a time (or as a block); do not proceed until every required field has a concrete value.
2103. **Show the entity's writeable fields** after the user names the entity:
211 ```bash
212 sf sobject describe --target-org <alias> --sobject '${PREFIX_UNDER}<Entity>__c' --json \
213 | jq -r '.fields[] | select(.updateable == true and .createable == true) | .name'
214 ```
215 Any `outputWrites` field must appear here; otherwise stop and ask for a different one (FLS is caller-scoped).
2164. **Confirm the plan** (recap Class/Entity/Workflows/Reads/Writes/Sort/Description → "Deploy + wire? [y/N]"). `n`/blank → save answers to `./.promotion-bo-api-deploy/interview.json` and exit. `y` → Phase 5a.
217
218## Phases 5a / 5b — Generate + deploy, register + wire
219
220Runbook in `references/generate-and-wire.md`. In short: guard the class name for
221idempotency; confirm target-field writeability; generate a namespace-agnostic
222`Callable` from the interview (reads `currentInput`/`currentOutput`, derives the
223prefix from `getSObjectType()` at runtime); show + confirm; `sf project deploy
224start`. Then, in one anon-Apex transaction, upsert one `BO_API_Workflow_Step__c`
225(bare class name in `Classname__c`), one `BO_API_Workflow_Workflow_Step__c`
226junction per chosen workflow (never assume all three; `derive` dropped), and the
227`BO_API_Step_Input_Structure__c` rows (RecordType per `inputPathType`). Both
228upserts key on `Unique_Key__c` (idempotent).
229
230## Phase 6 — Smoke test through the REST endpoints
231
232Runbook in `references/smoke-and-verify.md`. Derive the payload contract per
233invoked workflow from `BO_API_Step_Input_Structure__c` (schema-first), apply
234rules R1–R8 from `references/conventions-and-payload-rules.md`, resolve every
235reference in-sales-org, materialize `./out/smoke/{create,update,copy}.json`, then
236run `initialize → ingest → poll status` for each chosen workflow. Verify via a
237direct `BO_API_Transaction_Log__c` query (any row `!= 'Calculated'` → fail) plus
238an `outputWrites` field assertion against `./out/smoke/expected.json`. Update and
239copy legs run only if the interview included them.
240
241## Phase 7 — Report
242
243Short status: org, sales org, dry-run flag; namespace prefix; BO API seed rows
244before/after (Phases 2 & 4); interview (preset name or resolved `stepName`,
245`entity`, `workflows`, `inputPaths`, `outputWrites`); class deploy result +
246workflow-step id + junction ids (1–3); smoke import ids per invoked workflow, all
247`Calculated` with matching write assertions — or exactly which failed (SOQL,
248expected, actual); manual follow-ups (`none` if clean).
249
250## Rules
251
252- Never claim success while any `BO_API_Transaction_Log__c` row for the smoke import ids has `Status__c != 'Calculated'`. `Calculated` is the terminal success state (R2); `Processed` does NOT exist in the picklist.
253- Never claim success while any `outputWrites` assertion fails, or while any `GenericDemoSetupDataBatch` `AsyncApexJob` since the phase start is still running or failed.
254- Stop on the first failed preflight or verify check; report the exact failure.
255- Never hard-code the namespace prefix — compose every Apex/SOQL/REST reference from the Phase-1 detection.
256- Never invent Apex method signatures — use what the packaged `TPMSetupData` / `BO_API_*` objects and the shipping REST endpoints declare.
257- The seeded BO API metadata is packaged content — preview it and get user confirmation before invoking `TPMSetupData.setupBOApi`.
258- Never invent an interview answer. Absent a user and any `--preset`/`--interview-file`, stop and print the questions.
259- Never wire a workflow the interview did not include. `create`/`update`/`copy` are individually opt-in; `derive` is always excluded.
260- Never compose a smoke payload from a hard-coded template — derive accepted paths per workflow from `BO_API_Step_Input_Structure__c` and validate user input against that contract first.
261- Never resolve a reference value without the sales-org filter. A record under a different sales org is not valid for this invocation.