بنقرة واحدة
zuora-workflow-build
Compose an importable Zuora Workflow JSON from a design or requirement
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
Compose an importable Zuora Workflow JSON from a design or requirement
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
Generate Credit Memos / Debit Memos migration implementation artifacts based on the plan
Produce Credit Memos / Debit Memos migration strategy, code inventory, and phases
This skill should be used when the user mentions "Zuora API", "Zuora SDK", "Zuora Billing", "Zuora Revenue", "Zuora CPQ", "Zuora Payments", "subscription billing", "invoice settlement", "zuora-mcp", "rate plan", "product rate plan charge", "order API", or discusses integration with Zuora systems. Do not activate for generic billing or subscription discussions that are not Zuora-specific.
Design a Zuora Workflow-based solution
Generate legacy Zuora CPQ Apex, Visualforce, Component Library, zQuoteUtil, controller extension, or plugin-interface artifacts directly into a Salesforce DX repo
Design legacy Zuora CPQ Apex, Component Library, Visualforce, zQuoteUtil global method, controller extension, or plugin-interface customizations
| name | zuora-workflow-build |
| description | Compose an importable Zuora Workflow JSON from a design or requirement |
| argument-hint | <workflow design or requirement> |
| allowed-tools | ["Read","Write","Edit","Glob","Grep","Bash","Agent","mcp__zuora-mcp__manage_workflows","mcp__zuora-mcp__manage_workflow_runs","mcp__zuora-mcp__ask_zuora","mcp__zuora-mcp__zuora_codegen","mcp__zuora-mcp__query_objects"] |
Codex-only path resolution: When an instruction refers to ${CLAUDE_PLUGIN_ROOT}, treat it as the root of this installed plugin. In Codex, resolve that root as the ancestor directory containing skills/, references/, and .codex-plugin/.
You are building an importable Zuora Workflow JSON from a design (produced by /zuora-workflow-design or provided directly).
The user's workflow design or requirement: $ARGUMENTS
Format correctness is non-negotiable. A slightly malformed JSON fails import_workflow. Defense is layered:
scripts/lint-workflow-json.js — the client-side enforcement layer, since Task.import runs with validate: false and skips each task's task_setup_validation.import_workflow activate=false, then delete_workflow to clean up. Rails has no validate_only flag.The deliverable is a complete, importable Workflow JSON artifact, not a partial transcript:
.workflow.json file before finalizing. The file must contain exactly the four import envelope keys: workflow_definition, workflow, tasks, and linkages..workflow.json path and summarize validation status.Read these in parallel before composing:
${CLAUDE_PLUGIN_ROOT}/references/workflow-skeleton.json — the canonical empty envelope (start here).${CLAUDE_PLUGIN_ROOT}/references/workflow-task-templates.json — per-action_type templates (description, hooks, template, required_params, required_at_import, param_enums, boolean_string_params, data_contract for Tier-1).${CLAUDE_PLUGIN_ROOT}/references/workflow-enums.json — global enums, typo hints, standard_events, supported_ui_pages, schemas, and version_regex.${CLAUDE_PLUGIN_ROOT}/references/workflow-patterns.md — composition strategy.${CLAUDE_PLUGIN_ROOT}/references/workflow-task-catalog.md — task category overview and format pitfalls.${CLAUDE_PLUGIN_ROOT}/references/workflow-triggers-and-linkages.md — trigger modes, call_type matrix, linkage catalog, and the Workflow-level field derivation cheat-sheet.${CLAUDE_PLUGIN_ROOT}/references/workflow-events.md — standard event catalog, name corrections, and event_parameters derivation.${CLAUDE_PLUGIN_ROOT}/references/workflow-data-flow.md — Data.* symbol-table model, opaque-task protocol, walker algorithm. Required reading before Step 3d.${CLAUDE_PLUGIN_ROOT}/references/workflow-liquid.md — Liquid scopes.${CLAUDE_PLUGIN_ROOT}/references/workflow-liquid-filters.md — Workflow-specific Liquid filter signatures, argument counts/types, and examples from filters.rb.${CLAUDE_PLUGIN_ROOT}/references/workflow-examples.md — annotated, lint-clean workflow JSONs.Only if the user is modifying an existing workflow or has an obvious near-match:
Call mcp__zuora-mcp__manage_workflows:
list_workflows — find candidates.get_workflow_details or export_workflow — pull the JSON as a starting point.If cloning, still run the output through the composer steps below (and the linter) to ensure it matches the current template schema.
Use the skeleton + templates composer. Do not hand-write structure.
Start from a deep copy of workflow-skeleton.json.
Populate workflow_definition (name, description, category, ui_page_roles).
Set trigger flags and config on workflow (see Step 3b for the full envelope):
ondemand_trigger, callout_trigger, scheduled_trigger, event_trigger set to true. Set multiple flags when the same task graph should be runnable in multiple ways, such as on-demand plus scheduled.interval (6-token Rufus/Fugit cron SEC MIN HOUR DOM MON DOW; bare /N = */N step; the React UI always emits 6 tokens with second=0) and timezone (Rails ActiveSupport::TimeZone friendly name -- e.g. "UTC", "Eastern Time (US & Canada)", "London", "Tokyo"). The full allowlist is in references/rails-timezones.json. Never emit a bare IANA string like "America/New_York" -- it fails Rails validation (workflow/setup.rb L32) and the linter raises E175. If the user gives you an IANA name, look it up in rails-timezones.json -> iana_to_friendly_recommendations and convert.parameters.event_triggers (non-empty array of canonical event names) + parameters.event_parameters (array of {eventName, params: [...]}).call_type from workflow-enums.json -> workflow_call_types.user_facing (default BATCH; never emit ASYNC/RULE/UI).For each task in the design:
a. Look up the matching action_type in workflow-task-templates.json.
b. Deep-copy the template object.
c. Assign a unique integer id (simple sequence like 100, 101, 102).
d. Replace every <<REQUIRED: …>> sentinel with a real value. Use Liquid from Data.*, Credentials.zuora.*, GlobalConstants.* as appropriate.
e. Populate every field listed under required_at_import with a non-empty value (top-level attrs like object, object_id that Rails validates via ActiveRecord column presence).
f. Guarantee parameters is an object, not null or missing. An empty task uses "parameters": {}.
g. For boolean params listed in boolean_string_params, always emit the STRING "true" or "false", never JSON booleans.
h. For enum params listed in param_enums, pick one of the listed values — do not invent new ones.
i. Logic::Case special handling: pre-normalize parameters.case_condition keys to sequential Case_1, Case_2, … Case_N before emitting. This matches the server-side before_save :validate_labels rewrite so no linkages are destroyed.
j. Set css.top/css.left using the defaults in workflow-enums.json.css_layout_defaults (adjust for branches).
k. Set task_id to the id of the primary upstream task (for layout/dependency purposes), or null for the entry task.
Export vs Query data-scope check: if any downstream task needs direct variables like Data.RatePlan.SubscriptionId, the producer must be Query (or an Iterate For Each branch over an Export file), not a bare Export. Export writes file/reference metadata (Data.Export.<object> and Data.Files.<file-holder>); it does not make Data.<object>.<field> available until an Iterate consumes the file holder.
Bill Run task selection check: before using Billing::BillRun, verify the requested filters fit the OOTB task. It supports standard bill-run fields and v1 single-account/subscription filters via AccountId / SubscriptionIds; it does not support account-number filters, batch-number filters, or APM/ProductRatePlanCharge ID filters. If the requirement needs unsupported bill-run filters, use a custom Zuora Callout to the modern bill run API ({{ Credentials.zuora.rest_endpoint }}bill-runs) with authorization.type = "zuora" and a raw JSON body instead of forcing Billing::BillRun. Do not use the legacy object CRUD endpoint /object/bill-run for Create a bill run; the linter flags unsupported OOTB task filters as W185 and legacy bill-run object CRUD callouts as W192.
Subscription cancel API-stack check: for new-stack subscription cancellation, use a Zuora Callout to the Orders API ({{ Credentials.zuora.rest_endpoint }}orders) with an orderActions[] entry whose type is "CancelSubscription". Set authorization.type = "zuora" and ordinary headers such as Content-Type; do not use the SOAP Cancel amendment task unless the user explicitly asks for a legacy amendment workflow. The linter flags SOAP Cancel tasks as W189.
Zuora API Callout auth check: when a Callout / AsynchronousCallout targets a Zuora API (Credentials.zuora.rest_endpoint, Credentials.zuora.url, a Zuora GlobalConstant base URL, or a *.zuora.com endpoint), set parameters.authorization.type = "zuora". Do not emit apiAccessKeyId, apiSecretAccessKey, Authorization, or bearer-token headers for Zuora APIs; keep ordinary headers such as Content-Type. If a multi-entity tenant requires entity context, add the appropriate authorization.entity_id. The linter flags bad Zuora callout auth as E186.
Zuora API Callout validation/response check: for Zuora API Callout / AsynchronousCallout tasks, include validation.replace = "true" and validation.zuora_call = "true" (and the same polling_validation.* keys for a polling leg). Because include_response_code defaults to "true", downstream tasks must read the body as Data.<payload_location | 'Callout'>.ResponseBody.<field>; set include_response_code = "false" only when you intentionally want direct Data.<payload>.<field> paths. The linter flags missing Zuora validation flags as W190 and skipped ResponseBody paths as W191.
Data Query consolidation check: before emitting more than one Data::Link / Data Query task, check whether the first query only resolves scalar context for the next query. If yes, use one SQL query with a CTE and CROSS JOIN, project the scalar columns on every result row, and have downstream Iterate / Callout tasks reference row.<field>. Do not emit Data::Link -> Logic::Liquid(assign only) -> Data::Link just to copy Data.LinkRun.first.* into Data.Liquid.*. Keep separate queries only when the first result is reused by multiple branches, must stop/fail independently, produces a non-scalar collection, or cannot be expressed in the same SQL. The linter flags the avoidable chain as W180.
Workflow-specific Liquid filter check: before emitting a Logic::Liquid task with loops or array reshaping, review workflow-liquid.md -> Filters and workflow-liquid-filters.md for exact signatures. Prefer the Workflow filters from rails/lib/liquid/filters.rb when they express the operation. Use where / where_exp for row selection and group_by / group_by_exp for grouping instead of manual for + if + push loops. Keep manual loops only when transforming rows or building a shape the built-in filters cannot express. The linter flags obvious manual selection loops as W184.
Liquid shim minimization check: before emitting a separate Logic::Liquid task, ask whether its assigned/captured values are consumed by only the next task. If yes, inline that Liquid into the consuming task's parameter instead: date calculations belong in Export/Query predicates or task date fields, boolean decisions belong in If / Logic::Case clauses, and request-body assembly belongs in a Callout raw_body. Keep a separate Liquid task when the value is reused by multiple tasks, normalizes a large shared payload, creates a reusable named scope, or intentionally needs independent review/failure behavior. The linter flags avoidable one-consumer Liquid shim tasks as W187.
CRUD update consolidation check: before emitting more than one Update task against the same object and object_id, check whether the tasks are only setting different fields on the same record. If yes, emit one Update task with all field values under parameters.fields[<object>]; do not create one CRUD task per field unless each update intentionally needs independent failure/retry handling, intermediate validation, or ordered side effects. ProductRatePlanCharge (PRPC) object updates are a common example, not a special-only case. The linter flags adjacent same-record per-field updates as W183.
Zuora REST v1 URL check: when a Callout / AsynchronousCallout uses Credentials.zuora.rest_endpoint, remember that the value is already the Zuora REST v1 base URL. For v1 APIs append only the resource path ({{ Credentials.zuora.rest_endpoint }}orders), not /v1/orders; do not use replace: "/v1/", "" plus /v1/.... The linter flags duplicate-v1 risks as E182.
Emit linkages:
a. First linkage is always the Start: { "source_workflow_id": <workflow.id>, "source_task_id": null, "target_task_id": <entry_task.id>, "linkage_type": "Start" }.
b. For every task-to-task edge in the design, add { "source_workflow_id": null, "source_task_id": <upstream.id>, "target_task_id": <downstream.id>, "linkage_type": <hook> }.
c. linkage_type must be one of the upstream task's hooks (see the template). Use exact spelling — "For Each" with a space; "Case_1" not "case_1"; "Complete" not "Iterate".
d. Emit Case_N linkages in order and ensure the keys match the pre-normalized parameters.case_condition. Emit Case_Else as a linkage (not a case_condition key).
e. Verify: tasks array non-empty, linkages array non-empty, exactly one Start linkage, no For Each linkage on any path to a Logic::Merge task.
Before finalizing any field list or any <Object.Field> merge-field token, you MUST resolve it against the live tenant (or, at worst, the bundled catalog). Inventing field names is the single most common way a generated workflow fails at runtime: Rails accepts the JSON on import (validate: false) but the task then errors out on the first SOAP/ZOQL call, or an event binding silently resolves to nil because the merge field does not exist in the live payload.
Two describe surfaces must both be satisfied before emitting the JSON:
Export, Query, Create, Update, CustomObject::Query, CustomObject::Create, CustomObject::Update field lists and for any parameters.where_clause that references object fields.workflow.parameters.event_parameters[*].params[*].value that is not one of the hard-coded special tokens.The linter encodes these gates as W177 (task fields), E177 (wrong event BaseObject), and W179 (unverifiable event merge field).
Claude Code injects the Zuora credentials from ~/.claude/settings.json -> env into every Bash invocation (keys: ZUORA_BASE_URL, ZUORA_CLIENT_ID, ZUORA_CLIENT_SECRET). That means the direct HTTP path with curl is the primary describe channel — it returns the raw tenant response with no intermediary. Use MCP (mcp__zuora-mcp__ask_zuora) if either (a) the env vars are not present (check with printenv ZUORA_BASE_URL ZUORA_CLIENT_ID), or (b) you want the MCP's structured summarization rather than raw JSON.
One-time OAuth exchange per session (store the token, reuse for all describes):
ACCESS_TOKEN=$(curl -sS -X POST "$ZUORA_BASE_URL/oauth/token" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=$ZUORA_CLIENT_ID" \
--data-urlencode "client_secret=$ZUORA_CLIENT_SECRET" \
| jq -r '.access_token')
If printenv ZUORA_BASE_URL is empty, call mcp__zuora-mcp__ask_zuora instead — the MCP sub-process reads the same vars from Cursor's credential store (see .mcp.json).
The configuration_contract.fields[*].source value describe-call (per-task) tells you which field-bearing parameters need channel 1. From workflow-task-configuration.md:
| Task | Describe scope | Configurable parameter |
|---|---|---|
Export | ZOQL/AQuA describe of parameters.object (+ joinable related_objects) | parameters.fields[<object>][] and any parameters.where_clause field |
Query | SOAP describe of parameters.object | parameters.fields[<object>][] and any parameters.where_clause field |
Create | SOAP describe of parameters.object (createable fields only) | parameters.fields[<object>] map |
Update | SOAP describe of parameters.object (updateable fields only) | parameters.fields[<object>] map |
CustomObject::Query | Custom Object describe of parameters.object | implicit (selected via parameters.query Lucene refs) |
CustomObject::Create | Custom Object describe of parameters.object (origin != system) | parameters.fields[<object>] map |
CustomObject::Update | Custom Object describe of parameters.object (origin != system) | parameters.fields[<object>] map |
Direct HTTP (preferred when $ZUORA_BASE_URL / $ZUORA_CLIENT_ID are set):
# Standard SOAP/ZOQL describe — used for Export, Query, Create, Update.
# Response is XML; pipe through xmllint / xsltproc, or just grep for <name>.
curl -sS "$ZUORA_BASE_URL/v1/describe/Invoice" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Accept: application/xml" \
| xmllint --xpath '//fields/field/name/text()' - 2>/dev/null \
| tr -s '[:space:]' '\n' | sort -u
# Related-object list (second pass) — needed for Export joins like Invoice.Account.Name:
curl -sS "$ZUORA_BASE_URL/v1/describe/Invoice" \
-H "Authorization: Bearer $ACCESS_TOKEN" -H "Accept: application/xml" \
| xmllint --xpath '//related-objects/object/name/text()' - 2>/dev/null
# Custom Object describe — used for CustomObject::Query / Create / Update.
# Replace <namespace> (e.g. "default") and <object> accordingly.
curl -sS "$ZUORA_BASE_URL/objects/definitions/<namespace>/<object>" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq '.schema.properties'
Cache the describe response per (tenant, object, entity_id) for the duration of the agent session — subsequent tasks targeting the same object should reuse the cached field list (do not re-hit the endpoint each time).
MCP fallback (if printenv ZUORA_BASE_URL is empty, or curl returns 401 even after a fresh token): call mcp__zuora-mcp__ask_zuora with:
Return a JSON array of selectable fields for
<object>matching the shape Zuora's/describe/<object>endpoint returns. For each field includename,type(string|number|boolean|datetime|picklist|reference|...),required(bool),createable(bool),updateable(bool), and (if reference)referenceTo. Also list everyrelated_objectsentry that can be joined via dot notation in a ZOQLSELECT. If the tenant uses Custom Objects, return thepropertiesmap from/objects/definitions/<namespace>/<object>(origin != system) instead. Do not invent fields. Tenant:<tenant>. Entity:<entity_id | 'default'>.
If both channels fail (no curl credentials AND MCP unavailable), use references/zuora-standard-fields.json. It bundles the standard-field catalog for the most common Zuora SOAP/ZOQL objects (Account, Subscription, RatePlan, RatePlanCharge, Invoice, InvoiceItem, Payment, PaymentMethod, CreditMemo, DebitMemo, Contact, Amendment, Product, ProductRatePlan, ProductRatePlanCharge, BillingPreviewRun, BillingRun, Order, OrderAction). Custom fields (*__c) are never in the fallback — if the design needs custom fields and neither describe channel works, ask the user for the exact name + type per field.
When you fill in parameters.fields[<object>] (Export/Query) or parameters.fields[<object>].<FieldName> (Create/Update/CustomObject::*), every field name MUST be either:
references/zuora-standard-fields.json entry, ORThe same rule applies to fields referenced inside parameters.where_clause (e.g., BillRunId = '{{ Data.BillingRun.Id }}' on an Export Invoice task must be backed by Invoice.BillRunId in describe or in zuora-standard-fields.json).
The linter rule W177 undeclared-describe-field enforces this — any field name in parameters.fields, parameters.fields[<object>], or parameters.where_clause that is unknown to both describe and the fallback catalog emits a warning. When describe was unavailable for the lint run, W177 is downgraded to a notice (the linter cannot prove the field doesn't exist in the live tenant, only that the static fallback doesn't know it). If a required filter is not supported by the Object Query/Export describe surface (for example filtering Subscription by InvoiceScheduleId), do not emit an Object Query with that unsupported predicate; switch to a supported API/Data::Link path or ask the user for the supported relationship.
For CustomObject::* tasks, parameters.fields is nested: parameters.fields.<self.object>.<FieldName>. The Custom Object describe lives at GET /objects/definitions/<namespace>/<object> (origin != system). The fallback catalog never carries Custom Objects (they are tenant-specific) — if both channels fail, ask the user directly for the field map. Required __c fields (per the schema) MUST be supplied for Create or save fails with Missing fields: ....
workflow.parameters.event_parameters[*].params[*].value is the binding between Kafka event payload and Data.<object>.<key> scope. The UI picker at WorkflowSettingsForm.js L383-401 fetches the live merge-field list from:
GET {base_url}/notifications/email-templates/info/selections?category=<category>
where category is the event id for standard events (event.id.length < 5) or ${event.namespace}:${event.name} for custom events. This endpoint returns a hash of <object>: [<field path>, ...] that the UI flattens into <Object.Field> strings (e.g. <BillingRun.Id>, <Account.WorkEmail>). The corresponding Rails method is ZuoraConnect::AppInstance#get_custom_event_fields (app/models/zuora_connect/app_instance.rb L1452-1494).
At runtime, BusinessEvent#parse_event (app/models/business_event.rb) resolves these tokens in exactly two paths:
<Event.Category>, <Event.Date>, <Event.Timestamp>, <Functions.Today>, <Tenant.ID>, <Tenant.Name> (the full list lives in references/zuora-standard-fields.json → $event_special_tokens.tokens). These always work and do not need a describe call. Tokens such as <Event.EventName> or <Event.Object.Id> are NOT special-cased by Rails; use the notifications merge-field describe flow below and choose a published payload token instead.DataSource. / Event. prefix stripped, then the remainder is used as a literal key into the event payload. <BillingRun.Id> becomes the payload key BillingRun.Id; if that key is not present in the payload, the binding resolves to nil with no error. This is why you cannot invent tokens.Required flow per event:
Resolve the canonical event name (via workflow-enums.json → standard_events.$canonical_name_corrections). Confirm it exists in standard_events.events or via /events/event-triggers (see Step 3c).
Fetch the merge-field list for the event category using either channel:
Direct HTTP (preferred):
# Standard event: category is the 4-char numeric event id (look up in
# workflow-enums.json -> standard_events.events[*].id).
# Example: BillingRunCompletion -> category=1410.
curl -sS "$ZUORA_BASE_URL/notifications/email-templates/info/selections?category=1410" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq
# Custom event: category is "<namespace>:<name>".
curl -sS "$ZUORA_BASE_URL/notifications/email-templates/info/selections?category=user.notification:MyCustomEvent" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq
Apply the UI's filter (WorkflowSettingsForm.js L388-400): for standard events drop every path containing DataSource; for custom events keep only paths containing DataSource OR matching .<baseObject>..
MCP fallback: call mcp__zuora-mcp__ask_zuora with:
For the Zuora event category
<event.id-or-namespaced-name>, callGET {base_url}/notifications/email-templates/info/selections?category=<category>and return the raw JSON. For aStandardevent (id < 5 chars) I want the list excluding any field that containsDataSource. For a custom event I want only fields that containDataSourceOR whose path matches.<baseObject>..
Pick the <Object.Field> strings you need from the returned list. Every value you emit must be one of:
$event_special_tokens.tokens, OR<BaseObject.Field> string where BaseObject matches the event's declared baseObject (from references/zuora-standard-fields.json → $event_base_objects.events) AND Field appears in the fetched merge-field payload, OR<DataSource.Foo.Bar> string (custom events only) confirmed by the endpoint response.If both describe channels fail, fall back to references/zuora-standard-fields.json for well-known base objects (the catalog lists Id, core status/amount fields, etc.), and tell the user the token list is unverified — the linter will emit W179 unverifiable-event-parameter-value so the user can decide whether to proceed.
Linter gates:
E177 invalid-event-parameter-value: <BaseObject.Field> where BaseObject is not the event's declared baseObject and has no DataSource./Event. prefix. Hard error.W179 unverifiable-event-parameter-value: any <...> value that is neither a known special token nor a statically-confirmed <BaseObject.Field>. Prompts you to run the describe in Step 3a-5 and confirm.After tasks and linkages are in place, fill out the rest of workflow.*. Use workflow-triggers-and-linkages.md -> "Workflow-level field derivation" as the single source of truth. Apply these rules in order:
workflow.id: keep at 1 (skeleton default). The Start linkage's source_workflow_id MUST equal this value.workflow.name and workflow.description: copy from the user's design / workflow_definition.name.workflow.type: must be the literal string "Workflow::Setup".workflow.data: always {}.workflow.status: always "Inactive" on import. The import_workflow tool flips it to Active via activate_version: true.workflow.css: keep skeleton default {"top":"40px","left":"35px"}.true; multiple trigger flags are valid when they launch the same task graph (for example, on-demand plus scheduled). NEVER set ui_trigger (it is not a column on the workflows table and is silently dropped).workflow.interval + workflow.timezone: required IFF scheduled_trigger == true. Use interval_schema.examples and interval_schema.timezone.examples from workflow-enums.json.workflow.parameters — start from the skeleton's seven always-present keys (fields, entity_name, entity_id, skipping_check: "db", file_encryption: "false", secure_error_msgs: "false", show_run_prompt, callout_response). Then layer in:
workflow.event_trigger: true, append event_triggers: ["<canonical or registered custom name>"], and append matching event_parameters: [{eventName, params: [...]}]. Resolve the user's intent through workflow-enums.json -> standard_events.$canonical_name_corrections first. If the name is not standard and not a correction, treat the user-provided name as a custom event candidate; keep the exact registered custom event name in event_triggers[] instead of omitting the trigger. Emit BOTH event_parameters AND each params value as JSON arrays (not Hashes).parameters.fields[] with the inbound payload schema if known.object_name: "Workflow" and reference it as Data.Workflow.<field_name>. Use object_name: "Files" only for File-Field uploads, and use another object_name only when it is a real supported Zuora object from the run-prompt dropdown. NEVER create semantic grouping objects such as BillRunConfig, RequestParams, or InputConfig.default: null when datatype is "JSON". Workflow::Setup validates JSON field size with field['default'].size, so null/boolean/number defaults crash import. Use [] for array inputs, {} for object/map inputs, or a valid JSON string/default when the user supplied one.parameters.entity_id and parameters.entity_name if the user named an entity. Otherwise leave null and let the server default.parameters.merge_task_ids — Workflow::Setup.import deletes it on save.workflow.notifications — keep skeleton default unless the user requested email alerts. If they did:
emails: ["alice@example.com", "{{Data.Account.WorkEmail__c}}", ...].failure, success, pending, skipped_scheduled_run).error_ignore: a Ruby regex string. Validated by Regexp.new at import — invalid patterns reject the workflow.true if emails[] is non-empty (and vice versa).workflow.call_type — default "BATCH". Validate against workflow_call_types.user_facing[*].value. If the user requested UIACTION or SYNC_UI_ACTION, jump to step 12.workflow.ui_pages — {} for non-UIACTION call types. For UIACTION / SYNC_UI_ACTION: exactly one entry from supported_ui_pages.pages, shape { "<value>": { "label": "<button label>" } }.workflow.priority: default "Medium"; "High" for time-critical event workflows; "Low" for low-priority background work.workflow.delete_ttl: default 30. Acceptable range depends on tenant retention policy; do not emit 0 unless the user explicitly asks for "no retention".workflow.version: default "0.0.1" for new; increment for new versions of the same workflow_definition. Must match ^\d+(?:\.\d+)?(?:\.\d+)?$.workflow.solution_id, workflow.extension_id: null unless packaging as a Connect extension.workflow.zuora_org_id: null (deprecated). workflow.zuora_org_ids: [] to allow all accessible orgs.When the workflow is event-triggered AND any value in parameters.event_triggers[] is not in workflow-enums.json -> standard_events.events (after applying $canonical_name_corrections), verify that the custom event is registered before considering the workflow lint-clean. A non-standard name is a registration prerequisite, not a reason to set workflow.event_trigger false or leave parameters.event_triggers[] empty.
Try this lookup once, in order:
Direct HTTP (preferred):
curl -sS "$ZUORA_BASE_URL/events/event-triggers" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq '.data[] | .eventType.name'
curl -sS "$ZUORA_BASE_URL/events/scheduled-events" \
-H "Authorization: Bearer $ACCESS_TOKEN" | jq '.data[] | .name'
Both endpoints are paginated (body.next); follow cursors until you exhaust the list or confirm the event name.
MCP fallback: call mcp__zuora-mcp__ask_zuora with prompt: List event triggers from /events/event-triggers and tell me whether an event named "<name>" is registered. Also list event triggers from /events/scheduled-events.
If the event exists, proceed.
If the event is NOT registered (or the list is empty), tell the user the event must be registered first, and offer:
POST /events/event-triggers with {baseObject, condition, eventType: {name, displayName, description}}.callout_trigger and configure a standard Zuora Notification to hit the workflow's callout URL — this avoids needing a custom event.If both channels fail (no credentials in env AND MCP unavailable), the linter will surface W121 for the unconfirmed event name; carry on but flag the warning to the user.
This step is OPTIONAL by design — never block emission if neither channel can reach the tenant.
Walk the task graph topologically and validate every {{ Data.X.Y }} Liquid reference against an available_data set you accumulate. This catches the most common composition bug class: referencing data that no upstream task produces.
Required reading: workflow-data-flow.md (sections 1-3 + 10) and workflow-enums.json → default_data_workflow_keys / trigger_seeding_rules / data_flow_lint_rules.
Algorithm:
Seed available_data with the workflow-level inputs:
{ Workflow: [ExecutionDate, ExecutionDateTime, Name, Id, Tenant, User] }.call_type ∈ {UIACTION, SYNC_UI_ACTION}: { UIAction: [ObjectId, ObjectName, ObjectNumber] }.workflow.parameters.fields[]: union { <object_name>: [<field_name>] }. Ordinary user-entered filters, dates, IDs, and JSON maps belong under object_name: "Workflow"; do not use invented object names to group them.workflow.parameters.event_parameters[*].params[*]: union { <param.object>: [<param.key>] }.{ Callout: OPAQUE } (the inbound POST body — treat as opaque unless the workflow carries parameters._expected_response_schema or parameters._opaque_trusted).Topological sort the tasks via linkages from the Start (source_task_id == null). Each task inherits the union of its predecessors' final available_data.
For each task in topological order:
a. Look up the task's data_contract block in workflow-task-templates.json (fall back to $default_data_contract if absent — treats it as opaque).
b. Validate reads: scan every value in task.parameters (recursively) for {{ Data.X[.Y…] }} and {% if/elsif/case Data.X… %} patterns. For each reference:
Data.X scope is not in available_data: emit E170 and STOP (must fix). Suggest closest match if one exists.Data.X is the iterated key, treat as single-Hash mode. References like Data.X.Field are OK; Data.X[0].Field or Data.X | size trigger W173.Data.X came from a Logic::Case branch (one of Case_1, Case_2, …, Case_Else) and the current task is downstream of a Logic::Merge, AND X is in the union but not the intersection of branch contributions: emit W174.parameters._opaque_trusted="true" nor parameters._expected_response_schema.<X>: emit W172.parameters._expected_response_schema.<X> declared: validate Y against the declared field set; emit W171-equivalent if missing.Y is not in data_contract.writes[].fields (or parameters.fields[X][] for Query/Export/Create/Update/CustomObject::Query): emit W171.fields_partial_known: true: scope-level only (no W171).
c. Apply writes from the task's contract to compute the contribution to downstream available_data:scope_template placeholders against the task's parameters and object (e.g. Data.{parameters.placement | self.object} → Data.InvoicesFromBR).fields strings (from_param:fields[<obj>] → read the actual params; LIQUID_SCOPE → scan parameters.code for {% assign %} / {% capture %} names; OPAQUE → mark key opaque).Logic::Liquid with parameters.placement set, scope is Data.<placement> not Data.Liquid.
d. Iterate special case: when this task is Iterate(object='X'), for tasks reachable via For Each linkage, mark Data.X as "single-Hash mode". For tasks reached via Complete, restore the array binding (or whatever the inner-loop tasks rebound).
e. Logic::Case branch partitioning: each Case_N linkage's downstream subtree contributes its writes only to that branch. When two branches converge at a Logic::Merge, the post-merge available_data is the intersection of per-branch contributions; scopes only in the union (not the intersection) are flagged as branch_partial so downstream references emit W174.
f. Logic::Merge itself is a no-op for writes.Output: a small report listing, per task, what it consumes from Data.* and what it adds. Include the report in the design notes / commit message so reviewers can audit the data flow at a glance. The linter (scripts/lint-workflow-json.js, rules E170/W171/W172/W173/W174) is the final authority — never bypass it.
While running Step 3d, the moment you encounter the FIRST downstream reference to an OPAQUE task's scope (predictability=opaque per workflow-task-templates.json: Callout, AsynchronousCallout, Logic::Lambda, Script::JavaScript, Logic::JSONTransform, Logic::XMLTransform, Logic::CSVTranslator, Logic::ResponseFormatter, Execute::WorkflowTask, Mediation::SendEvents), pause the walker and prompt the user. One prompt per opaque task (covers all downstream references), not one per reference.
Task
<opaque task name>(<action_type>, predictability=opaque) returns a payload whose shape is unknown statically. Downstream<downstream task name(s)>referenceData.<scope>.<field…>. Choose how to proceed:(a) Declare expected response schema — list the fields you expect (e.g.
acknowledgmentId, receivedAt, errors[].code, errors[].message). I'll add this to the opaque task:"parameters": { ..., "_expected_response_schema": { "<scope>": { "acknowledgmentId": "string", "receivedAt": "string", "errors": [{ "code": "string", "message": "string" }] } } }The linter then performs field-level validation on every downstream
Data.<scope>.<field>reference (treats the scope as if DETERMINISTIC). Best choice when the response shape is known.(b) Opt out — set
parameters._opaque_trusted = "true"on the opaque task to suppress allW172warnings onData.<scope>.*references. Best when the response shape is too dynamic to declare (e.g. variable webhook payloads) and you trust runtime.(c) Insert a normalizer — I'll auto-insert a
Logic::JSONTransform(orLogic::ResponseFormatter) right after<opaque task name>that maps the response into a deterministic scope (e.g.Data.NormalizedInvoice). Downstream tasks then reference the normalized scope instead of the opaque one. Best when only a few fields are needed and the user wants type-safe downstream usage.(d) Pause for offline confirmation — I'll annotate the workflow as draft, leave the W172 warnings in place, and stop. Confirm the protocol choice before re-running build.
Apply the user's choice immediately, then continue Step 3d. Encode the choice as follows:
parameters._expected_response_schema = { "<scope>": { ...field declarations... } } to the opaque task. The leading _ prevents Rails from persisting it (unknown keys in parameters JSONB are accepted but no Ruby code reads _-prefixed keys).parameters._opaque_trusted = "true" (string, not boolean — matches the boolean_string_params convention)._expected_response_schema declaring the normalized scope. Wire linkages: <opaque task> --Success--> <normalizer> --Success--> <original downstream task>. Update downstream Liquid references to use Data.<normalized scope> instead of Data.<opaque scope>.This step is a hard gate for OPAQUE tasks with downstream consumers. Skipping it leaves W172 warnings the linter will surface in Step 5 anyway.
Before writing to disk, confirm every item:
Export/Query/Create/Update/CustomObject::* task emitted, one of these held: (a) a curl $ZUORA_BASE_URL/v1/describe/<object> (or Custom Object endpoint) call was made and cached, (b) mcp__zuora-mcp__ask_zuora was called with the Step 3a-1 prompt, (c) the object is in references/zuora-standard-fields.json, or (d) the user confirmed the field list explicitly.parameters.fields[<object>] and every field referenced inside parameters.where_clause is present in that describe response (or the fallback catalog, or user-confirmed).parameters.event_triggers[], the merge-field list from GET /notifications/email-templates/info/selections?category=<category> was fetched via curl or MCP (Step 3a-5), OR the only tokens used are recognised special tokens ($event_special_tokens.tokens).parameters.event_parameters[*].params[*].value is either a special token OR a <BaseObject.Field> whose BaseObject matches the event's declared baseObject ($event_base_objects.events) AND whose Field appeared in the fetched merge-field payload.workflow-skeleton.json as the base.workflow.type === "Workflow::Setup" (literal string).workflow.id === 1 and the Start linkage's source_workflow_id === 1.workflow.data === {}, workflow.status === "Inactive", workflow.css matches the skeleton default.ui_trigger key anywhere in workflow (it is not a column and is silently dropped).parameters.merge_task_ids key (auto-derived by Rails; deleted on save).workflow.parameters carries the seven always-present keys (fields, entity_name, entity_id, skipping_check, file_encryption, secure_error_msgs, show_run_prompt, callout_response).workflow.call_type is one of workflow-enums.json -> workflow_call_types.user_facing[*].value (no ASYNC/RULE/UI).workflow.version matches ^\d+(?:\.\d+)?(?:\.\d+)?$.event_trigger == true: parameters.event_triggers[] non-empty AND every entry in parameters.event_parameters[*].eventName matches one of those names; both event_parameters and inner params are JSON arrays.scheduled_trigger == true: interval is a 6-token cron string (5-token Unix cron is tolerated by Rufus but not emitted by the UI) and timezone is a Rails ActiveSupport::TimeZone friendly name, not a bare IANA name.scheduled_trigger == true: every required workflow input in parameters.fields[] has a non-blank default because scheduled runs cannot prompt a user.notifications.{failure|success|pending|skipped_scheduled_run} includes any true: notifications.emails[] is non-empty.call_type == "UIACTION" or "SYNC_UI_ACTION": ui_pages has exactly one entry from supported_ui_pages.pages.index, field_name, datatype, default, required, object_name) rather than UI/adapter keys such as name, label, type, or default_value (E126).datatype: "JSON" has a non-null string/array/object default value ([], {}, "", or a user-provided valid default), never null / boolean / number (E124).tasks array non-empty; linkages array non-empty.id, an action_type that exists in workflow-enums.json.action_types, and a parameters object (even if {}).required_at_import attribute for the task's action_type is populated with a non-empty value.<<REQUIRED: …>> sentinel replaced.boolean_string_params list is emitted as "true" / "false".param_enums.Logic::Case.parameters.case_condition keys are sequential Case_1, Case_2, … and the linkages use the same keys.Start linkage with source_workflow_id = workflow.id, source_task_id = null.source_workflow_id = null and non-null source_task_id.linkage_type values match upstream task hooks (see workflow-task-templates.json.hooks).For Each linkage sits on any path to a Logic::Merge task (if any exists).E170 (unknown Data.X reference). Any W171 / W172 / W173 / W174 warnings either resolved or explicitly accepted.Data.<Export.object>.<field> directly after an Export. Use Query for direct Data.* variables, or Iterate over the Export file holder before referencing row fields (E170 includes this hint).Billing::BillRun; use a custom Zuora Callout to {{ Credentials.zuora.rest_endpoint }}bill-runs when the requested bill run requires account-number, batch-number, APM/PRPC, or other filters the OOTB task cannot express; never use legacy /object/bill-run (W185 / W192).CancelSubscription through a Zuora-authorized Callout, not the legacy SOAP Cancel amendment task (W189).Callout / AsynchronousCallout to a Zuora API uses authorization.type = "zuora" and does not carry manual credential or bearer headers (E186).validation.replace = "true" and validation.zuora_call = "true", and downstream response references include ResponseBody unless include_response_code = "false" is explicitly set (W190 / W191).Data::Link -> Logic::Liquid(assign only) -> Data::Link chain. If the first query only resolves scalar context for the second query, fold it into one query with a CTE / CROSS JOIN and project the scalar fields onto each row (W180).where, where_exp, group_by, or group_by_exp instead of manual for + if + push loops (W184).Logic::Liquid task that can be inlined into the next task's Export/Query predicate, Case/If clause, date field, or Callout raw_body (W187).Update tasks that each set separate fields. Combine them into one object update; ProductRatePlanCharge / PRPC is one example of this general rule (W183).Credentials.zuora.rest_endpoint append resource paths only (orders, subscriptions/...), never /v1/... (E182).Callout / AsynchronousCallout / Logic::Lambda / Script::JavaScript / Logic::JSONTransform / Logic::XMLTransform / Logic::CSVTranslator / Logic::ResponseFormatter / Execute::WorkflowTask / Mediation::SendEvents whose output is referenced downstream carries either:
parameters._expected_response_schema = { "<scope>": { ... } }, ORparameters._opaque_trusted = "true", ORLogic::JSONTransform / Logic::ResponseFormatter) that rebinds the response into a deterministic scope.Iterate(object=X) For-Each branch, downstream references use Data.X.Field (single-record form), NOT Data.X[0].Field or Data.X | size (array forms — would trigger W173).Logic::Merge following a Logic::Case, downstream references only use scopes produced on all branches (or W174 will surface for branch-partial scopes).Write the complete JSON to a stable .workflow.json artifact path, then run:
node scripts/lint-workflow-json.js <path-to-generated.json>
The linter uses workflow-task-templates.json (per-task templates and data_contract blocks) and workflow-enums.json as its rule source. It prints errors and warnings with file paths and line numbers where possible. Fix every error; address warnings where they apply. Loop compose → lint until the linter exits with status 0. Do not replace this artifact with a shortened chat excerpt after linting; the linted artifact is the source of truth.
When the user wants an authoritative import check (e.g., AR column validations, call_type-enablement checks), run:
mcp__zuora-mcp__manage_workflows with import_workflow:
activate: falsename: prefixed with lint-dryrun- so it is easy to identify and delete.delete_workflow immediately to clean up.required_at_import, unsupported call_type, empty tasks/linkages), and loop.Note: activate: false still persists the workflow. It is not a free-form "validate-only" endpoint. Always delete after a dry-run.
In addition to the workflow JSON, generate as appropriate:
mcp__zuora-mcp__zuora_codegen for endpoints that receive / respond to the workflow's callout tasks. Follow the codegen flow: code_guidance → get_api_details → get_model_details → code_rules.manage_workflow_runs run_workflow + get_run_status assertions.import_workflow activate=true in sandbox.manage_workflow_runs run_workflow, then poll get_run_status and inspect task-level results./zuora-validate on any generated callout handler code.When responding, report the saved .workflow.json path and validation status. Only include the full JSON inline when it is small enough to paste completely without truncation.