{"id":18048,"plugin_id":"plugins_6a82b32a6ee8819191258c0368112b78","kind":"skill","collection_source":null,"comparison_source":null,"observed_at":"2026-09-30T23:14:35.338Z","digest":"68bb04b1974eef6f995a769c4080dc00615ee2ee18e642db07da2090faf396ff","against":null,"payload":{"description":"Compose an importable Zuora Workflow JSON from a design or requirement","included_files":[],"name":"zuora-workflow-build","skill_md_contents":"---\nname: zuora-workflow-build\ndescription: Compose an importable Zuora Workflow JSON from a design or requirement\nargument-hint: <workflow design or requirement>\nallowed-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]\n---\n\nCodex-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/`.\n\n\nYou are building an importable Zuora Workflow JSON from a design (produced by `/zuora-workflow-design` or provided directly).\n\n## Input\n\nThe user's workflow design or requirement: $ARGUMENTS\n\n## Correctness strategy\n\nFormat correctness is non-negotiable. A slightly malformed JSON fails `import_workflow`. Defense is layered:\n\n1. **Compose** from a canonical skeleton + per-action_type templates. Never hand-write structure.\n2. **Lint** with `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`.\n3. **Self-check** against the checklist below before writing the file.\n4. **Optional dry-run** in a sandbox: `import_workflow activate=false`, then `delete_workflow` to clean up. Rails has no `validate_only` flag.\n\n## Output contract\n\nThe deliverable is a complete, importable Workflow JSON artifact, not a partial transcript:\n\n- Always write the full workflow JSON to a `.workflow.json` file before finalizing. The file must contain exactly the four import envelope keys: `workflow_definition`, `workflow`, `tasks`, and `linkages`.\n- Run the linter against the exact file you will hand to the user. If the workflow cannot be linted or dry-run because prerequisites are missing (credentials, tenant access, unresolved fields, unsupported objects, missing user inputs), do not emit hopeful or partial JSON; explain the missing prerequisite and keep the artifact clearly marked as not ready for import.\n- In Codex, do not paste a shortened JSON block, excerpt, ellipsis, or \"rest omitted\" version as the workflow deliverable. If the JSON is too large for the final response, point to the saved `.workflow.json` path and summarize validation status.\n- If you include JSON in the final response at all, it must be the same complete linted object from the artifact. Otherwise, provide the file path plus the validation commands/results.\n\n## Workflow\n\n### Step 1: Load references\n\nRead these in parallel before composing:\n\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-skeleton.json` — the canonical empty envelope (start here).\n- `${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).\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-enums.json` — global enums, typo hints, `standard_events`, `supported_ui_pages`, schemas, and `version_regex`.\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-patterns.md` — composition strategy.\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-task-catalog.md` — task category overview and format pitfalls.\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-triggers-and-linkages.md` — trigger modes, call_type matrix, linkage catalog, and the **Workflow-level field derivation** cheat-sheet.\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-events.md` — standard event catalog, name corrections, and event_parameters derivation.\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-data-flow.md` — `Data.*` symbol-table model, opaque-task protocol, walker algorithm. **Required reading before Step 3d.**\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-liquid.md` — Liquid scopes.\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-liquid-filters.md` — Workflow-specific Liquid filter signatures, argument counts/types, and examples from `filters.rb`.\n- `${CLAUDE_PLUGIN_ROOT}/references/workflow-examples.md` — annotated, lint-clean workflow JSONs.\n\n### Step 2: Optional — check existing workflows to clone\n\nOnly if the user is modifying an existing workflow or has an obvious near-match:\n\nCall `mcp__zuora-mcp__manage_workflows`:\n\n- `list_workflows` — find candidates.\n- `get_workflow_details` or `export_workflow` — pull the JSON as a starting point.\n\nIf cloning, still run the output through the composer steps below (and the linter) to ensure it matches the current template schema.\n\n### Step 3: Compose the workflow JSON\n\nUse the skeleton + templates composer. Do not hand-write structure.\n\n1. **Start from a deep copy of `workflow-skeleton.json`.**\n\n2. **Populate `workflow_definition`** (name, description, category, `ui_page_roles`).\n\n3. **Set trigger flags and config on `workflow`** (see Step 3b for the full envelope):\n   - At least one of `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.\n   - 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.\n   - Event: `parameters.event_triggers` (non-empty array of canonical event names) + `parameters.event_parameters` (array of `{eventName, params: [...]}`).\n   - Choose `call_type` from `workflow-enums.json` -> `workflow_call_types.user_facing` (default `BATCH`; never emit `ASYNC`/`RULE`/`UI`).\n   - Do not create two workflows with identical tasks just to support two trigger modes. Use one workflow with multiple trigger flags unless the trigger-specific inputs or branches make the task graph meaningfully different.\n\n4. **For each task** in the design:\n   a. Look up the matching `action_type` in `workflow-task-templates.json`.\n   b. Deep-copy the `template` object.\n   c. Assign a unique integer `id` (simple sequence like 100, 101, 102).\n   d. Replace every `<<REQUIRED: …>>` sentinel with a real value. Use Liquid from `Data.*`, `Credentials.zuora.*`, `GlobalConstants.*` as appropriate.\n   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).\n   f. Guarantee `parameters` is an object, not `null` or missing. An empty task uses `\"parameters\": {}`.\n   g. For boolean params listed in `boolean_string_params`, always emit the STRING `\"true\"` or `\"false\"`, never JSON booleans.\n   h. For enum params listed in `param_enums`, pick one of the listed values — do not invent new ones.\n   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.\n   j. Set `css.top`/`css.left` using the defaults in `workflow-enums.json.css_layout_defaults` (adjust for branches).\n   k. Set `task_id` to the id of the primary upstream task (for layout/dependency purposes), or `null` for the entry task.\n\n   **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.\n\n   **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`.\n\n   **Async Zuora operation polling check:** when a Zuora API callout creates an async operation such as a bill run, payment run, journal run, or another job-style API, a `Success` linkage only means Zuora accepted the job. Do not chain a second async create, posting step, or dependent work directly from the create callout. Use either `AsynchronousCallout` with `polling_url`, `response_path`, and `finish_status`, or model `Callout -> status Callout -> If/Logic::Case`: the status callout checks the returned job/run id, the branch tests for a successful/completed status, the pending branch polls again (usually after a delay), and only the completed branch continues. The linter flags async create-to-create paths without a status poll plus completion decision as `W195`.\n\n   **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`.\n\n   **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`.\n\n   **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`.\n\n   **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`.\n\n   **Workflow error summary check:** when the user asks for a workflow error summary, final error report, or workflow execution error log, emit a `Data::Link` / Data Query task that reads the `workflow_task` table for the current run, filtered by `workflow_instance_id = '{{ WorkflowInstance.id }}'` and error/failure status or non-empty error fields. Project task id/name/status/error message/timestamps from `workflow_task`, then build the email/file/upload from that query result. Do not introduce a custom object to accumulate those errors unless the user explicitly asks for a durable custom-object audit store.\n\n   **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`.\n\n   **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`.\n\n   **Custom Object opt-in check:** do not emit `CustomObject::*` tasks unless the user explicitly asks to use a custom object, an existing custom-object schema, or a durable custom-object audit/state store. Prefer standard Zuora objects, Workflow runtime data, direct queries, files, email, or callouts for ordinary workflow state and reporting. When the user explicitly requests a custom object, set `parameters._custom_object_user_requested = \"true\"` on each `CustomObject::*` task to document that intent. The linter flags unmarked Custom Object tasks as `W194`. Shape must match Rails: no trailing `__c` on `object` (`E187`), nested `parameters.fields[<object>]` (`E188`), top-level `object_id` not `parameters.id` (`E189`), Query uses `alternate_location` not `placement` (`E190`).\n\n   **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`.\n\n   **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`.\n\n5. **Emit linkages**:\n   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\" }`.\n   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> }`.\n   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\"`.\n   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).\n   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.\n\n### Step 3a: Describe before selecting fields (HARD REQUIREMENT)\n\n**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.\n\nTwo describe surfaces must both be satisfied before emitting the JSON:\n\n1. **Task field describe** — for `Export`, `Query`, `Create`, `Update`, `CustomObject::Query`, `CustomObject::Create`, `CustomObject::Update` field lists **and** for any `parameters.where_clause` that references object fields.\n2. **Event merge-field describe** — for every `workflow.parameters.event_parameters[*].params[*].value` that is not one of the hard-coded special tokens.\n\nThe linter encodes these gates as `W177` (task fields), `E177` (wrong event BaseObject), and `W179` (unverifiable event merge field).\n\n#### 3a-0. Pick a channel (direct HTTP or MCP — either is fine)\n\nClaude 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.\n\nOne-time OAuth exchange per session (store the token, reuse for all describes):\n\n```bash\nACCESS_TOKEN=$(curl -sS -X POST \"$ZUORA_BASE_URL/oauth/token\" \\\n  --data-urlencode \"grant_type=client_credentials\" \\\n  --data-urlencode \"client_id=$ZUORA_CLIENT_ID\" \\\n  --data-urlencode \"client_secret=$ZUORA_CLIENT_SECRET\" \\\n  | jq -r '.access_token')\n```\n\nIf `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`).\n\nThe `configuration_contract.fields[*].source` value `describe-call` (per-task) tells you which field-bearing parameters need channel 1. From `workflow-task-configuration.md`:\n\n| Task | Describe scope | Configurable parameter |\n| --- | --- | --- |\n| `Export` | ZOQL/AQuA describe of `parameters.object` (+ joinable `related_objects`) | `parameters.fields[<object>][]` **and** any `parameters.where_clause` field |\n| `Query` | SOAP describe of `parameters.object` | `parameters.fields[<object>][]` **and** any `parameters.where_clause` field |\n| `Create` | SOAP describe of `parameters.object` (createable fields only) | `parameters.fields[<object>]` map |\n| `Update` | SOAP describe of `parameters.object` (updateable fields only) | `parameters.fields[<object>]` map |\n| `CustomObject::Query` | Custom Object describe of top-level `object` | implicit (selected via `parameters.query` Lucene refs) |\n| `CustomObject::Create` | Custom Object describe of top-level `object` (origin != system) | `parameters.fields[<object>]` nested map |\n| `CustomObject::Update` | Custom Object describe of top-level `object` (origin != system) | `parameters.fields[<object>]` nested map |\n\n#### 3a-1. Run the describe (Bash recipe, with MCP as the fallback)\n\n**Direct HTTP (preferred when `$ZUORA_BASE_URL` / `$ZUORA_CLIENT_ID` are set):**\n\n```bash\n# Standard SOAP/ZOQL describe — used for Export, Query, Create, Update.\n# Response is XML; pipe through xmllint / xsltproc, or just grep for <name>.\ncurl -sS \"$ZUORA_BASE_URL/v1/describe/Invoice\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H \"Accept: application/xml\" \\\n  | xmllint --xpath '//fields/field/name/text()' - 2>/dev/null \\\n  | tr -s '[:space:]' '\\n' | sort -u\n\n# Related-object list (second pass) — needed for Export joins like Invoice.Account.Name:\ncurl -sS \"$ZUORA_BASE_URL/v1/describe/Invoice\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" -H \"Accept: application/xml\" \\\n  | xmllint --xpath '//related-objects/object/name/text()' - 2>/dev/null\n\n# Custom Object describe — used for CustomObject::Query / Create / Update.\n# Replace <namespace> (e.g. \"default\") and <object> accordingly.\ncurl -sS \"$ZUORA_BASE_URL/objects/definitions/<namespace>/<object>\" \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" | jq '.schema.properties'\n```\n\nCache 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).\n\n**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:\n\n> Return a JSON array of selectable fields for `<object>` matching the shape Zuora's `/describe/<object>` endpoint returns. For each field include `name`, `type` (`string|number|boolean|datetime|picklist|reference|...`), `required` (bool), `createable` (bool), `updateable` (bool), and (if reference) `referenceTo`. Also list every `related_objects` entry that can be joined via dot notation in a ZOQL `SELECT`. If the tenant uses Custom Objects, return the `properties` map from `/objects/definitions/<namespace>/<object>` (origin != system) instead. Do not invent fields. Tenant: `<tenant>`. Entity: `<entity_id | 'default'>`.\n\n#### 3a-2. Fall back to the bundled catalog (standard fields only)\n\nIf **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.\n\n#### 3a-3. Cross-check during composition\n\nWhen you fill in `parameters.fields[<object>]` (Export/Query) or `parameters.fields[<object>].<FieldName>` (Create/Update/CustomObject::*), every field name MUST be either:\n\n1. Present in the describe response (curl or MCP) for that object, OR\n2. Present in the matching `references/zuora-standard-fields.json` entry, OR\n3. Explicitly confirmed by the user (with type) when both describe channels failed AND the field is not in the fallback.\n\nThe 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`).\n\nThe 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.\n\n#### 3a-4. Custom Object specifics\n\nFor `CustomObject::*` tasks, match the Rails models in `workflow/rails/app/models/tasks/custom_object/` — do **not** reuse SOAP `Query` / `Create` / `Update` shapes:\n\n1. **`object`** is `<namespace>__<object>` (e.g. `default__Vendor`). Never append `__c` to the object name — `__c` is a **field** suffix. Rails splits with `rpartition('__')`, so `default__Vendor__c` becomes `object_name = \"c\"`. Linter: `E187`.\n2. **`parameters.fields` is nested**: `parameters.fields.<self.object>.<Field__c> = <value>`. A flat `parameters.fields.<Field__c>` map is silently empty at runtime. Linter: `E188`.\n3. **Update/Delete id** is top-level `object_id`, never `parameters.id`. Linter: `E189`.\n4. **Query placement** uses `parameters.alternate_location`, not `parameters.placement` (SOAP Query). Create/Update have **no** placement — output is always `Data.<self.object>`. Linter: `E190`.\n\nThe 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: ...`. When the user explicitly requests a custom object, set `parameters._custom_object_user_requested = \"true\"` (`W194`).\n\n#### 3a-5. Event merge-field describe (event_parameters values)\n\n`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:\n\n```\nGET {base_url}/notifications/email-templates/info/selections?category=<category>\n```\n\nwhere `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).\n\nAt runtime, `BusinessEvent#parse_event` (`app/models/business_event.rb`) resolves these tokens in exactly two paths:\n\n1. **Special tokens** are resolved explicitly: `<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.\n2. **Everything else** has angle brackets stripped, then any `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.\n\n**Required flow per event:**\n\n1. 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).\n2. Fetch the merge-field list for the event category using either channel:\n\n   **Direct HTTP (preferred):**\n\n   ```bash\n   # Standard event: category is the 4-char numeric event id (look up in\n   # workflow-enums.json -> standard_events.events[*].id).\n   # Example: BillingRunCompletion -> category=1410.\n   curl -sS \"$ZUORA_BASE_URL/notifications/email-templates/info/selections?category=1410\" \\\n     -H \"Authorization: Bearer $ACCESS_TOKEN\" | jq\n\n   # Custom event: category is \"<namespace>:<name>\".\n   curl -sS \"$ZUORA_BASE_URL/notifications/email-templates/info/selections?category=user.notification:MyCustomEvent\" \\\n     -H \"Authorization: Bearer $ACCESS_TOKEN\" | jq\n   ```\n\n   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>.`.\n\n   **MCP fallback:** call `mcp__zuora-mcp__ask_zuora` with:\n\n   > For the Zuora event category `<event.id-or-namespaced-name>`, call `GET {base_url}/notifications/email-templates/info/selections?category=<category>` and return the raw JSON. For a `Standard` event (id < 5 chars) I want the list **excluding** any field that contains `DataSource`. For a custom event I want only fields that contain `DataSource` OR whose path matches `.<baseObject>.`.\n\n3. Pick the `<Object.Field>` strings you need from the returned list. Every `value` you emit must be one of:\n   - A special token from `$event_special_tokens.tokens`, OR\n   - A `<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\n   - A `<DataSource.Foo.Bar>` string (custom events only) confirmed by the endpoint response.\n4. 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.\n\n**Linter gates:**\n\n- `E177 invalid-event-parameter-value`: `<BaseObject.Field>` where `BaseObject` is not the event's declared `baseObject` and has no `DataSource.`/`Event.` prefix. Hard error.\n- `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.\n\n### Step 3b: Materialize the workflow envelope\n\nAfter 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:\n\n1. **`workflow.id`**: keep at `1` (skeleton default). The Start linkage's `source_workflow_id` MUST equal this value.\n2. **`workflow.name`** and **`workflow.description`**: copy from the user's design / `workflow_definition.name`.\n3. **`workflow.type`**: must be the literal string `\"Workflow::Setup\"`.\n4. **`workflow.data`**: always `{}`.\n5. **`workflow.status`**: always `\"Inactive\"` on import. The `import_workflow` tool flips it to `Active` via `activate_version: true`.\n6. **`workflow.css`**: keep skeleton default `{\"top\":\"40px\",\"left\":\"35px\"}`.\n7. **Trigger flags**: set at least one to `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).\n8. **`workflow.interval`** + **`workflow.timezone`**: required IFF `scheduled_trigger == true`. Use `interval_schema.examples` and `interval_schema.timezone.examples` from `workflow-enums.json`.\n9. **`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:\n   - **For event triggers**: set `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).\n   - **For callout triggers**: populate `parameters.fields[]` with the inbound payload schema if known.\n   - **For workflow-level run prompts**: set each ordinary input field to `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`.\n   - **For run-prompt/callout JSON fields**: never emit `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.\n   - **For multi-entity tenants**: set `parameters.entity_id` and `parameters.entity_name` if the user named an entity. Otherwise leave `null` and let the server default.\n   - **NEVER emit** `parameters.merge_task_ids` — `Workflow::Setup.import` deletes it on save.\n10. **`workflow.notifications`** — keep skeleton default unless the user requested email alerts. If they did:\n    - Populate `emails: [\"alice@example.com\", \"{{Data.Account.WorkEmail__c}}\", ...]`.\n    - Set the relevant booleans (`failure`, `success`, `pending`, `skipped_scheduled_run`).\n    - Optional `error_ignore`: a Ruby regex string. Validated by `Regexp.new` at import — invalid patterns reject the workflow.\n    - At least one boolean must be `true` if `emails[]` is non-empty (and vice versa).\n11. **`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.\n12. **`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>\" } }`.\n13. **`workflow.priority`**: default `\"Medium\"`; `\"High\"` for time-critical event workflows; `\"Low\"` for low-priority background work.\n14. **`workflow.delete_ttl`**: default `30`. Acceptable range depends on tenant retention policy; do not emit `0` unless the user explicitly asks for \"no retention\".\n15. **`workflow.version`**: default `\"0.0.1\"` for new; increment for new versions of the same `workflow_definition`. Must match `^\\d+(?:\\.\\d+)?(?:\\.\\d+)?$`.\n16. **`workflow.solution_id`**, **`workflow.extension_id`**: `null` unless packaging as a Connect extension.\n17. **`workflow.zuora_org_id`**: `null` (deprecated). **`workflow.zuora_org_ids`**: `[]` to allow all accessible orgs.\n\n### Step 3c: Optional event-trigger preflight\n\nWhen 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.\n\nTry this lookup once, in order:\n\n1. **Direct HTTP (preferred):**\n\n   ```bash\n   curl -sS \"$ZUORA_BASE_URL/events/event-triggers\" \\\n     -H \"Authorization: Bearer $ACCESS_TOKEN\" | jq '.data[] | .eventType.name'\n   curl -sS \"$ZUORA_BASE_URL/events/scheduled-events\" \\\n     -H \"Authorization: Bearer $ACCESS_TOKEN\" | jq '.data[] | .name'\n   ```\n\n   Both endpoints are paginated (`body.next`); follow cursors until you exhaust the list or confirm the event name.\n\n2. **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.`\n3. If the event exists, proceed.\n4. If the event is NOT registered (or the list is empty), tell the user the event must be registered first, and offer:\n   - Manual: Settings -> Notifications -> Custom Events.\n   - API: `POST /events/event-triggers` with `{baseObject, condition, eventType: {name, displayName, description}}`.\n   - Alternative design: switch to `callout_trigger` and configure a standard Zuora Notification to hit the workflow's callout URL — this avoids needing a custom event.\n5. 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.\n\nThis step is OPTIONAL by design — never block emission if neither channel can reach the tenant.\n\n### Step 3d: Static data accessibility check\n\nWalk 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.\n\nRequired reading: `workflow-data-flow.md` (sections 1-3 + 10) and `workflow-enums.json` → `default_data_workflow_keys` / `trigger_seeding_rules` / `data_flow_lint_rules`.\n\nAlgorithm:\n\n1. **Seed `available_data`** with the workflow-level inputs:\n   - Always: `{ Workflow: [ExecutionDate, ExecutionDateTime, ExecutionDateTimeUTC, WorkflowRunUser] }`.\n   - For each `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.\n   - For each `workflow.parameters.event_parameters[*].params[*]`: union `{ <param.object>: [<param.key>] }`.\n   - For **callout-trigger** workflows: `{ Callout: OPAQUE }` (the inbound POST body — treat as opaque unless the workflow carries `parameters._expected_response_schema` or `parameters._opaque_trusted`).\n\n2. **Topological sort** the tasks via linkages from the Start (`source_task_id == null`). Each task inherits the union of its predecessors' final `available_data`.\n\n3. **For each task in topological order:**\n   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).\n   b. **Validate reads**: scan every value in `task.parameters` (recursively) for `{{ Data.X[.Y…] }}` and `{% if/elsif/case Data.X… %}` patterns. For each reference:\n      - If `Data.X` scope is **not in `available_data`**: emit `E170` and STOP (must fix). Suggest closest match if one exists.\n      - If inside an Iterate For-Each branch and `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`.\n      - If `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`.\n      - If the producing task is **OPAQUE** (predictability=opaque) AND has neither `parameters._opaque_trusted=\"true\"` nor `parameters._expected_response_schema.<X>`: emit `W172`.\n      - If the producing task is OPAQUE AND has `parameters._expected_response_schema.<X>` declared: validate `Y` against the declared field set; emit `W171`-equivalent if missing.\n      - If the producing task is **DETERMINISTIC** and `Y` is not in `data_contract.writes[].fields` (or `parameters.fields[X][]` for Query/Export/Create/Update/CustomObject::Query): emit `W171`.\n      - If the producing task is **SEMI-DETERMINISTIC** and `fields_partial_known: true`: scope-level only (no `W171`).\n   c. **Apply writes** from the task's contract to compute the contribution to downstream `available_data`:\n      - Resolve `scope_template` placeholders against the task's `parameters` and `object` (e.g. `Data.{parameters.placement | self.object}` → `Data.InvoicesFromBR`).\n      - Resolve `fields` strings (`from_param:fields[<obj>]` → read the actual params; `LIQUID_SCOPE` → scan `parameters.code` for `{% assign %}` / `{% capture %}` names; `OPAQUE` → mark key opaque).\n      - For `Logic::Liquid` with `parameters.placement` set, scope is `Data.<placement>` not `Data.Liquid`.\n   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).\n   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`.\n   f. **Logic::Merge** itself is a no-op for writes.\n\n4. **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.\n\n### Step 3e: Opaque-task confirmation (three-option prompt)\n\nWhile 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.\n\n> Task `<opaque task name>` (`<action_type>`, predictability=opaque) returns a payload whose shape is unknown statically. Downstream `<downstream task name(s)>` reference `Data.<scope>.<field…>`. Choose how to proceed:\n>\n> **(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:\n>\n> ```jsonc\n> \"parameters\": {\n>   ...,\n>   \"_expected_response_schema\": {\n>     \"<scope>\": {\n>       \"acknowledgmentId\": \"string\",\n>       \"receivedAt\": \"string\",\n>       \"errors\": [{ \"code\": \"string\", \"message\": \"string\" }]\n>     }\n>   }\n> }\n> ```\n>\n> 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.\n>\n> **(b) Opt out** — set `parameters._opaque_trusted = \"true\"` on the opaque task to suppress all `W172` warnings on `Data.<scope>.*` references. Best when the response shape is too dynamic to declare (e.g. variable webhook payloads) and you trust runtime.\n>\n> **(c) Insert a normalizer** — I'll auto-insert a `Logic::JSONTransform` (or `Logic::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.\n>\n> **(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.\n\nApply the user's choice immediately, then continue Step 3d. Encode the choice as follows:\n\n- **(a)** add `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).\n- **(b)** add `parameters._opaque_trusted = \"true\"` (string, not boolean — matches the `boolean_string_params` convention).\n- **(c)** insert the normalizer task with its own `_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>`.\n- **(d)** persist the workflow with W172 warnings; report to the user; do NOT proceed to Step 5 (lint) until the user picks (a)/(b)/(c).\n\nThis step is a **hard gate** for OPAQUE tasks with downstream consumers. Skipping it leaves W172 warnings the linter will surface in Step 5 anyway.\n\n### Step 4: Self-check\n\nBefore writing to disk, confirm every item:\n\n- [ ] **Describe gate (hard pre-condition):**\n  - [ ] For every `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.\n  - [ ] Every field in `parameters.fields[<object>]` and every field referenced inside `parameters.where_clause` is present in that describe response (or the fallback catalog, or user-confirmed).\n  - [ ] For every event in `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`).\n  - [ ] Every `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.\n- [ ] Deep-copied `workflow-skeleton.json` as the base.\n- [ ] At least one trigger flag set; multiple flags combined when the same task graph should run through more than one trigger mode.\n- [ ] If multiple trigger flags are true, shared tasks only consume data available for every enabled trigger, or a default / normalizer step supplies the missing trigger-specific data.\n- [ ] `workflow.type === \"Workflow::Setup\"` (literal string).\n- [ ] `workflow.id === 1` and the Start linkage's `source_workflow_id === 1`.\n- [ ] `workflow.data === {}`, `workflow.status === \"Inactive\"`, `workflow.css` matches the skeleton default.\n- [ ] No `ui_trigger` key anywhere in `workflow` (it is not a column and is silently dropped).\n- [ ] No `parameters.merge_task_ids` key (auto-derived by Rails; deleted on save).\n- [ ] `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`).\n- [ ] `workflow.call_type` is one of `workflow-enums.json` -> `workflow_call_types.user_facing[*].value` (no `ASYNC`/`RULE`/`UI`).\n- [ ] `workflow.version` matches `^\\d+(?:\\.\\d+)?(?:\\.\\d+)?$`.\n- [ ] If `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.\n- [ ] If `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.\n- [ ] If `scheduled_trigger == true`: every required workflow input in `parameters.fields[]` has a non-blank default because scheduled runs cannot prompt a user.\n- [ ] If `notifications.{failure|success|pending|skipped_scheduled_run}` includes any `true`: `notifications.emails[]` is non-empty.\n- [ ] If `call_type == \"UIACTION\"` or `\"SYNC_UI_ACTION\"`: `ui_pages` has exactly one entry from `supported_ui_pages.pages`.\n- [ ] Every workflow input uses import keys (`index`, `field_name`, `datatype`, `default`, `required`, `object_name`) rather than UI/adapter keys such as `name`, `label`, `type`, or `default_value` (`E126`).\n- [ ] Every workflow input with `datatype: \"JSON\"` has a non-null string/array/object `default` value (`[]`, `{}`, `\"\"`, or a user-provided valid default), never `null` / boolean / number (`E124`).\n- [ ] `tasks` array non-empty; `linkages` array non-empty.\n- [ ] Every task has a unique integer `id`, an `action_type` that exists in `workflow-enums.json.action_types`, and a `parameters` object (even if `{}`).\n- [ ] Every `required_at_import` attribute for the task's `action_type` is populated with a non-empty value.\n- [ ] Every `<<REQUIRED: …>>` sentinel replaced.\n- [ ] Every boolean in a `boolean_string_params` list is emitted as `\"true\"` / `\"false\"`.\n- [ ] Enum params use values from `param_enums`.\n- [ ] `Logic::Case.parameters.case_condition` keys are sequential `Case_1`, `Case_2`, … and the linkages use the same keys.\n- [ ] Exactly one `Start` linkage with `source_workflow_id = workflow.id`, `source_task_id = null`.\n- [ ] Every non-Start linkage has `source_workflow_id = null` and non-null `source_task_id`.\n- [ ] `linkage_type` values match upstream task hooks (see `workflow-task-templates.json.hooks`).\n- [ ] No `For Each` linkage sits on any path to a `Logic::Merge` task (if any exists).\n- [ ] **Data-flow walker (Step 3d) ran clean**: no `E170` (unknown `Data.X` reference). Any `W171` / `W172` / `W173` / `W174` warnings either resolved or explicitly accepted.\n- [ ] **Export vs Query data-scope check ran clean**: no downstream task references `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).\n- [ ] **Bill Run task selection check ran clean**: no unsupported filter params on `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`).\n- [ ] **Async Zuora operation polling check ran clean**: async operation create callouts such as bill runs, payment runs, and journal runs are followed by `AsynchronousCallout` polling or a status callout plus `If` / `Logic::Case` completion decision before any dependent async operation starts (`W195`).\n- [ ] **Subscription cancel API-stack check ran clean**: new-stack subscription cancellation uses Orders API `CancelSubscription` through a Zuora-authorized `Callout`, not the legacy SOAP `Cancel` amendment task (`W189`).\n- [ ] **Zuora API Callout auth check ran clean**: any `Callout` / `AsynchronousCallout` to a Zuora API uses `authorization.type = \"zuora\"` and does not carry manual credential or bearer headers (`E186`).\n- [ ] **Zuora API Callout validation/response check ran clean**: Zuora API callouts include `validation.replace = \"true\"` and `validation.zuora_call = \"true\"`, and downstream response references include `ResponseBody` unless `include_response_code = \"false\"` is explicitly set (`W190` / `W191`).\n- [ ] **Data Query consolidation check ran clean**: no avoidable `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`).\n- [ ] **Workflow Liquid filter check ran clean**: simple array filtering/grouping uses `where`, `where_exp`, `group_by`, or `group_by_exp` instead of manual `for` + `if` + `push` loops (`W184`).\n- [ ] **Liquid shim minimization check ran clean**: no single-consumer `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`).\n- [ ] **CRUD update consolidation check ran clean**: no adjacent same-record `Update` tasks that each set separate fields. Combine them into one object update; ProductRatePlanCharge / PRPC is one example of this general rule (`W183`).\n- [ ] **Custom Object shape check ran clean**: every `CustomObject::*` task uses `<namespace>__<object>` without trailing `__c` (`E187`), nests Create/Update fields under `parameters.fields[<object>]` (`E188`), puts Update/Delete ids on top-level `object_id` not `parameters.id` (`E189`), and uses Query `alternate_location` not `placement` (`E190`). Unmarked Custom Object tasks still warn as `W194`.\n- [ ] **Zuora REST v1 URL check ran clean**: Callout / AsynchronousCallout URLs that use `Credentials.zuora.rest_endpoint` append resource paths only (`orders`, `subscriptions/...`), never `/v1/...` (`E182`).\n- [ ] **Opaque-task confirmation (Step 3e) completed for every OPAQUE task with downstream consumers**: each `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:\n  - `parameters._expected_response_schema = { \"<scope>\": { ... } }`, OR\n  - `parameters._opaque_trusted = \"true\"`, OR\n  - a downstream normalizer task (`Logic::JSONTransform` / `Logic::ResponseFormatter`) that rebinds the response into a deterministic scope.\n- [ ] Inside any `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`).\n- [ ] After a `Logic::Merge` following a `Logic::Case`, downstream references only use scopes produced on **all** branches (or `W174` will surface for branch-partial scopes).\n\n### Step 5: Lint the output\n\nWrite the complete JSON to a stable `.workflow.json` artifact path, then run:\n\n```bash\nnode scripts/lint-workflow-json.js <path-to-generated.json>\n```\n\nThe 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.\n\n### Step 6: Optional sandbox dry-run\n\nWhen the user wants an authoritative import check (e.g., AR column validations, call_type-enablement checks), run:\n\n1. `mcp__zuora-mcp__manage_workflows` with `import_workflow`:\n   - `activate: false`\n   - `name`: prefixed with `lint-dryrun-` so it is easy to identify and delete.\n2. On success, call `delete_workflow` immediately to clean up.\n3. If import fails, read the error, auto-repair (most common: missing `required_at_import`, unsupported `call_type`, empty tasks/linkages), and loop.\n\nNote: `activate: false` still **persists** the workflow. It is not a free-form \"validate-only\" endpoint. Always delete after a dry-run.\n\n### Step 7: Write supporting artifacts\n\nIn addition to the workflow JSON, generate as appropriate:\n\n- Callout handler code — use `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`.\n- Test scripts — for `manage_workflow_runs` `run_workflow` + `get_run_status` assertions.\n- Monitoring / operational notes.\n\n### Step 8: Suggest next steps\n\n- Sandbox import (for real): `import_workflow activate=true` in sandbox.\n- Functional test: `manage_workflow_runs` `run_workflow`, then poll `get_run_status` and inspect task-level results.\n- Production promotion: re-export the sandbox workflow and re-import into production.\n- Run `/zuora-validate` on any generated callout handler code.\n\nWhen 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.\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}