← Files Zuora Coding AgentARCHIVED FILE

references/workflow-task-templates.json

246 KB · Oct 2, 2026 · 00:30 UTC

↓ Download file

{
  "$description": "Per-action_type templates for composing Zuora Workflow tasks. Each entry contains description, hooks, a fully-formed template with sentinel slots, required_params, required_at_import, param_enums, boolean_string_params, and (for Tier-1 tasks) data_contract. Distilled from Rails models at ~/Workspace/workflow/rails/app/models/tasks/*.rb. Sentinel tokens: <<TASK_ID>>, <<UPSTREAM_TASK_ID_OR_NULL>>, <<TOP>>, <<LEFT>>, <<REQUIRED: ...>>. The linter fails on any unreplaced <<...>> token.",
  "$common_task_envelope_keys": [
    "id", "name", "parameters", "action_type", "object", "object_id",
    "call_type", "task_id", "css", "concurrent_limit", "tags", "priority",
    "assignment", "zuora_org_id", "zuora_org_ids"
  ],

  "$data_contract_schema": {
    "$description": "Per-task contract describing what the task adds to Data.* for downstream tasks (writes), what Data.X.Y references it consumes from upstream (reads), and how predictable that output is at design time. The linter walks the task graph topologically using these contracts to validate Liquid {{ Data.X.Y }} references (rules E170/W171/W172/W173/W174). For non-Tier-1 tasks without a contract, the linter falls back to $default_data_contract.",
    "predictability": "One of: 'deterministic' (scope and field shape both fully known: Query, Create, Update, CustomObject::Query, amendments), 'semi-deterministic' (scope known, field shape partial or large/standardized: Export, Billing::BillRun, Payment::PaymentRun, GraphQuery, Logic::Liquid, Approval, Reporting::*, file-handling tasks), 'opaque' (scope known but field shape unknowable until runtime: Callout, AsynchronousCallout, Logic::Lambda, Script::JavaScript, Logic::JSONTransform, Logic::XMLTransform, Logic::CSVTranslator, Logic::ResponseFormatter, Execute::WorkflowTask, Mediation::SendEvents), 'scoping' (no positive writes; routes execution and/or rebinds existing scopes: If, Logic::Case, Iterate, Logic::Merge, Approval, Delete, CustomObject::Delete), 'none' (side-effect-only or non-data tasks: Email, Notifications::SMS, Notifications::Kafka, Delay, UI::Page, UI::Stop, Upload::*, UsageMediation::*, File::FileOperations file-only modes).",
    "writes": [
      {
        "to_template": "Data.<key>  (e.g. 'Data.{parameters.placement | self.object}')",
        "shape": "Hash | Array<Hash> | File | object",
        "fields": "comma-separated field names, or from_param:<paramPath>, or OPAQUE, or LIQUID_SCOPE",
        "notes": "optional human note"
      }
    ],
    "reads": [
      { "from_param": "<paramPath>", "shape": "liquid | string" }
    ],
    "opaque": "true if downstream cannot statically know writes[].fields (Callout, Lambda, JS, JSONTransform, XMLTransform, AsyncCallout). The linter emits W172 for downstream Data.<placement>.<field> references unless the opaque task carries a parameters._opaque_trusted='true' or parameters._expected_response_schema={...} sentinel.",
    "opaque_prompt_required": "true if the build skill must prompt the user (Step 3e) before composing downstream tasks that reference this task's scope. Mirrors opaque=true for Callout, AsynchronousCallout, Logic::Lambda, Logic::Script::JavaScript, Execute::WorkflowTask, Logic::JSONTransform, Logic::XMLTransform, Logic::CSVTranslator, Logic::ResponseFormatter, Mediation::SendEvents.",
    "rebinds": "object key whose Array binding becomes a single Hash inside the For-Each branch (Iterate only). Inside that branch downstream tasks read Data.<rebound>.<Field> for the current row. Outside the branch the original Array binding (or Merge result) returns.",
    "routing_only": "true for tasks that only branch (If, Logic::Case, Approval, Delay) \u2014 no writes, but reads still apply via parameters.if_clause / case_clause. Used together with predictability='scoping' or 'none'.",
    "fields_partial_known": "true when the writes scope is known but the per-row keys are extracted from a free-form param (e.g. GraphQuery selection set, Logic::Liquid assigns). Linter downgrades W171 to a notice.",
    "$sentinel_keys_in_parameters": {
      "_opaque_trusted": "Set to 'true' (string) on Callout/Async/Lambda/JS/etc. to suppress W172 across all downstream references. Rails ignores unknown parameter keys, so this has zero runtime impact.",
      "_expected_response_schema": "JSON object documenting the expected response shape (e.g. { 'Invoice': { 'Id': 'string', 'Number': 'string' } }). When set, the linter performs field-level checks on Data.<scope>.<field> references for that opaque scope as if it were deterministic."
    }
  },

  "$default_data_contract": {
    "$description": "Fallback contract for any action_type not present in this catalog (or for Tier-1 entries without a data_contract block). Treated as opaque: writes nothing the linter can validate, downstream Data.<TaskAction>.* references emit W172. Composer should add an explicit data_contract or _data_contract annotation when known.",
    "predictability": "opaque",
    "writes": [
      { "to_template": "Data.<TaskActionDemodulized>", "shape": "object", "fields": "OPAQUE" }
    ],
    "reads": [],
    "opaque": true,
    "opaque_prompt_required": true
  },

  "$configuration_contract_schema": {
    "$description": "Per-task contract describing how each `parameters.*` field is sourced in the Zuora Workflow Rails UI, what the controller permits, and what the model validates. Distilled from `app/views/tasks/partials/_<task>.html.erb` (and shared partials), the per-task `case` branch in `app/controllers/tasks_controller.rb`, and the model at `app/models/tasks/<task>.rb`. The composer/build skill consults this contract to (a) only emit fields that survive the controller permit, (b) prompt for fields whose `source` is `describe-call` or `user-input`, and (c) skip fields whose `visible_when` predicate evaluates false against sibling parameters. Linter rules W178 (UI-only-leak) and W177 (undeclared-describe-field) reference these contracts.",
    "field_schema": {
      "$field": "Dotted path under the task envelope. Most common is `parameters.<name>`; for top-level columns use `<columnName>` (e.g. `object`, `object_id`, `task_id`).",
      "purpose": "One-line explanation of what this field controls at runtime.",
      "source": "How the UI populates the value. One of: `user-input` (free-form text/number), `dropdown-static` (hard-coded options inline in the partial), `dropdown-static-from-enums` (options pulled from a Rails constant), `dropdown-from-parent-task` (options derived from the upstream task's `data_structure` or `object_structure` -- e.g. Iterate.object), `describe-call` (options come from a live Zuora describe API call -- e.g. Export/Query/Create/Update parameters.fields), `derive-from-workflow-parameters` (auto-derived from `workflow.parameters.fields`), `callout-response-field` (set at runtime, not user-configurable), `computed` (auto-set by the model; never user-supplied), `code-editor` (CodeMirror text area such as Liquid/JS/SQL), `repeated-row` (UI-driven repeating row form -- e.g. Email recipients, Callout headers), `boolean-checkbox` (rendered as checkbox; persisted as `\"true\"`/`\"false\"` strings -- see `boolean_string_params`).",
      "options_source": "When `source` is dropdown-* or describe-call: short pointer to where the UI gets its options (e.g. `Rails enum Task::CALL_TYPES`, `parent_task.data_structure['Files']`, `describe_helper(object: <object>)`). Omit when irrelevant.",
      "required_when": "Liquid-ish predicate against sibling parameters that makes this field required (e.g. `parameters.body_type == 'raw'` for Callout.parameters.raw_body). When omitted, the field is either always required (see template `<<REQUIRED>>` markers) or always optional.",
      "visible_when": "Liquid-ish predicate against sibling parameters that makes this field visible in the UI. When omitted, the field is always visible. The composer should drop fields whose predicate is false (otherwise they can confuse the runtime).",
      "controller_permit": "How the field is permitted in `tasks_controller.rb`. Common values: `parameters.<name>` (single permitted key), `parameters.<name>: []` (array form), `parameters.<name>_attributes` (Rails nested attributes), `parameters: any-hash` (no per-key strong-params check -- the catch-all branch at L801-802 of tasks_controller.rb). Use exactly the symbol the controller writes.",
      "model_validation": "Short summary of the model-side validation that runs when persisting through the form (NOT during `import_workflow`, which uses `validate: false`). Cite the model file + line range when nontrivial.",
      "default": "Default value the React/ERB UI seeds when the field is left blank. Match the literal type Rails will persist (string `\"true\"` for booleans, integer for chunk_size, etc.).",
      "task_process_use": "Optional. Mention if `task_process` reads or rewrites this field (e.g. Iterate's chunk_size becomes the For-Each batch).",
      "notes": "Any extra constraints not captured above (cross-field rules, gotchas, deprecated keys)."
    },
    "$design_intent": "These contracts are the truth source for: (a) the `zuora-workflow-build` skill's per-task UX (which fields to ask the user for vs. infer); (b) the linter's W177/W178 rules; (c) the composer's drop-list of UI-only fields that must NOT appear in import JSON. They are NOT a runtime schema -- task_process semantics live in `data_contract` (above)."
  },

  "Callout": {
    "description": "Make an HTTP callout to an external system.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Callout",
      "parameters": {
        "url": "<<REQUIRED: target URL>>",
        "method": "POST",
        "body_type": "raw",
        "raw_body": "",
        "headers": [{ "key": "Content-Type", "value": "application/json" }],
        "authorization": { "type": "none" },
        "validation": { "status_codes": ["200"] },
        "retry_rules": { "retry_count": "0", "retry_window": "30" },
        "strict_variables": "true",
        "disable_validation": "false"
      },
      "action_type": "Callout",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["url", "method"],
    "required_at_import": [],
    "param_enums": {
      "method": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "HEAD"],
      "body_type": ["raw", "binary", "form-data", "none"],
      "authorization.type": ["none", "zuora", "basic_auth", "hmac", "oauth_2.0", "oauth_1.0", "NetsuiteTBA"]
    },
    "boolean_string_params": ["strict_variables", "disable_validation", "validate_response", "notification_history_enabled"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "Callout",
      "scope_template": "Data.{parameters.validation.payload_location | 'Callout'}",
      "writes": [
        { "to_template": "Data.{parameters.validation.payload_location | 'Callout'}", "shape": "object", "fields": "OPAQUE", "notes": "Parsed JSON / SOAP-XML / text response of the external endpoint. Shape is not statically knowable. When include_response_code is true or omitted, response fields live under ResponseBody; direct Data.<payload>.<field> paths require include_response_code = \"false\"." }
      ],
      "reads": [
        { "from_param": "url", "shape": "liquid" },
        { "from_param": "raw_body", "shape": "liquid" },
        { "from_param": "headers[*].value", "shape": "liquid" },
        { "from_param": "form_datas[*].value", "shape": "liquid" }
      ],
      "opaque": true,
      "opaque_prompt_required": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_callout.html.erb",
      "$controller_branch": "tasks_controller.rb L596-653 -- explicit `when :callout` branch with a fully enumerated parameter allowlist. Top-level `headers_attributes`, `form_datas_attributes`, and `datas_attributes` ride on common_params. Files (parameters.files) are coerced from a hash to an array (L647-649); cert.p12_file is read into bytea (L643-645).",
      "$model": "app/models/tasks/callout.rb",
      "$model_validation": "task_setup_validation (L11-49): url required; GET cannot have raw_body; URL_BLACKLIST (Zuora file API, S3 owl) rejected; cannot point at /workflows/.../run; retry_count 0..10; retry_window 0..60; rejects plain-text apiAccessKeyId / Authorization headers; payload_location must match `[a-zA-Z0-9_]+`; entity_id required when authorization.type == 'zuora' and tenant has multiple entities; notification_history_account_id required when notification_history_enabled.",
      "$task_process_use": "task_process (L79+) parses url via Liquid, performs the HTTP request honoring authorization.type, then writes Data.<payload_location | 'Callout'> with the parsed response. When include_response_code is true, the value is `{ResponseBody, ResponseCode, URL}`; otherwise it's the body directly. validation.replace replaces the entire Data scope; validation.zuora_call enables Zuora-specific retry semantics.",
      "fields": [
        {
          "$field": "parameters.api_name",
          "purpose": "When set, autofills url/method/raw_body/authorization from a known Zuora REST API entry.",
          "source": "dropdown-static",
          "options_source": "f.object.retrieve_apis grouped by tag (`_callout.html.erb` L72-76).",
          "controller_permit": "parameters.api_name (tasks_controller.rb L599)",
          "default": null,
          "notes": "Selecting an api_name auto-submits the form so url/method/body/auth refresh from the API metadata."
        },
        {
          "$field": "parameters.method",
          "purpose": "HTTP method.",
          "source": "dropdown-static",
          "options_source": "['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'TRACE'] (`_callout.html.erb` L87)",
          "controller_permit": "parameters.method (L601)",
          "model_validation": "Required; supported list enforced at runtime (callout.rb L82). For GET, raw_body must be blank.",
          "default": "POST",
          "notes": "Persist as uppercase string."
        },
        {
          "$field": "parameters.url",
          "purpose": "HTTP endpoint. Liquid evaluated.",
          "source": "user-input",
          "controller_permit": "parameters.url (L602)",
          "model_validation": "Required; rejected when matches URL_BLACKLIST or `/workflows/.../run`. When host ends in .zuora.com, the UI auto-enables zuora_call validation (L155-322).",
          "default": null
        },
        {
          "$field": "parameters.body_type",
          "purpose": "Encoding of the request body.",
          "source": "dropdown-static",
          "options_source": "['none', 'form-data', 'x-www-form-urlencoded', 'raw', 'binary'] (`_callout.html.erb` L174-175). UI disables 'x-www-form-urlencoded'.",
          "controller_permit": "parameters.body_type (L604)",
          "default": "raw"
        },
        {
          "$field": "parameters.raw_body",
          "purpose": "Request body when body_type == 'raw'. Liquid evaluated.",
          "source": "code-editor",
          "controller_permit": "parameters.raw_body (L603)",
          "model_validation": "Must be blank when method == 'GET'.",
          "default": "",
          "visible_when": "body_type == 'raw'"
        },
        {
          "$field": "headers_attributes (top-level on the form, becomes Headers join records)",
          "purpose": "HTTP request headers.",
          "source": "form-array",
          "options_source": "Free-form (key, value).",
          "controller_permit": "headers_attributes: %i(key value) (L639)",
          "model_validation": "Rejects plain-text apiAccessKeyId / Authorization Basic (callout.rb L26-31). The first row's value is required.",
          "default": "[{ key: 'Content-Type', value: 'application/json' }] (in template)",
          "notes": "In the import JSON we put these into parameters.headers as `[{key, value}, ...]` -- the controller maps them to top-level headers_attributes when saving from the UI. (See env-specific normalization in workflow-examples.md.)"
        },
        {
          "$field": "parameters.headers",
          "purpose": "JSON-import representation of HTTP request headers (the same data as the UI's headers_attributes; Workflow::Setup.import normalizes this to the headers_attributes form when persisting).",
          "source": "form-array",
          "options_source": "Mirror of headers_attributes (`[{key, value}, ...]`).",
          "controller_permit": "parameters: any-hash (the controller writes the same content via headers_attributes -- see callout.rb#load_headers).",
          "model_validation": "Same as headers_attributes -- rejects plain-text apiAccessKeyId / Authorization Basic (callout.rb L26-31).",
          "default": "[{ key: 'Content-Type', value: 'application/json' }] (in template)",
          "notes": "Always emit headers via `parameters.headers` in import JSON; the model parses both shapes."
        },
        {
          "$field": "parameters.form_datas",
          "purpose": "JSON-import representation of multipart form-data fields (mirror of form_datas_attributes).",
          "source": "form-array",
          "controller_permit": "parameters: any-hash",
          "default": "[]",
          "visible_when": "body_type == 'form-data'"
        },
        {
          "$field": "parameters.datas",
          "purpose": "JSON-import representation of x-www-form-urlencoded fields (mirror of datas_attributes).",
          "source": "form-array",
          "controller_permit": "parameters: any-hash",
          "default": "[]",
          "visible_when": "body_type == 'x-www-form-urlencoded'"
        },
        {
          "$field": "form_datas_attributes",
          "purpose": "Multipart form-data fields.",
          "source": "form-array",
          "controller_permit": "form_datas_attributes: %i(key value upload) (L638)",
          "default": "[]",
          "visible_when": "body_type == 'form-data'"
        },
        {
          "$field": "parameters.file_binary",
          "purpose": "Files entry name to send as the binary body.",
          "source": "dropdown-from-parent-task",
          "options_source": "f.object.data_structure['Files'] (`_callout.html.erb` L267-275).",
          "controller_permit": "parameters.file_binary (L619)",
          "visible_when": "body_type == 'binary'"
        },
        {
          "$field": "parameters.files",
          "purpose": "Workflow-Files attachments to include with the request.",
          "source": "form-array",
          "options_source": "f.object.data_structure['Files'] (L209).",
          "controller_permit": "parameters.files: [:key, :upload, :name, :filename] (L636) -- the controller hash->array coerces (L647-649).",
          "default": "[]",
          "visible_when": "body_type == 'form-data'"
        },
        {
          "$field": "parameters.authorization.type",
          "purpose": "Auth scheme. Selecting a type unlocks the matching nested block.",
          "source": "dropdown-static",
          "options_source": "['none', 'zuora', 'basic_auth', 'oauth1', 'oauth2', 'hmac', 'cert', 'netsuite_tba'] (see partials/auth + tasks_controller.rb L702-707).",
          "controller_permit": "parameters.authorization.type (L627)",
          "model_validation": "When type == 'zuora' and tenant has multiple entities, entity_id is required.",
          "default": "none",
          "notes": "For Zuora API URLs, set type to 'zuora'. Do not use type 'none' with apiAccessKeyId, apiSecretAccessKey, Authorization, or bearer-token headers; Workflow's Zuora auth mode injects the tenant credentials and entity context."
        },
        {
          "$field": "parameters.basic_auth / parameters.oauth1 / parameters.oauth2 / parameters.hmac / parameters.cert / parameters.netsuite_tba",
          "purpose": "Auth-type-specific credentials block.",
          "source": "user-input (nested hash)",
          "controller_permit": "see tasks_controller.rb L630-636",
          "default": null,
          "visible_when": "authorization.type matches the corresponding scheme",
          "notes": "Only the block matching authorization.type should be populated; the others are dropped server-side."
        },
        {
          "$field": "parameters.retry_rules",
          "purpose": "Retry counter and window in minutes.",
          "source": "user-input (nested hash)",
          "controller_permit": "parameters.retry_rules: [:retry_count, :retry_window, :current_retry_count, :on_timeout] (L628)",
          "model_validation": "retry_count 0..10; retry_window 0..60.",
          "default": "{ retry_count: 0, retry_window: 30, on_timeout: false }"
        },
        {
          "$field": "parameters.validation.status_codes",
          "purpose": "HTTP response codes considered success.",
          "source": "multi-select-with-tags",
          "options_source": "f.object.http_codes union the existing list (`_callout.html.erb` L331).",
          "controller_permit": "parameters.validation.status_codes: [] (L629)",
          "model_validation": "Persisted as Array<String> of integer-strings (NOT integers).",
          "default": "[\"200\"]"
        },
        {
          "$field": "parameters.validation.payload_location",
          "purpose": "Override the Data scope name where the parsed response is written (default 'Callout').",
          "source": "user-input",
          "controller_permit": "parameters.validation.payload_location (L629)",
          "model_validation": "Must match `[a-zA-Z0-9_]+`.",
          "default": "\"Callout\""
        },
        {
          "$field": "parameters.validation.replace",
          "purpose": "When true, the response replaces the entire Data scope at payload_location instead of merging.",
          "source": "boolean-string checkbox",
          "controller_permit": "parameters.validation.replace (L629)",
          "default": "\"false\""
        },
        {
          "$field": "parameters.validation.zuora_call",
          "purpose": "Enable Zuora-specific retry/error semantics (session, locking, rate limit).",
          "source": "boolean-string checkbox",
          "controller_permit": "parameters.validation.zuora_call (L629)",
          "default": "auto-set when url host ends in .zuora.com (L155, 322)"
        },
        {
          "$field": "parameters.include_response_code",
          "purpose": "When true, wraps the response as `{ResponseBody, ResponseCode, URL}` under the Data scope.",
          "source": "boolean-string checkbox",
          "controller_permit": "parameters.include_response_code (L605)",
          "default": "\"true\""
        },
        {
          "$field": "parameters.validate_response",
          "purpose": "When true, evaluate parameters.validation_scheme (Liquid) against the response and fail if it returns 'false'.",
          "source": "boolean-string checkbox",
          "controller_permit": "parameters.validate_response (L606)",
          "model_validation": "validation_scheme required when validate_response is true.",
          "default": "\"false\""
        },
        {
          "$field": "parameters.validation_scheme",
          "purpose": "Liquid expression that must evaluate to 'true' for the callout to succeed.",
          "source": "code-editor",
          "controller_permit": "parameters.validation_scheme (L607)",
          "required_when": "validate_response == 'true'"
        },
        {
          "$field": "parameters.notification_history_enabled / notification_history_account_id / notification_history_object_id / event_name",
          "purpose": "Optional Customer Notification History stream attached to the callout.",
          "source": "boolean-string + user-input",
          "controller_permit": "parameters.notification_history_enabled / *_account_id / *_object_id / event_name (L608-611)",
          "model_validation": "account_id required when enabled.",
          "default": "false"
        },
        {
          "$field": "parameters.enable_polling / polling_interval / response_path / finish_status",
          "purpose": "Async (long-running) callout polling -- only available on appinstances with concurrency_subsequent_task enabled.",
          "source": "user-input",
          "controller_permit": "parameters.enable_polling / polling_interval / response_path / finish_status: [] (L620-623)",
          "default": "false / 30 seconds",
          "visible_when": "appinstance.extra_settings['concurrency_subsequent_task'] (`_callout.html.erb` L1)"
        }
      ],
      "$see_controller_permit": "tasks_controller.rb#task_params L596-653 enumerates every permitted key. Hand-edited JSON that adds keys outside this list is silently dropped on save (and tripped by the W178 linter rule once it lands)."
    },
    "notes": "If method is GET, parameters.raw_body must be blank. parameters.validation.status_codes is an array of integer-strings (NOT integers). retry_rules.retry_count 0..10 and retry_window 0..60."
  },

  "Email": {
    "description": "Send an email notification with a Liquid-templated body.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Email",
      "parameters": {
        "email": {
          "to": ["<<REQUIRED: recipient or Liquid expression>>"],
          "cc": [],
          "bcc": [],
          "from": "workflow@zuora.com",
          "reply_to": "",
          "name": "",
          "subject": "<<REQUIRED: subject line>>",
          "template": "<<REQUIRED: HTML body with optional Liquid>>",
          "attachments": { "invoices": "false" },
          "preview_only": "false",
          "disable_editor": "false"
        },
        "files": {},
        "strict_variables": "true"
      },
      "action_type": "Email",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["email.to", "email.from", "email.subject", "email.template"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables", "email.preview_only", "email.disable_editor", "email.attachments.invoices", "notification_history_enabled"],
    "data_contract": {
      "predictability": "none",
      "scope_template": "Data.Files.{file_holder_name}",
      "writes": [
        { "to_template": "Data.Files.{file_holder_name}", "shape": "File", "fields": "{name, task_id, object_class, file_type}", "notes": "Email writes the rendered HTML and encoded body as Files entries (file_holder_name(file_type:'html') and (file_type:'encoded')). Treated as predictability=none for downstream Liquid validation: nothing of business interest is exposed via Data.<scope>." }
      ],
      "reads": [
        { "from_param": "email.subject", "shape": "liquid" },
        { "from_param": "email.template", "shape": "liquid" },
        { "from_param": "email.to[*]", "shape": "liquid" },
        { "from_param": "email.cc[*]", "shape": "liquid" },
        { "from_param": "email.bcc[*]", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_email.html.erb",
      "$controller_branch": "tasks_controller.rb L795-799 -- explicit `when :email` branch permits `headers_attributes: %i(key value)` at the top level and then merges `params[:email][:parameters]` raw (opaque). So the full `parameters.email.*` tree is accepted unfiltered, exactly like the fall-through branch.",
      "$model": "app/models/tasks/email.rb",
      "$model_validation": "task_setup_validation (L16-27): email.to / email.from / email.subject / email.template required; from must be from appinstance.emails (or any address when SMTP enabled, see L57-61); reply_to required when org isn't Zuora and from == 'workflow@zuora.com'; notification_history_account_id required when notification_history_enabled. cast (L50-54) drops blank entries from to/cc/bcc.",
      "$task_process_use": "task_process renders the Liquid template, sends via SMTP / WorkflowMailer, and uploads two Files entries: file_holder_name(file_type: 'html') and (file_type: 'encoded'). No business-value Data is written, so downstream Liquid should not depend on Email output.",
      "fields": [
        {
          "$field": "parameters.email.to",
          "purpose": "Recipient email addresses.",
          "source": "multi-select-with-tags",
          "options_source": "f.object.accessible_payload (Liquid-evaluated entries from upstream tasks).",
          "controller_permit": "parameters.email.* (opaque branch)",
          "model_validation": "Required (cast drops blanks).",
          "default": "[]",
          "notes": "Each entry can be a literal email or a Liquid expression like `{{Data.Account.WorkEmail__c}}`."
        },
        { "$field": "parameters.email.from", "purpose": "Sender email.", "source": "dropdown-static", "options_source": "appinstance.emails (or free-text when SMTP enabled, `_email.html.erb` L64-74).", "controller_permit": "parameters.email.* (opaque)", "model_validation": "Required unless email.preview_only is 'true'; must be in appinstance.emails when SMTP not enabled (email.rb L57-61).", "default": "\"workflow@zuora.com\"" },
        { "$field": "parameters.email.name", "purpose": "Display name for the sender.", "source": "user-input-or-Liquid", "controller_permit": "parameters.email.*" },
        { "$field": "parameters.email.reply_to", "purpose": "Reply-To address.", "source": "user-input-or-Liquid", "controller_permit": "parameters.email.*", "model_validation": "Required when org isn't Zuora and from == 'workflow@zuora.com'." },
        { "$field": "parameters.email.cc / parameters.email.bcc", "purpose": "Carbon-copy addresses.", "source": "multi-select-with-tags", "controller_permit": "parameters.email.*", "default": "[]" },
        { "$field": "parameters.email.return_path", "purpose": "Return-Path address (must end in one of appinstance.emails domains).", "source": "user-input", "controller_permit": "parameters.email.*", "model_validation": "return_path_domain_valid? at validation (L21-23)." },
        { "$field": "parameters.email.subject", "purpose": "Email subject. Liquid evaluated.", "source": "user-input", "controller_permit": "parameters.email.*", "model_validation": "Required." },
        { "$field": "parameters.email.template", "purpose": "HTML email body. Liquid evaluated.", "source": "code-editor (HTML or richtext)", "controller_permit": "parameters.email.*", "model_validation": "Required.", "notes": "When the template contains <script>/<iframe> the rich-text editor is force-disabled (email.rb L41-48)." },
        { "$field": "parameters.email.preview_only", "purpose": "When 'true', render the email and store it in logs but do not send.", "source": "boolean-string checkbox", "controller_permit": "parameters.email.*", "default": "\"true\" (in the UI)" },
        { "$field": "parameters.email.disable_editor", "purpose": "When 'true', use the raw HTML editor instead of summernote rich-text.", "source": "boolean-string checkbox", "controller_permit": "parameters.email.*", "default": "\"false\"" },
        { "$field": "parameters.email.attachments.invoices", "purpose": "When 'true', attach Invoice PDFs from Data.Invoice[].Id to the email.", "source": "boolean-string checkbox", "controller_permit": "parameters.email.*", "model_validation": "UI disables this checkbox unless Data.Invoice[].Id is in the parent data_structure (`_email.html.erb` L177-189).", "default": "\"false\"" },
        { "$field": "parameters.email.attachments.file_ids", "purpose": "Array of Zuora file Ids to attach (Liquid expressions allowed).", "source": "multi-select-with-tags", "controller_permit": "parameters.email.*", "default": "[]" },
        { "$field": "parameters.files", "purpose": "Workflow Files entries from upstream tasks to attach.", "source": "form-array", "controller_permit": "parameters.email.*", "options_source": "f.object.prev_operation.data_structure['Files'] (`_email.html.erb` L205-209).", "default": "{}" },
        { "$field": "parameters.notification_history_enabled / notification_history_account_id / notification_history_object_id", "purpose": "Optional Customer Notification History stream attached to the email.", "source": "boolean-string + user-input", "controller_permit": "parameters.* (opaque)", "model_validation": "account_id required when enabled (email.rb L26).", "default": "false" },
        { "$field": "headers_attributes (top-level on the form)", "purpose": "Custom email headers (X-*).", "source": "form-array", "controller_permit": "headers_attributes: %i(key value) (tasks_controller.rb L797)", "default": "[]", "notes": "Top-level on the form, but in import JSON the canonical place is parameters.headers_attributes." }
      ],
      "$see_controller_permit": "tasks_controller.rb L795-799. The branch only enumerates `headers_attributes`; everything inside `parameters` is merged in raw, so the contract enforcement above comes from the UI partial + email.rb."
    },
    "notes": "email.from is required unless email.preview_only is 'true'. Liquid template should reference Data scopes: {{Data.Account.Name}}, {{Data.Invoice.Number}}, etc."
  },

  "Export": {
    "description": "Export a Zuora object via SOAP export (bulk CSV).",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Export",
      "parameters": {
        "fields": { "<<REQUIRED: ObjectName>>": { "<<REQUIRED: FieldName>>": "true" } },
        "where_clause": "",
        "delay": 0,
        "zip": "true",
        "encrypt": "false",
        "zero_result_stop": "false",
        "strict_variables": "true"
      },
      "action_type": "Export",
      "object": "<<REQUIRED: Zuora object name, e.g. Invoice>>",
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 5,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields"],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["zip", "encrypt", "zero_result_stop", "strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "scope_template": "Data.Files.{file_holder_name}",
      "writes": [
        { "to_template": "Data.Files.{file_holder_name}", "shape": "File", "fields": "{name, task_id, object_class, ...}", "notes": "Export streams CSV results to file storage; downstream Iterate(object=<file_holder_name>) processes the rows. The selected fields parameters.fields[<object>][] become the CSV headers and per-row keys when iterated. Inside that Iterate body, Data.<object>.<Field> is field-level resolvable from parameters.fields." },
        { "to_template": "Data.Export.<object>", "shape": "object", "fields": "{file_holder_name, file_id, ...}", "notes": "Export also stores a small reference object at Data.Export[self.object] (see Tasks::Export#task_process self.new_data['Export'][self.object])." }
      ],
      "reads": [
        { "from_param": "where_clause", "shape": "liquid" }
      ],
      "opaque": false,
      "fields_partial_known": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_export.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash, so per-key constraints come from the UI partial and the model's task_setup_validation, not strong-params.",
      "$model": "app/models/tasks/export.rb",
      "$model_validation": "validates :object, presence: true (L2). task_setup_validation (L4-15) requires at least one selected field and runs export_test against live tenant (L9, L169-194) -- skipped on Task.import. Optional `delay` must be <= 60s (L13).",
      "$task_process_use": "Builds the Export query via `url` (L218-226): \"Select <selected_fields> From <object> [Where <parsed_where_clause>]\". Selected aggregations (Min/Max/Count/Sum/Average) wrap the column. parameters.fields shape is {<Object>: {<FieldName>: \"true\" | \"Min\" | \"Max\" | \"Count\" | \"Sum\" | \"Average\"}}.",
      "fields": [
        {
          "$field": "object",
          "purpose": "Zuora SOAP-exportable object name (e.g. Invoice, Account, Subscription).",
          "source": "describe-call",
          "options_source": "@appinstance.describe_helper(...).keys filtered by export support (see _object_id_type.html.erb L9-12 + _export.html.erb L22). Composer should call mcp__zuora-mcp describe before populating.",
          "controller_permit": "object (top-level via common_params)",
          "model_validation": "validates :object, presence: true (export.rb L2)",
          "default": null
        },
        {
          "$field": "parameters.fields",
          "purpose": "Per-object map of selected fields and their aggregation/output mode.",
          "source": "describe-call",
          "options_source": "@appinstance.describe_helper(object: object, entity_id: f.object.entity_id, log_failures: false) filtered to entries where v[:selectable] == 'true' AND v[:context].include?('export') (export.html.erb L22). Related objects via describe_helper :related_objects (L24).",
          "controller_permit": "parameters: any-hash (fall-through branch)",
          "model_validation": "task_setup_validation L6-7: at least one (Object,Field) pair must be present.",
          "default": "{}",
          "notes": "Value per field is one of \"true\" | \"Min\" | \"Max\" | \"Count\" | \"Sum\" | \"Average\". Sum/Average only valid for numeric fields (decimal/integer per export.html.erb L59). Composer must NEVER invent field names -- always derive from describe."
        },
        {
          "$field": "parameters.where_clause",
          "purpose": "Export ZOQL filter appended after `Where`. Liquid-evaluated.",
          "source": "code-editor",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Tested implicitly via export_test SOAP call when saving from the UI; not validated on import.",
          "default": "",
          "notes": "Use Export ZOQL syntax (joins via Object.Field). Liquid in `{{Data.X.Y}}` interpolates at runtime. Never include `Where` keyword -- export.rb L222 prepends it."
        },
        {
          "$field": "parameters.delay",
          "purpose": "Seconds to wait after enqueue before issuing the SOAP create -- gives Zuora data-source replication time to catch up.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "task_setup_validation L12-14: must be <= 60. UI input is `number_field min:0 max:60` (export.html.erb L192).",
          "default": "0 (60 on persisted records when blank, see L191)",
          "notes": "Persist as integer or numeric string. task_process L42 sleeps `parameters.fetch(\"delay\", \"60\").to_i` seconds."
        },
        {
          "$field": "parameters.encrypt",
          "purpose": "When 'true', request a Zuora encrypted export.",
          "source": "boolean-checkbox",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Persisted as the literal STRING \"true\"/\"false\" (boolean_string_params).",
          "default": "\"false\""
        },
        {
          "$field": "parameters.zip",
          "purpose": "When 'true', request a zipped CSV export.",
          "source": "boolean-checkbox",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Boolean string. file_holder_name uses `*.csv.zip` when true and `*.csv` when false (export.rb L229).",
          "default": "\"true\""
        },
        {
          "$field": "parameters.zero_result_stop",
          "purpose": "When 'true', stop downstream tasks if the Export returned 0 rows.",
          "source": "boolean-checkbox",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Boolean string. task_process L141 `return if self.parameters['zero_result_stop'].to_bool && export_data['Size'].to_i == 0`.",
          "default": "\"false\""
        }
      ]
    },
    "notes": "At least one field selected for the configured object. Each selected field value must be the STRING 'true'. Server-side query_test / export_test hits the live tenant at save time but is skipped on import (validate: false)."
  },

  "Query": {
    "description": "Query a Zuora object via SOAP Query (max 2000 records).",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Query",
      "parameters": {
        "fields": { "<<REQUIRED: ObjectName>>": { "<<REQUIRED: FieldName>>": "true" } },
        "where_clause": "",
        "placement": "",
        "zero_query_proceed": "true",
        "strict_variables": "true"
      },
      "action_type": "Query",
      "object": "<<REQUIRED: Zuora object name, e.g. Account>>",
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 5,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields"],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["zero_query_proceed", "strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "<self.object>",
      "scope_template": "Data.{parameters.placement | self.object}",
      "writes": [
        { "to_template": "Data.{parameters.placement | self.object}", "shape": "Array<Hash>", "fields": "from_param:fields[<object>]", "notes": "Each Hash row contains the keys selected in parameters.fields[<object>]. Inside an Iterate(object=<placement|object>) For-Each branch, Data.<placement|object> rebinds to a single Hash (one row)." }
      ],
      "reads": [
        { "from_param": "where_clause", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_query.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash, so per-key constraints come from the UI partial and the model's task_setup_validation, not strong-params.",
      "$model": "app/models/tasks/query.rb",
      "$model_validation": "validates :object, presence: true (L2). task_setup_validation (L4-9) requires at least one selected field. SOAP query is implicitly tested at save via query_test (skipped on import).",
      "$task_process_use": "Builds the SOAP query as \"Select <fields> From <object> [Where <parsed_where_clause>]\" (L33). Limits to batch_size <= 2000 (L29 max(2000, parameters.batch_size)). Each row becomes a Hash keyed by the selected field names; placement defaults to self.object.",
      "fields": [
        {
          "$field": "object",
          "purpose": "Zuora SOAP-queryable object name.",
          "source": "describe-call",
          "options_source": "@appinstance.describe_helper(...).keys filtered by query support (see _object_id_type.html.erb L9-11 + _query.html.erb L18-19). Composer should run mcp__zuora-mcp describe before populating.",
          "controller_permit": "object (top-level via common_params)",
          "model_validation": "validates :object, presence: true (query.rb L2)",
          "default": null
        },
        {
          "$field": "parameters.fields",
          "purpose": "Per-object selection of returned columns.",
          "source": "describe-call",
          "options_source": "@appinstance.describe_helper(object: object, entity_id: f.object.entity_id) filtered to entries where v[:selectable] == 'true' AND v[:context].include?('select') (query.html.erb L18). Related objects via :related_objects (L20).",
          "controller_permit": "parameters: any-hash (fall-through branch)",
          "model_validation": "task_setup_validation L5-7: at least one (Object,Field) pair required.",
          "default": "{}",
          "notes": "Value per field is the literal STRING \"true\". Composer must NEVER invent field names -- always derive from the describe response."
        },
        {
          "$field": "parameters.where_clause",
          "purpose": "ZOQL WHERE clause appended after `Where`. Liquid-evaluated.",
          "source": "code-editor",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Tested implicitly via query_test at save (live tenant); skipped on import.",
          "default": "",
          "notes": "Use Query ZOQL (joins via Object.Field). Liquid `{{Data.X.Y}}` interpolates at runtime. Do NOT include the keyword `Where` -- query.rb L33 prepends it."
        },
        {
          "$field": "parameters.placement",
          "purpose": "Override the Data scope name (default: self.object).",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Free-form string (no allowlist). Becomes Data.<placement> Array<Hash>.",
          "default": "\"\" (falls back to self.object via payload_location L88-90)",
          "notes": "Use when two Query tasks target the same object and you need to disambiguate. Downstream Liquid must reference Data.<placement>. Iterate.object should match this value."
        },
        {
          "$field": "parameters.batch_size",
          "purpose": "Cap on rows returned by the SOAP query.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Coerced to int and capped at 2000 in query.rb L29 (`[2000, parameters['batch_size'].to_i].max`). Persisted as integer or numeric string.",
          "default": "2000",
          "notes": "For more than 2000 rows use Export instead of Query."
        },
        {
          "$field": "parameters.zero_query_proceed",
          "purpose": "When 'true', allow downstream tasks to run even if Query returned 0 rows; when 'false', stop the workflow.",
          "source": "boolean-checkbox",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Boolean string. Default in template is \"true\".",
          "default": "\"true\""
        }
      ]
    },
    "notes": "Results stored into Data.<object> (or Data.<placement> if placement parameter is set). For more than 2000 records use Export or GraphQuery."
  },

  "GraphQuery": {
    "description": "Query Zuora data via the GraphQL API with variables.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "GraphQuery",
      "parameters": {
        "query": "<<REQUIRED: GraphQL query string>>",
        "variables": "{}",
        "baseObject": "<<REQUIRED: base object name>>",
        "placement": "",
        "strict_variables": "true"
      },
      "action_type": "GraphQuery",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["query", "variables", "baseObject"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "<parameters.baseObject>",
      "scope_template": "Data.{parameters.placement | parameters.baseObject}",
      "writes": [
        { "to_template": "Data.{parameters.placement | parameters.baseObject}", "shape": "Array<Hash>", "fields": "from_param:query (the GraphQL selection set)", "notes": "Shape is the parsed GraphQL response under data.<baseObject>. Linter does not parse GraphQL; treats fields as 'best-effort' opaque inside the placement bucket." }
      ],
      "reads": [
        { "from_param": "query", "shape": "liquid" },
        { "from_param": "variables", "shape": "liquid" }
      ],
      "opaque": false,
      "fields_partial_known": true
    },
    "notes": "query is a GraphQL string; variables is a JSON-encoded string (not an object)."
  },

  "Iterate": {
    "description": "Iterate over a data collection; emit a 'For Each' linkage per row.",
    "hooks": ["For Each", "Complete", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Iterate",
      "parameters": {
        "file_type": "CSV",
        "skip_trailer": "false",
        "generate_auto_headers": "false",
        "fetched_data_is_array": "false",
        "strict_variables": "true"
      },
      "action_type": "Iterate",
      "object": "<<REQUIRED: Data key to iterate over, e.g. Invoice, or CUSTOM LIQUID>>",
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 150,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object"],
    "param_enums": {
      "file_type": ["CSV", "FIXED"],
      "iteration_type": ["Default", "Unique-Field"]
    },
    "boolean_string_params": ["skip_headers", "skip_trailer", "generate_auto_headers", "fetched_data_is_array", "strict_variables"],
    "data_contract": {
      "predictability": "scoping",
      "writes": [],
      "reads": [
        { "from_param": "object", "shape": "string", "notes": "Must be the name of an Array<Hash> or File key already present in Data, OR the literal 'CUSTOM LIQUID' (with parameters.liquid_statement)." },
        { "from_param": "liquid_statement", "shape": "liquid" }
      ],
      "rebinds": "Inside the For Each linkage branch, Data.{object} rebinds to a single Hash (one row) for the duration of that branch. After Complete, downstream tasks see the original Array binding (or the merged result of inner-loop writes).",
      "opaque": false,
      "iteration_unwrap": "single-row"
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_iterate.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash. The `object` column is permitted via common_params at top level.",
      "$model": "app/models/tasks/iterate.rb",
      "$model_validation": "validates :object, presence: true (L4). task_setup_validation -> validate_object (L99-194) classifies the object as :liquid | :flash | :query | :export | :billing_preview | :custom_file | :file_operations | :report | :zuora_import | :other | :fix_length | :json_file based on what produced it (parent task action_type and object_structure entry). Errors on missing parent producer, unknown scope, or wrong shape.",
      "$task_process_use": "Iterate creates one For-Each linkage execution per row. iterate.rb L196+ dispatches by mode: :liquid uses parameters.liquid_statement; :file_operations / :export / :custom_file stream the underlying file (parameters.skip_headers, csv_header_filter, generate_auto_headers, etc.); :query / :other walk the parent's Array<Hash>. data_structure (L605-695) overrides downstream visibility so Data.{object} appears as a single Hash to inner tasks.",
      "fields": [
        {
          "$field": "object",
          "purpose": "Source of the iteration: a Data scope name from a parent task, the file_holder_name produced by an upstream Export / File task, or the literal 'CUSTOM LIQUID'.",
          "source": "dropdown-from-parent-task",
          "options_source": "iterate.html.erb L18 -- `f.select :object, options_for_select(f.object.objects, ...)`. iterate.rb#objects (L740-841) walks the parent task's data_structure entries plus any Files entries, and prepends 'CUSTOM LIQUID'. Composer must therefore pick from the parent task's writes and not invent a name.",
          "controller_permit": "object (top-level via common_params)",
          "model_validation": "validate_object (iterate.rb L99-194)",
          "default": null,
          "notes": "After an Export, the dropdown shows the file_holder_name like `Invoice__1.csv.zip`, NOT just `Invoice`. Both are valid `object` values but route to different code paths in task_process; if you want row-level Liquid you almost always pick the file form."
        },
        {
          "$field": "parameters.iteration_type",
          "purpose": "How to enumerate the source.",
          "source": "dropdown-static",
          "options_source": "iterate.html.erb L43-49: ['Default', 'Unique-Field'].",
          "controller_permit": "parameters: any-hash (fall-through branch)",
          "model_validation": "Free-form string read by iterate.rb#unique_field_iteration? (L545-547).",
          "default": "\"Default\"",
          "notes": "Unique-Field requires parameters.iteration_field; Iterate emits one branch per distinct value of that field across the parent rows."
        },
        {
          "$field": "parameters.iteration_field",
          "purpose": "When iteration_type is Unique-Field, the column name whose distinct values drive the loop.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required only when iteration_type == 'Unique-Field'.",
          "default": null,
          "visible_when": "iteration_type == 'Unique-Field'"
        },
        {
          "$field": "parameters.chunk_size",
          "purpose": "Number of rows per For-Each branch (chunked iteration).",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Capped at appinstance.limits['chunking'] (default 2000) inside iterate.rb. UI input is `number_field min:1 max:appinstance.limits['chunking']`.",
          "default": "1",
          "notes": "When >1, the branch sees Data.{object} as Array<Hash> of `chunk_size` rows instead of a single Hash."
        },
        {
          "$field": "parameters.liquid_statement",
          "purpose": "When object == 'CUSTOM LIQUID', the Liquid expression that produces the iteration source.",
          "source": "code-editor",
          "controller_permit": "parameters: any-hash",
          "model_validation": "task_setup_validation parses the Liquid (raises if unparseable). Must yield JSON Array.",
          "default": null,
          "required_when": "object == 'CUSTOM LIQUID'"
        },
        {
          "$field": "parameters.file_type",
          "purpose": "When iterating a file, the file format.",
          "source": "dropdown-static",
          "options_source": "['CSV', 'FIXED'] (iterate.html.erb L101).",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Read by iterate.rb#file_iteration? branches.",
          "default": "\"CSV\"",
          "visible_when": "object resolves to a Files entry (e.g. *.csv.zip from Export, *.csv from File::FileOperations)"
        },
        {
          "$field": "parameters.skip_headers",
          "purpose": "Skip the first N lines of the file as headers (CSV).",
          "source": "boolean-checkbox-or-int",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Boolean-string semantics in template (\"true\"/\"false\"); but iterate.rb also accepts an integer count.",
          "default": "\"false\""
        },
        {
          "$field": "parameters.skip_trailer",
          "purpose": "Skip a fixed-width trailer line at end of file.",
          "source": "boolean-checkbox",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Boolean string.",
          "default": "\"false\""
        },
        {
          "$field": "parameters.csv_header_filter",
          "purpose": "Comma-separated list of CSV columns to keep; others are dropped before iteration.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Free-form string.",
          "default": null,
          "visible_when": "file_type == 'CSV'"
        },
        {
          "$field": "parameters.generate_auto_headers",
          "purpose": "When 'true', synthesize column headers (col_1, col_2, ...) for header-less CSV.",
          "source": "boolean-checkbox",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Boolean string.",
          "default": "\"false\"",
          "visible_when": "file_type == 'CSV'"
        },
        {
          "$field": "parameters.encoding",
          "purpose": "File encoding for FIXED width files.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "default": "\"UTF-8\"",
          "visible_when": "file_type == 'FIXED'"
        },
        {
          "$field": "parameters.skip_from_beginning",
          "purpose": "Number of lines to skip from the top of a FIXED width file.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "default": "0",
          "visible_when": "file_type == 'FIXED'"
        },
        {
          "$field": "parameters.skip_from_bottom",
          "purpose": "Number of lines to skip from the bottom of a FIXED width file.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "default": "0",
          "visible_when": "file_type == 'FIXED'"
        },
        {
          "$field": "parameters.input_constants_form",
          "purpose": "Field-name + offset + length definitions for FIXED width parsing.",
          "source": "form-array",
          "controller_permit": "parameters: any-hash (UI submits as a nested array)",
          "model_validation": "iterate.rb fix_length parser reads each entry's start, length, name.",
          "default": "[]",
          "required_when": "file_type == 'FIXED'",
          "visible_when": "file_type == 'FIXED'"
        },
        {
          "$field": "parameters.fetched_data_is_array",
          "purpose": "When 'true', the iterated source is already an Array (e.g. parameters.liquid_statement returns JSON Array directly, or the file payload is a JSON Array). When 'false' (default), the model parses the source row-by-row.",
          "source": "boolean-checkbox",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Boolean string. Read by iterate.rb when classifying the iteration source.",
          "default": "\"false\""
        }
      ]
    },
    "notes": "object can be a Data key name (e.g., 'Invoice' reads Data.Invoice[]), a file label from Files, or the literal 'CUSTOM LIQUID' with parameters.liquid_statement set. Hook 'Complete' fires once after all iterations finish; NOT 'Iterate' (that is a scope name only - see linter typo hint)."
  },

  "If": {
    "description": "Branch on a Liquid-evaluated boolean expression; emit True or False linkage.",
    "hooks": ["True", "False", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "If",
      "parameters": {
        "if_clause": "<<REQUIRED: Liquid expression evaluating to 'true' or 'false'>>",
        "strict_variables": "true",
        "disable_validation": "false"
      },
      "action_type": "If",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["if_clause"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables", "disable_validation"],
    "data_contract": {
      "predictability": "scoping",
      "writes": [],
      "reads": [
        { "from_param": "if_clause", "shape": "liquid" }
      ],
      "routing_only": true,
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_if.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `disable_validation` and `strict_variables` ride on common_params.",
      "$model": "app/models/tasks/if.rb",
      "$model_validation": "task_setup_validation (L2-10) parses parameters.if_clause via Liquid (validate: true) and surfaces any parse errors. Skipped on Task.import (validate: false).",
      "$task_process_use": "task_process (L24-38) evaluates if_clause via template_parse(strict: true), normalizes whitespace and lowercases the result, and routes to either the True or False linkage. Anything other than the strings 'true' / 'false' raises a runtime error. The hooks block (L20-22) declares only True / False / Failure -- no Success hook.",
      "fields": [
        {
          "$field": "parameters.if_clause",
          "purpose": "Liquid expression that must evaluate (after lowercasing and stripping) to either 'true' or 'false'.",
          "source": "code-editor",
          "options_source": "Free-form Liquid; UI placeholder is `{% if 0 > 1 %} True {% else %} False {% endif %}`.",
          "controller_permit": "parameters: any-hash (fall-through branch)",
          "model_validation": "Parsed via template_parse(strict: true); raises at task_setup_validation when the Liquid is malformed.",
          "default": "{% if 0 > 1 %}\\nTrue\\n{% else %}\\nFalse\\n{% endif %} (UI default)",
          "notes": "The model lowercases and strips before comparing, so 'True' / 'FALSE' / 'true\\n' all work. Any other value raises a WorkflowError at runtime. Linter flags `If` tasks with `Success` linkages (only True/False/Failure are valid)."
        },
        {
          "$field": "parameters.disable_validation",
          "purpose": "Skip the save-time Liquid parse check (use when the Liquid depends on runtime data not visible at design time).",
          "source": "boolean-string checkbox",
          "controller_permit": "disable_validation (top-level via common_params)",
          "default": "\"false\""
        }
      ]
    },
    "notes": "Example if_clause: '{% if Data.Invoice.Balance > 0 %}true{% else %}false{% endif %}'. Must evaluate to the lowercase string 'true' or 'false' at run time. task_setup_validation parses the Liquid (may raise) but is skipped on import with validate: false."
  },

  "Logic::Case": {
    "description": "Multi-way branch: evaluate a Liquid clause and route to the matching Case_N hook.",
    "hooks": ["Case_1", "Case_2", "Case_Else", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Case",
      "parameters": {
        "case_clause": "<<REQUIRED: Liquid expression whose string value is matched against case_condition values>>",
        "case_condition": {
          "Case_1": "<<REQUIRED: match value for branch 1>>",
          "Case_2": "<<REQUIRED: match value for branch 2>>"
        },
        "disable_regex": "false",
        "strict_variables": "true",
        "disable_validation": "false"
      },
      "action_type": "Logic::Case",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["case_clause", "case_condition"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["disable_regex", "strict_variables", "disable_validation"],
    "data_contract": {
      "predictability": "scoping",
      "writes": [],
      "reads": [
        { "from_param": "case_clause", "shape": "liquid" }
      ],
      "routing_only": true,
      "opaque": false,
      "branch_partitioning": "Each Case_N hook produces an independent downstream subgraph. The linter (W174) flags references to scopes only produced inside one branch from a task that's downstream of a different (or all/Else) branch."
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_case.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `disable_validation` / `strict_variables` ride on common_params.",
      "$model": "app/models/tasks/logic/case.rb",
      "$model_validation": "task_setup_validation (L6-22): case_clause required; Liquid must parse cleanly; case_condition required and every value non-blank. before_save :validate_labels (L51-74) RENUMBERS the case_condition keys to Case_1, Case_2, ... in stable order AND destroys any source_linkage whose linkage_type doesn't map to the new keys -- so hand-edited gaps (e.g. Case_3 with no Case_2) are silently rewritten.",
      "$task_process_use": "task_process (L32-45) parses case_clause via Liquid (strict), then iterates parameters.case_condition in insertion order. Match rule (L37-43): when disable_regex is true, equality compare; otherwise the case value is treated as a regex anchored with `\\A...\\z`. The first matching key wins; if nothing matches, Case_Else fires. hooks (L47-49) advertise one hook per Case_N plus Case_Else + Failure.",
      "fields": [
        { "$field": "parameters.case_clause", "purpose": "Liquid expression whose stripped string value is matched against case_condition values.", "source": "code-editor", "controller_permit": "parameters: any-hash (fall-through)", "model_validation": "Required; parsed via template_parse(strict: true).", "default": "{{Data.Account.Currency}} (UI default)" },
        { "$field": "parameters.case_condition", "purpose": "Ordered map of `Case_N -> match value` (regex by default).", "source": "form-array (UI submits as parameters.case_condition[Case_N] = value)", "controller_permit": "parameters: any-hash", "model_validation": "Required; every value non-blank. before_save :validate_labels renumbers keys to Case_1..Case_N in stable order (logic/case.rb L51-74).", "default": "{}", "notes": "Keys MUST be `Case_1`, `Case_2`, ... (sequential, no gaps). Linker E111/E112 flag mis-numbered keys; Rails would silently renumber and destroy mismatched linkages." },
        { "$field": "parameters.disable_regex", "purpose": "When 'true', match each case value as a literal string instead of a regex.", "source": "boolean-string checkbox", "controller_permit": "parameters: any-hash", "default": "\"false\"" },
        { "$field": "parameters.disable_validation", "purpose": "Skip the save-time Liquid parse check.", "source": "boolean-string checkbox", "controller_permit": "disable_validation (top-level via common_params)", "default": "\"false\"" }
      ]
    },
    "notes": "ALWAYS pre-normalize case_condition keys to sequential Case_1, Case_2, ..., Case_Else. Rails Logic::Case#before_save :validate_labels renumbers the keys and DESTROYS any linkage whose linkage_type does not map to the new keys (app/models/tasks/logic/case.rb:51-73). Emit linkages with linkage_type matching case_condition keys plus optional Case_Else and Failure."
  },

  "Logic::Liquid": {
    "description": "Execute a Liquid template to transform or augment workflow Data.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Liquid",
      "parameters": {
        "code": "<<REQUIRED: Liquid code block>>",
        "placement": "",
        "strict_variables": "true"
      },
      "action_type": "Logic::Liquid",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["code"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Liquid",
      "scope_template": "Data.Liquid (or Data.Liquid.{parameters.placement})",
      "writes": [
        { "to_template": "Data.Liquid.<assigned_var>", "shape": "Hash", "fields": "LIQUID_SCOPE", "notes": "Every {% assign foo = ... %} or {% capture foo %} in parameters.code becomes Data.Liquid.foo for downstream tasks. The linter scans parameters.code for assign/capture tags to infer the available keys." }
      ],
      "reads": [
        { "from_param": "code", "shape": "liquid" }
      ],
      "opaque": false,
      "fields_partial_known": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_liquid.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `disable_validation` / `strict_variables` ride on common_params.",
      "$model": "app/models/tasks/logic/liquid.rb",
      "$model_validation": "No task_setup_validation defined -- the Liquid template is NOT parsed at save time. Syntax errors surface at runtime when task_process executes the template.",
      "$task_process_use": "task_process (L10-13) calls template_parse(item: parameters.code), which evaluates the Liquid against the standard Liquid scopes (Data, Credentials.zuora, WorkflowInstance, WorkflowSetup, TaskInstance, GlobalConstants). Any {% assign foo = ... %} / {% capture foo %} writes appear under Data.Liquid (or Data.Liquid.<placement> when parameters.placement is set, since template_parse merges the rendered scope into Data via the placement key). Then iterate_tasks(linkage_type: 'Success').",
      "fields": [
        { "$field": "parameters.code", "purpose": "Liquid template body. Use {% assign %} / {% capture %} to publish keys under Data.Liquid for downstream tasks.", "source": "code-editor", "controller_permit": "parameters: any-hash (fall-through)", "model_validation": "None at save time -- runtime errors only.", "default": "(empty)", "notes": "Only top-level {% assign %} and {% capture %} survive into Data.Liquid; values rendered with {{ ... }} but not assigned are discarded. Linter scans this field to populate the W173 catalogue of producible scopes." },
        { "$field": "parameters.placement", "purpose": "Override the destination key under Data.Liquid (or under Data when used with the standard placement convention).", "source": "user-input (text)", "controller_permit": "parameters: any-hash", "default": "\"\" (results land at Data.Liquid)", "notes": "Optional. The W170 family enforces unique placement values across sibling Liquid tasks." },
        { "$field": "parameters.disable_validation", "purpose": "No-op for Logic::Liquid because no save-time validation runs; included for API symmetry.", "source": "boolean-string checkbox", "controller_permit": "disable_validation (top-level via common_params)", "default": "\"false\"" }
      ]
    },
    "notes": "Result placement: if parameters.placement is set, output goes to Data.Liquid.<placement>; otherwise Data.Liquid. Liquid syntax errors are caught at RUN time, not import - task_setup_validation does not parse the Liquid."
  },

  "Logic::Merge": {
    "description": "Merge multiple branches back into a single path after a For-Each/Case fan-out.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Merge",
      "parameters": {
        "strict_variables": "true"
      },
      "action_type": "Logic::Merge",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "scoping",
      "writes": [
        { "to_template": "<union of inbound branches' writes>", "shape": "merged", "fields": "from upstream branches", "notes": "merge_data_from_branches deep-merges Workflow/Files/Liquid keys; concatenates arrays; mixes other clashing values into arrays. Linter takes set-union of predecessor available_data on the Success linkage." }
      ],
      "reads": [],
      "opaque": false,
      "branch_partitioning": "Logic::Merge consolidates available scopes from all inbound branches; downstream tasks see the UNION."
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_merge.html.erb (intentionally empty -- no parameter fields exposed)",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- only common_params (name, description, error_handler, on_error, etc.) and an opaque `parameters` hash.",
      "$model": "app/models/tasks/logic/merge.rb",
      "$model_validation": "task_setup_validation (L10-13) runs avoid_for_each_linkage_before_merge_task (linkage.rb L72-122) and validate_merge_start_task (DFS over the graph that rewrites merge_start / merge_paths on every reachable task). Both are SAVE-time only. The validator REJECTS the workflow if any 'For Each' linkage sits on any path from start to this Merge task.",
      "$task_process_use": "task_process (L19-32): waits until merge_finished? returns true (all incoming branches have terminated), then calls merge_data_from_branches to deep-merge predecessor `data` hashes into self.new_data (Workflow/Files/Liquid keys are merged; arrays concatenated; collisions widened into arrays). Finally iterate_tasks(linkage_type: 'Success').",
      "fields": [
        { "$field": "parameters", "purpose": "Logic::Merge has NO user-configurable parameters; the partial is intentionally blank. Only the workflow graph topology controls behaviour.", "source": "n/a", "controller_permit": "parameters: any-hash (fall-through, but normally empty)", "model_validation": "None on parameters; structural validations on linkages.", "default": "{} or {\"strict_variables\": \"true\"}" }
      ]
    },
    "notes": "CONSTRAINT: no 'For Each' linkage may appear on any directed path from the workflow start to this Merge task - mirrors Linkage#avoid_for_each_linkage_before_merge_task (app/models/linkage.rb:72-122). Merge task's own validate_merge_start_task does DFS and rewrites merge_start/merge_paths on other tasks at save time - server handles this, do not attempt to populate client-side."
  },

  "Logic::Lambda": {
    "description": "Invoke an AWS Lambda function synchronously.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Lambda",
      "parameters": {
        "LambdaFunction": "<<REQUIRED: ARN or FunctionName, or provide FunctionName+Handler+Runtime>>",
        "payload": "{}",
        "strict_variables": "true"
      },
      "action_type": "Logic::Lambda",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["LambdaFunction"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "Lambda",
      "scope_template": "Data.Lambda",
      "writes": [
        { "to_template": "Data.Lambda", "shape": "object", "fields": "OPAQUE", "notes": "Whatever the Lambda function returns. Hard-coded key 'Lambda' (no placement parameter)." }
      ],
      "reads": [
        { "from_param": "payload", "shape": "liquid" }
      ],
      "opaque": true,
      "opaque_prompt_required": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_lambda.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an opaque hash plus uploaded files via the multipart form. Common params (name, description, error_handler, on_error, files, etc.) ride on common_params.",
      "$model": "app/models/tasks/logic/lambda.rb",
      "$model_validation": "task_setup_validation (L19-28) splits two modes: (1) when parameters.LambdaFunction is present -> require LambdaFunction non-blank; (2) otherwise (upload-new-function mode) -> require FunctionName, Handler, Runtime, plus at least one attached file. after_validation :upload_function (L4) packages the attached zip and uploads it to AWS Lambda when no LambdaFunction key is present and validation passed.",
      "$task_process_use": "task_process (L42-onward) builds an invocation payload {EventType, AppInstance, Data, Credentials.zuora, TaskInstance, WorkflowInstance, ...}, then invokes AWS Lambda with the function name `WF-<owner>-<LambdaFunction>` (lambda_function_name L38-40). Async vs sync mode: SYNC default uses EXECUTION_TIMEOUT_SYNC=240; the task self-suspends with transient ExecutingLambda flag and is resumed by the Lambda callback. The response is written verbatim to Data.Lambda; downstream tasks must use opaque references.",
      "fields": [
        { "$field": "parameters.LambdaFunction", "purpose": "Name (suffix after `WF-<owner>-`) of an EXISTING Lambda function deployed for this tenant. When set, the task skips upload mode.", "source": "dropdown-from-aws (UI lists deployed functions for this tenant prefix)", "options_source": "AWS::Lambda list_functions filtered by `WF-<owner>-` prefix", "required_when": "parameters does NOT include FunctionName / Handler / Runtime / files", "controller_permit": "parameters: any-hash (fall-through)", "model_validation": "Required non-blank when LambdaFunction key is present (lambda.rb L21).", "default": "(none)", "notes": "Mutually exclusive with FunctionName/Handler/Runtime upload mode -- pick one path." },
        { "$field": "parameters.FunctionName", "purpose": "Name to register a NEW Lambda function (uploaded from the attached zip).", "source": "user-input (text)", "required_when": "parameters.LambdaFunction is BLANK", "controller_permit": "parameters: any-hash", "model_validation": "Required when not using existing function (lambda.rb L23).", "notes": "After upload, the function lives at `WF-<owner>-<FunctionName>`; reuse via parameters.LambdaFunction afterwards." },
        { "$field": "parameters.Handler", "purpose": "Lambda handler entry point (e.g. `index.handler`).", "source": "user-input (text)", "required_when": "parameters.LambdaFunction is BLANK", "controller_permit": "parameters: any-hash", "model_validation": "Required when not using existing function (lambda.rb L24)." },
        { "$field": "parameters.Runtime", "purpose": "Lambda runtime identifier (`nodejs18.x`, `python3.11`, `java17`, `ruby3.2`, etc.).", "source": "dropdown-static", "options_source": "AWS Lambda supported runtimes", "required_when": "parameters.LambdaFunction is BLANK", "controller_permit": "parameters: any-hash", "model_validation": "Required when not using existing function (lambda.rb L25)." },
        { "$field": "parameters.Memory", "purpose": "Memory (MB) allocated to the Lambda invocation.", "source": "user-input (integer)", "controller_permit": "parameters: any-hash", "model_validation": "Bounded by AWS limits (UI suggests <= 512 for synchronous tasks).", "default": "128" },
        { "$field": "parameters.TimeOut", "purpose": "Synchronous execution timeout (seconds). Hard-capped by Logic::Lambda::EXECUTION_TIMEOUT_SYNC = 240.", "source": "user-input (integer)", "controller_permit": "parameters: any-hash", "default": "60" },
        { "$field": "parameters.TimeOutAsync", "purpose": "Async execution timeout (seconds). Hard-capped by Logic::Lambda::EXECUTION_TIMEOUT_ASYNC = 600.", "source": "user-input (integer)", "controller_permit": "parameters: any-hash", "default": "300" },
        { "$field": "parameters.Description", "purpose": "Free-text description forwarded to AWS Lambda when uploading.", "source": "user-input (text)", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.env_var", "purpose": "Array of {env_key, env_val} pairs forwarded as Lambda environment variables.", "source": "form-array", "controller_permit": "parameters: any-hash", "default": "[]" },
        { "$field": "files", "purpose": "Uploaded zip containing the Lambda function source. Required only in upload mode.", "source": "file-upload (top-level field on Task)", "required_when": "parameters.LambdaFunction is BLANK", "controller_permit": "files (top-level via common_params)", "model_validation": "files must be present when uploading new function (lambda.rb L26)." },
        { "$field": "parameters.payload", "purpose": "Liquid-evaluated string (commonly JSON) sent as the Lambda invocation event body.", "source": "code-editor (Liquid + JSON)", "controller_permit": "parameters: any-hash", "default": "\"{}\"", "notes": "Evaluated through Liquid so you can inject {{Data....}} values into the request payload." }
      ]
    }
  },

  "Logic::CSVTranslator": {
    "description": "Operate on an upstream CSV file: Filter rows by column value, convert to JSON or XML, or merge multiple CSVs that share a header row.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CSVTranslator",
      "parameters": {
        "action": "<<REQUIRED: one of Filter|to_json|xml|merge>>",
        "strict_variables": "true"
      },
      "action_type": "Logic::CSVTranslator",
      "object": "<<REQUIRED for Filter/to_json/xml: upstream Files holder name>>",
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["action"],
    "required_at_import": [],
    "param_enums": {
      "action": ["Filter", "to_json", "xml", "merge"],
      "OutputFormat": ["JSON", "CSV", "CSV.ZIP"]
    },
    "boolean_string_params": ["strict_variables", "filter_regex", "replace_payload", "zero_result_stop"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "CSVTranslator",
      "scope_template": "Data.{parameters.placement | 'CSVTranslator'} (to_json) OR Data.Files.<source_basename>.{csv|csv.zip} (Filter) OR Data.Files.<source_basename>.xml (xml) OR Data.Files.<csv_filename | 'MergedCSV'>.csv (merge)",
      "writes": [
        { "to_template": "Data.{parameters.placement | 'CSVTranslator'}", "shape": "Hash|Array<Hash>", "fields": "OPAQUE (depends on filtered row shape and source classification)", "when": "parameters.action == 'to_json'", "notes": "to_json mode rewrites the in-memory Data scope; for Zuora-data sources (Export/Data::Link/Reporting::RunReport) it preserves new_fields per object. parameters.replace_payload overwrites instead of merging." },
        { "to_template": "Data.Files.<source_base>.<csv|csv.zip>", "shape": "file-handle", "when": "parameters.action == 'Filter' AND parameters.OutputFormat in ['CSV','CSV.ZIP']", "notes": "Filter writes the surviving rows back to a file holder under Files; OutputFormat=='CSV' uploads the unzipped csv, 'CSV.ZIP' wraps it as `CSVTranslator__<id>.csv.zip`." },
        { "to_template": "Data.Files.<source_base>.xml", "shape": "file-handle", "when": "parameters.action == 'xml'", "notes": "Each row becomes a `<record>` under `<records>`; uploads as `.xml`." },
        { "to_template": "Data.Files.{parameters.csv_filename | 'MergedCSV'}.csv", "shape": "file-handle", "when": "parameters.action == 'merge'", "notes": "Concatenates two or more selected CSVs that share an identical header row; rejects mismatched headers." }
      ],
      "reads": [
        { "from_param": "object", "shape": "file-name", "when": "parameters.action in ['Filter','to_json','xml']" },
        { "from_param": "Column|column_number", "shape": "string|int", "when": "parameters.action == 'Filter'" },
        { "from_param": "Value", "shape": "liquid", "when": "parameters.action == 'Filter'" },
        { "from_param": "files", "shape": "object", "when": "parameters.action == 'merge'" }
      ],
      "opaque": true,
      "opaque_prompt_required": true,
      "fields_partial_known": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_csv_translator.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `object` rides on common_params; `parameters.files` is the merge selection map.",
      "$model": "app/models/tasks/logic/csv_translator.rb (extends Task::CSVHandler).",
      "$model_validation": "validates :object, presence: true when action in ACTIONS_WITH_OBJECT (Filter|xml|to_json) (L8). task_setup_validation (L10-26): action required; for Filter -- exactly one of Column / column_number must be present (XOR), column_number must be a positive int, Value and OutputFormat required. For merge -- at least 2 entries in parameters.files must have selected='true' (L23-25).",
      "$task_process_use": "task_process (L48-87): for Filter/to_json/xml resolves the object file holder from Data.Files (rejecting `.json` files), unzips when needed, and dispatches to filter_handler / xml_handler / merge_handler. Filter+to_json reads CSV in-memory and either appends to the matching object array (Zuora data sources) or to Data.<placement> (other sources). xml_handler streams rows as `<record><col>val</col></record>` and uploads `.xml`. merge_handler concatenates files in order_index order, requiring identical headers.",
      "fields": [
        { "$field": "parameters.action", "purpose": "Selects which handler runs.", "source": "dropdown-static", "options_source": "['Filter', 'to_json', 'xml', 'merge'] (case-sensitive; UI labels may show 'Filter' / 'Convert to XML' / 'Convert to JSON' / 'Merge')", "controller_permit": "parameters: any-hash", "model_validation": "Required; Logic::CSVTranslator::ACTIONS_WITH_OBJECT == %w(Filter xml to_json) requires :object." },
        { "$field": "object", "purpose": "Upstream Files holder name (e.g. 'Account__123.csv' or 'Account__123.csv.zip').", "source": "dropdown-grouped (Files)", "options_source": "Workflow file fields (datatype File-Field) + parent_task.data_structure['Files']", "required_when": "parameters.action in ['Filter','to_json','xml']", "controller_permit": "object (top-level via common_params)", "model_validation": "Required when action in ACTIONS_WITH_OBJECT." },
        { "$field": "parameters.placement", "purpose": "Destination key under Data when action='to_json'.", "source": "user-input (text)", "visible_when": "parameters.action == 'to_json'", "controller_permit": "parameters: any-hash", "default": "\"CSVTranslator\"" },
        { "$field": "parameters.replace_payload", "purpose": "When 'true' (and action='to_json'), overwrite Data.<placement> rather than appending.", "source": "boolean-string", "visible_when": "parameters.action == 'to_json'", "controller_permit": "parameters: any-hash", "default": "\"false\"" },
        { "$field": "parameters.Column", "purpose": "Filter column NAME (CSV header). Mutually exclusive with column_number.", "source": "picklist (populated from source_task.new_fields when known) + tag input", "visible_when": "parameters.action == 'Filter'", "required_when": "parameters.action == 'Filter' AND parameters.column_number is blank", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.column_number", "purpose": "Filter column NUMBER (1-indexed). Mutually exclusive with Column.", "source": "user-input (integer)", "visible_when": "parameters.action == 'Filter'", "required_when": "parameters.action == 'Filter' AND parameters.Column is blank", "controller_permit": "parameters: any-hash", "model_validation": "Must be a positive integer (csv_translator.rb L16-17)." },
        { "$field": "parameters.Value", "purpose": "Filter match value (Liquid OK). Treated as a regex when filter_regex='true'.", "source": "user-input (text, Liquid)", "required_when": "parameters.action == 'Filter'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.filter_regex", "purpose": "When 'true', match Value as a regex; otherwise equality compare.", "source": "boolean-string", "visible_when": "parameters.action == 'Filter'", "controller_permit": "parameters: any-hash", "default": "\"false\"" },
        { "$field": "parameters.OutputFormat", "purpose": "Filter output medium: write JSON to Data.<placement>, or CSV/CSV.ZIP file holder.", "source": "dropdown-static", "options_source": "['JSON', 'CSV', 'CSV.ZIP']", "required_when": "parameters.action == 'Filter'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.csv_filename", "purpose": "Output filename (no extension) for merge mode.", "source": "user-input (text, Liquid)", "visible_when": "parameters.action == 'merge'", "controller_permit": "parameters: any-hash", "default": "\"MergedCSV\"" },
        { "$field": "parameters.files", "purpose": "Map of `{ <source_file>: { selected: 'true'|'false', order_index: N } }` controlling which Files participate in merge and in what order.", "source": "form-array (drag-and-drop in UI)", "visible_when": "parameters.action == 'merge'", "controller_permit": "parameters: any-hash", "model_validation": "At least 2 selected entries required for merge (csv_translator.rb L23-25)." },
        { "$field": "parameters.zero_result_stop", "purpose": "When 'true', stop downstream tasks if the resulting CSV/merged CSV has zero data rows.", "source": "boolean-string", "visible_when": "parameters.action == 'merge' OR parameters.OutputFormat in ['CSV','CSV.ZIP']", "controller_permit": "parameters: any-hash", "default": "\"false\"" }
      ]
    }
  },

  "Logic::XMLTransform": {
    "description": "Transform an upstream `.xml` file (or inline `XML Text`) via XSLT (`mode: to_xml`) or convert it back to JSON (`mode: to_json`). Required output extension comes from the `<xsl:output method=\"...\"/>` directive in the template.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "XMLTransform",
      "parameters": {
        "mode": "<<REQUIRED: 'to_xml' for XSLT, 'to_json' to read XML into Data.XMLTransform>>",
        "template": "<<REQUIRED when mode='to_xml': XSLT 1.0/2.0 stylesheet with <xsl:output method='xml|csv|txt|html'/>>",
        "filename": "",
        "include_tag_attr": "false",
        "strict_variables": "true"
      },
      "action_type": "Logic::XMLTransform",
      "object": "<<REQUIRED: file holder name from upstream Files (e.g. 'Export__123.xml') OR the literal 'XML Text'>>",
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["mode"],
    "required_at_import": ["object"],
    "param_enums": {
      "mode": ["to_xml", "to_json"]
    },
    "boolean_string_params": ["strict_variables", "include_tag_attr"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "XMLTransform",
      "scope_template": "Data.XMLTransform (mode='to_json') OR Data.Files.<filename>.<output> (mode='to_xml')",
      "writes": [
        { "to_template": "Data.XMLTransform", "shape": "Hash", "fields": "OPAQUE", "when": "parameters.mode == 'to_json'", "notes": "Hash.from_xml result (or ActiveSupport::XMLConverterWithAttributes when include_tag_attr is true). Field shape mirrors the input XML." },
        { "to_template": "Data.Files.XMLTransform__<task_id>.<extension>", "shape": "file-handle", "when": "parameters.mode == 'to_xml'", "notes": "Output extension is parsed from the XSLT (<xsl:output method='xml|csv|txt|html'/>); xsd_input optionally validates xml output. file_holder_name advertises the holder to data_structure / Files." }
      ],
      "reads": [
        { "from_param": "object", "shape": "file-name|literal:'XML Text'" },
        { "from_param": "template", "shape": "xslt", "when": "parameters.mode == 'to_xml'" },
        { "from_param": "xml_text", "shape": "liquid+xml", "when": "object == 'XML Text' AND mode == 'to_json'" },
        { "from_param": "xsd_input", "shape": "xsd", "when": "parameters.mode == 'to_xml' AND output_format == 'xml'" }
      ],
      "opaque": true,
      "opaque_prompt_required": true,
      "fields_partial_known": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_xml_transform.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `object` is permitted via common_params.",
      "$model": "app/models/tasks/logic/xml_transform.rb",
      "$model_validation": "validates :object, presence: true (L2). task_setup_validation (L6-10) requires parameters.template AND derives output_format from <xsl:output method='...'/>; rejects when format is missing or not in [csv, xml, txt, html] (skipped when template is blank by surfacing :missing_template).",
      "$task_process_use": "task_process (L28-118): when object='XML Text' use parameters.xml_text directly; otherwise pull the named XML file from Data.Files (must have .xml extension and be <100 MB). For mode='to_json' parse via Hash.from_xml or ActiveSupport::XMLConverterWithAttributes (when include_tag_attr) and write_data to Data.XMLTransform. For mode='to_xml' apply XSLT via Nokogiri::XSLT, optionally validate against xsd_input, and upload_file the transformed output (filename defaults to `<source>_xslt`).",
      "fields": [
        { "$field": "object", "purpose": "Source XML. Either an upstream file holder name (e.g. 'Export__123.xml', shown via Files dropdown) OR the literal string 'XML Text' to use the inline xml_text textarea.", "source": "dropdown-grouped (Files | Custom['XML Text'])", "options_source": "Workflow file fields (datatype File-Field) + parent_task.data_structure['Files'] filtered to /__\\d*\\.(xml)$/", "controller_permit": "object (top-level via common_params)", "model_validation": "Required (xml_transform.rb L2)." },
        { "$field": "parameters.mode", "purpose": "'to_xml' runs XSLT and uploads a transformed file; 'to_json' parses the source XML into a Hash and writes to Data.XMLTransform.", "source": "dropdown-static", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.template", "purpose": "XSLT template (1.0 or 2.0). Must include `<xsl:output method='xml|csv|txt|html'/>` so output_format can be derived.", "source": "code-editor (CodeMirror, XML mode)", "required_when": "parameters.mode == 'to_xml'", "controller_permit": "parameters: any-hash", "model_validation": "Required (xml_transform.rb L7); the embedded `<xsl:output method=...>` must yield csv/xml/txt/html (L8-9)." },
        { "$field": "parameters.xsd_input", "purpose": "Optional XSD schema; validated against the transformed XML output.", "source": "code-editor", "visible_when": "parameters.mode == 'to_xml' AND output_format == 'xml'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.xml_text", "purpose": "Inline XML payload used when object == 'XML Text' and mode == 'to_json'.", "source": "code-editor (CodeMirror, XML mode)", "visible_when": "object == 'XML Text'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.filename", "purpose": "Output file base name (without extension) when mode='to_xml'. Defaults to `<source>_xslt`.", "source": "user-input (text)", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.include_tag_attr", "purpose": "When 'true' and mode='to_json', use ActiveSupport::XMLConverterWithAttributes so XML attributes survive into the parsed Hash.", "source": "boolean-string", "controller_permit": "parameters: any-hash", "default": "\"false\"" }
      ]
    }
  },

  "Logic::JSONTransform": {
    "description": "Transform workflow data via one of four processors: JSONata, Liquid (returning JSON), XML (write a JSON-derived XML file), or CSV (write a JSON-array-derived CSV file).",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "JSONTransform",
      "parameters": {
        "processor": "<<REQUIRED: one of JSONata|liquid|xml|csv>>",
        "placement": "JSONTransform",
        "template": "<<REQUIRED for JSONata/liquid: the JSONata expression or Liquid template>>",
        "strict_variables": "true"
      },
      "action_type": "Logic::JSONTransform",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["processor"],
    "required_at_import": [],
    "param_enums": {
      "processor": ["JSONata", "liquid", "xml", "csv"],
      "version": ["1.8.1", "1.8.6", "2.0.2", "1.0", "2.0"]
    },
    "boolean_string_params": ["strict_variables", "replace_payload"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "JSONTransform",
      "scope_template": "Data.{parameters.placement | 'JSONTransform'} (JSONata/liquid) OR Data.Files.JSONTransform__<task_id>.<csv|xml> (csv/xml)",
      "writes": [
        { "to_template": "Data.{parameters.placement | 'JSONTransform'}", "shape": "object", "fields": "OPAQUE", "notes": "JSONata or Liquid+JSON output. Depends entirely on the template; set parameters.placement to make the destination self-documenting. parameters.replace_payload overwrites instead of merging." },
        { "to_template": "Data.Files.JSONTransform__<task_id>.csv", "shape": "file-handle", "when": "parameters.processor == 'csv'", "notes": "csv processor uploads a CSV file derived from parameters.liquid_statement (must render a JSON array of flat objects); upstream tasks reference it via the file holder." },
        { "to_template": "Data.Files.<base>.xml", "shape": "file-handle", "when": "parameters.processor == 'xml'", "notes": "xml processor reads parameters.data_source (Liquid path or `data` dig-path), serializes via Hash#to_xml (v2.0) or Nokogiri::XML::Builder (v1.0), uploads as a `.xml` file. RootElement is the wrapper tag." }
      ],
      "reads": [
        { "from_param": "template", "shape": "liquid", "when": "parameters.processor in ['JSONata','liquid']" },
        { "from_param": "data_source", "shape": "liquid|data-path", "when": "parameters.processor == 'xml'" },
        { "from_param": "liquid_statement", "shape": "liquid", "when": "parameters.processor == 'csv'" }
      ],
      "opaque": true,
      "opaque_prompt_required": true,
      "fields_partial_known": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_json_transform.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash.",
      "$model": "app/models/tasks/logic/json_transform.rb (extends NodeWorkerTask).",
      "$model_validation": "task_setup_validation (L6-23): for processor='JSONata', parameters.template required. For processor='csv', csv_filename and liquid_statement required AND -- if csv_headers is provided -- it must template_parse to valid JSON.",
      "$task_process_use": "task_process (L37-56) dispatches on parameters.processor: jsonata_handler (Node sidecar w/ template + version, write_data placement), liquid_handler (template_parse -> JSON.parse -> write_data placement), xml_handler (resolve data_source via Liquid/dig path, build XML via Nokogiri/Hash#to_xml, upload as `.xml`), csv_handler (parse liquid_statement to JSON array, validate items are flat hashes, write CSV with optional csv_headers mapping, upload).",
      "fields": [
        { "$field": "parameters.processor", "purpose": "Selects which handler runs.", "source": "dropdown-static", "options_source": "['JSONata', 'liquid', 'xml', 'csv'] (NOTE: the literal label values are JSONata|liquid|xml|csv; case matters)", "controller_permit": "parameters: any-hash (fall-through)", "model_validation": "Required at runtime; unknown value raises a WorkflowError (json_transform.rb L48).", "default": "(none)" },
        { "$field": "parameters.template", "purpose": "JSONata expression (when processor=JSONata) OR Liquid template that emits a JSON document (when processor=liquid).", "source": "code-editor", "required_when": "parameters.processor in ['JSONata','liquid']", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.placement", "purpose": "Destination key under Data for JSONata/liquid output.", "source": "user-input (text)", "visible_when": "parameters.processor in ['JSONata','liquid']", "controller_permit": "parameters: any-hash", "default": "\"JSONTransform\"" },
        { "$field": "parameters.replace_payload", "purpose": "When 'true', overwrite Data.<placement>; otherwise deep-merge.", "source": "boolean-string", "visible_when": "parameters.processor in ['JSONata','liquid']", "controller_permit": "parameters: any-hash", "default": "\"false\"" },
        { "$field": "parameters.version", "purpose": "Processor version. JSONata: '1.8.1'|'1.8.6'|'2.0.2' (default '1.8.1'). XML: '1.0'|'2.0' (default '2.0').", "source": "dropdown-static", "visible_when": "parameters.processor in ['JSONata','xml']", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.data_source", "purpose": "Where to find the JSON input for the XML processor. Either a Liquid expression like `{{ Data.X | to_json }}` OR a dot path like `Data.Account.Subscriptions[0]`.", "source": "code-editor", "required_when": "parameters.processor == 'xml'", "controller_permit": "parameters: any-hash", "default": "\"Data\"" },
        { "$field": "parameters.RootElement", "purpose": "Wrapping XML root tag (XML v1.0 mode requires this when input has multiple top-level nodes; v2.0 always passes it to Hash#to_xml).", "source": "user-input (text)", "visible_when": "parameters.processor == 'xml'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.csv_filename", "purpose": "Base name (without extension) of the CSV file to produce.", "source": "user-input (text)", "required_when": "parameters.processor == 'csv'", "controller_permit": "parameters: any-hash", "model_validation": "Required (json_transform.rb L12). Defaults to 'JSONTransform' at runtime if blank.", "default": "\"JSONTransform\"" },
        { "$field": "parameters.liquid_statement", "purpose": "Liquid expression that must render a JSON array of flat objects (e.g. `{{ Data.Account | to_json }}`).", "source": "code-editor", "required_when": "parameters.processor == 'csv'", "controller_permit": "parameters: any-hash", "model_validation": "Required (json_transform.rb L13). At runtime, items with nested arrays/hashes are rejected." },
        { "$field": "parameters.csv_headers", "purpose": "Optional JSON object mapping `{ \"item_key\": \"Header Label\" }`. Determines column order and labels; when omitted the first row's keys are used.", "source": "code-editor (JSON)", "visible_when": "parameters.processor == 'csv'", "controller_permit": "parameters: any-hash", "model_validation": "When present, must template_parse to valid JSON (json_transform.rb L17-21)." }
      ]
    }
  },

  "Logic::ResponseFormatter": {
    "description": "Terminating SYNC task that builds an HTTP response payload (Liquid or JSONata) and signals the parent workflow via Redis pubsub. Only valid in UI / SYNC / SYNC_UI_ACTION / REALTIME / DATASTREAM workflows.",
    "hooks": ["Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "ResponseFormatter",
      "parameters": {
        "processor": "Liquid",
        "code": "200",
        "template": "<<REQUIRED: Liquid template OR JSONata expression that renders the response body>>",
        "parent_workflow_status": "Success",
        "strict_variables": "true"
      },
      "action_type": "Logic::ResponseFormatter",
      "object": null,
      "object_id": null,
      "call_type": "SYNC",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["processor", "code", "template"],
    "required_at_import": [],
    "param_enums": {
      "processor": ["Liquid", "JSONata"],
      "parent_workflow_status": ["Success", "Error"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "ResponseFormatter",
      "scope_template": "Data.ResponseFormatter (HARD-CODED key, no placement)",
      "writes": [
        { "to_template": "Data.ResponseFormatter", "shape": "object", "fields": "OPAQUE", "notes": "Liquid mode: JSON.parse(template_parse(template)). JSONata mode: result of POST /transform on the Node sidecar. Field shape depends entirely on the template; no placement override." }
      ],
      "reads": [
        { "from_param": "template", "shape": "liquid|jsonata" }
      ],
      "opaque": true,
      "opaque_prompt_required": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/logic/_response_formatter.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash.",
      "$model": "app/models/tasks/logic/response_formatter.rb",
      "$model_validation": "task_setup_validation (L6-17): code (response status code), template, and processor all required. When workflow.ui_mode_workflow is true, EVERY task in workflow.all_tasks must report task_mode['SYNC'] == true; if any task lacks SYNC support, surface :incompatible_mode.",
      "$task_process_use": "task_process (L31-56): for processor='Liquid', JSON.parse(template_parse(template)) and merge as Data.ResponseFormatter. For processor='JSONata', POST {data, template} to the Node sidecar `/transform` and merge the response['result'] as Data.ResponseFormatter. Then mark Task::SUCCESS, save without validation, and Redis.publish('Sync:<appinstance_id>:<original_workflow_id>') -- the parent workflow listens for this notification and returns the formatted response. Hooks (L27-29) advertise ONLY Failure -- this task DOES NOT iterate downstream tasks; it terminates the SYNC branch.",
      "fields": [
        { "$field": "parameters.processor", "purpose": "Render engine.", "source": "dropdown-static", "options_source": "[['Liquid','Liquid'], ['JSONata','JSONata']] (case-sensitive)", "controller_permit": "parameters: any-hash", "model_validation": "Required (response_formatter.rb L9).", "default": "\"Liquid\"" },
        { "$field": "parameters.code", "purpose": "HTTP response status code (1-599). Sent back to the SYNC caller.", "source": "user-input (number)", "controller_permit": "parameters: any-hash", "model_validation": "Required (response_formatter.rb L7).", "default": "200" },
        { "$field": "parameters.parent_workflow_status", "purpose": "Whether the parent workflow should be marked Success or Error after publishing the response.", "source": "dropdown-static", "options_source": "['Success', 'Error']", "controller_permit": "parameters: any-hash", "default": "\"Success\"" },
        { "$field": "parameters.template", "purpose": "Liquid template (must render valid JSON) or JSONata expression that yields the response body.", "source": "code-editor (CodeMirror, JS mode)", "controller_permit": "parameters: any-hash", "model_validation": "Required (response_formatter.rb L8).", "notes": "Liquid mode: JSON.ParserError surfaces as 'The response is not a valid JSON' (L37). JSONata mode is executed in the Node sidecar and may return arbitrary JSON shapes." }
      ]
    },
    "notes": "Only usable in SYNC-mode workflows (UI, UIACTION, REALTIME, SYNC_UI_ACTION, DATASTREAM). Hooks declare ONLY Failure -- this task is terminal and never iterates a Success linkage."
  },

  "Script::JavaScript": {
    "description": "Execute a Node.js script block. The exported `step(input)` function receives the workflow Data + WorkflowInstance / WorkflowSetup / TaskInstance / GlobalConstants and returns the value written to Data.{placement | 'Script'}.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "JavaScript",
      "parameters": {
        "code": "<<REQUIRED: JavaScript code (must export a step(input) function)>>",
        "placement": "Script",
        "strict_variables": "true"
      },
      "action_type": "Script::JavaScript",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["code"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables", "replace_payload"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "Script",
      "scope_template": "Data.{parameters.placement | 'Script'}",
      "writes": [
        { "to_template": "Data.{parameters.placement | 'Script'}", "shape": "object", "fields": "OPAQUE", "notes": "Whatever the exported step() function returns. Default placement is 'Script' (UI-defaulted in the placement text input). parameters.replace_payload (if 'true') overwrites the existing scope rather than merging." }
      ],
      "reads": [
        { "from_param": "code", "shape": "javascript", "notes": "JS code can read Data.* via the injected `input` argument plus WorkflowInstance/WorkflowSetup/TaskInstance/GlobalConstants." }
      ],
      "opaque": true,
      "opaque_prompt_required": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/script/_java_script.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash.",
      "$model": "app/models/tasks/script/java_script.rb (extends NodeWorkerTask).",
      "$model_validation": "No save-time validation. task_process raises a WorkflowError if parameters.code is blank at runtime (java_script.rb L25-30).",
      "$task_process_use": "task_process (L24-58): builds node_options = {code, timeout, custom_data: {WorkflowInstance, WorkflowSetup, TaskInstance, GlobalConstants}}, invokes the Node sidecar via get_node_response, then write_data(object_name: self.placement, object_data: output, overwrite: parameters.replace_payload). Timeout precedence: appinstance.extra_settings.js_timeout -> parameters.timeout -> 20s default (L32-34).",
      "fields": [
        { "$field": "parameters.code", "purpose": "Node.js source. Must export a function via `exports.step = function(input) { ... }`; the return value lands at Data.<placement>.", "source": "code-editor (CodeMirror, JS mode)", "controller_permit": "parameters: any-hash", "model_validation": "Required at runtime (java_script.rb L25).", "default": "exports.step = function test(input) {\\n  var executionDate = input.Workflow.ExecutionDate;\\n  return { executionDate };\\n};" },
        { "$field": "parameters.placement", "purpose": "Destination key under Data; default 'Script'.", "source": "user-input (text)", "controller_permit": "parameters: any-hash", "default": "\"Script\"" },
        { "$field": "parameters.replace_payload", "purpose": "When 'true', overwrite Data.<placement>; otherwise deep-merge (write_data default).", "source": "boolean-string", "controller_permit": "parameters: any-hash", "default": "\"false\"" },
        { "$field": "parameters.timeout", "purpose": "Per-task Node execution timeout (seconds). Used only when appinstance.extra_settings.js_timeout is unset.", "source": "user-input (integer)", "controller_permit": "parameters: any-hash", "default": "20" }
      ]
    }
  },

  "Create": {
    "description": "Create a Zuora object via SOAP Create.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Create",
      "parameters": {
        "fields": { "<<REQUIRED: ObjectName>>": { "<<REQUIRED: FieldName>>": "<<REQUIRED: value>>" } },
        "strict_variables": "true"
      },
      "action_type": "Create",
      "object": "<<REQUIRED: Zuora object name>>",
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields"],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "<self.object>",
      "scope_template": "Data.{self.object}",
      "writes": [
        { "to_template": "Data.{self.object}", "shape": "Hash", "fields": "from_param:fields[<object>] + 'Id' (returned by Zuora)", "notes": "The created record (single Hash) is written back to Data.<object>. If the field map under fields[<object>] is provided, downstream tasks can statically reference any of those fields plus Id." }
      ],
      "reads": [
        { "from_param": "fields[*][*]", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_create.html.erb (object dropdown via shared partials/_object_id_type.html.erb)",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `object` is permitted via common_params.",
      "$model": "app/models/tasks/create.rb",
      "$model_validation": "validates :object, presence: true (L2). task_setup_validation (L4-10) requires at least one (Object,Field) pair under parameters.fields[<object>].",
      "$task_process_use": "task_process L12-37 emits a SOAP `create` with each (key, value) under parameters.fields[<object>] -- value is Liquid-evaluated. Returned record is merged via write_data into Data.<object> (Hash) plus the auto-assigned Id. data_structure (L39-43) advertises the new field set + Id to downstream tasks.",
      "fields": [
        {
          "$field": "object",
          "purpose": "Zuora SOAP-creatable object name.",
          "source": "describe-call",
          "options_source": "@appinstance.describe_helper(...).keys filtered by `_object_id_type.html.erb` L9-14 (Create excludes Subscription/Invoice/InvoiceItem/TaxationItem/RatePlanCharge/RatePlan/ProductRatePlanChargeTier/JournalRun/RatePlanChargeTier/Order/OrderAction/ProcessedUsage).",
          "controller_permit": "object (top-level via common_params)",
          "model_validation": "validates :object, presence: true",
          "default": null,
          "notes": "Composer must run describe before guessing field names; the partial then loads describe_helper a second time to populate the field dropdown."
        },
        {
          "$field": "parameters.fields",
          "purpose": "Map of field name -> value (Liquid) to send in the SOAP create body.",
          "source": "describe-call (selectable list) + user-input (per-field value)",
          "options_source": "describe_helper(object: f.object.object).select { |_, v| v[:context].include?('soap') } (`_create.html.erb` L7) -- field key choices are limited to those entries. Fields whose v[:createable] is true are auto-prefilled with required entries (L29-31).",
          "controller_permit": "parameters: any-hash (fall-through)",
          "model_validation": "task_setup_validation requires `parameters.fields[self.object]` to have at least 1 entry (create.rb L4-9).",
          "default": "{}",
          "notes": "Shape is `{<Object>: {<FieldName>: <value or Liquid>}}`. Values are NOT booleans like Query/Export; they are the literal value to write. The UI surface warns when a chosen field is not advertised as createable but still allows it."
        }
      ]
    }
  },

  "Update": {
    "description": "Update a Zuora object via SOAP Update.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Update",
      "parameters": {
        "fields": { "<<REQUIRED: ObjectName>>": { "<<REQUIRED: FieldName>>": "<<REQUIRED: value>>" } },
        "strict_variables": "true"
      },
      "action_type": "Update",
      "object": "<<REQUIRED: Zuora object name>>",
      "object_id": "<<REQUIRED: Zuora object id or Liquid reference>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields"],
    "required_at_import": ["object", "object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "<self.object>",
      "scope_template": "Data.{self.object}",
      "writes": [
        { "to_template": "Data.{self.object}", "shape": "Hash", "fields": "from_param:fields[<object>]", "notes": "Updated record is merged back into Data.<object>. When inside Iterate(object=<object>), the per-row Hash is updated in place at the iterated index." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" },
        { "from_param": "fields[*][*]", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_update.html.erb (object/object_id via shared partials/_object_id_type.html.erb)",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `object` and `object_id` permitted via common_params.",
      "$model": "app/models/tasks/update.rb",
      "$model_validation": "validates :object, presence: true (L2). validates :object_id, presence: true (L3). task_setup_validation (L9-15) requires at least one (Object,Field) pair under parameters.fields[<object>]. object_id_parsed (L144-146) requires a 32-char Id at runtime.",
      "$task_process_use": "task_process (L29-36) chooses single_object vs multiple_objects based on whether object_id contains the literal `[*]`. multiple_objects (L38-107) iterates over Data.<iterated_object>[] and issues one SOAP create per row, persisting `success_objects` in transient_fields for resume. Both paths write_data the updated Hash back to Data.<object>.",
      "fields": [
        { "$field": "object", "purpose": "Zuora SOAP-updateable object name.", "source": "describe-call", "options_source": "describe_helper(...).keys filtered by `_object_id_type.html.erb` L9-12.", "controller_permit": "object (top-level via common_params)", "model_validation": "validates :object, presence: true", "default": null },
        { "$field": "object_id", "purpose": "Id (32 chars) of the record to update; supports the iteration marker `[*]` to fan out across an upstream Array<Hash>.", "source": "user-input-or-liquid", "options_source": "accessible_payload dropdown (`_object_id_type.html.erb` L26).", "controller_permit": "object_id (top-level via common_params)", "model_validation": "validates :object_id, presence: true; runtime requires Id length 32.", "default": null, "notes": "When object_id contains `[*]`, the model walks Data.<iterated_object>[] and substitutes the index per call." },
        { "$field": "parameters.fields", "purpose": "Map of field name -> new value (Liquid) to write.", "source": "describe-call (selectable list) + user-input (per-field value)", "options_source": "describe_helper(object:).select { |_, v| v[:updateable] == 'true' && v[:context].include?('soap') } (`_update.html.erb` L7).", "controller_permit": "parameters: any-hash (fall-through)", "model_validation": "task_setup_validation requires at least 1 (Object,Field) pair (update.rb L11-13).", "default": "{}", "notes": "Special key `fieldsToNull` (Array<String>) tells Zuora to null those fields. Other entries are key -> value (or Liquid)." }
      ]
    },
    "notes": "object_id may be a Liquid reference (e.g., {{Data.Subscription.Id}}) or literal; supports [*] iteration notation for fan-out updates."
  },

  "Delete": {
    "description": "Delete a Zuora object via SOAP Delete.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Delete",
      "parameters": {
        "strict_variables": "true"
      },
      "action_type": "Delete",
      "object": "<<REQUIRED: Zuora object name>>",
      "object_id": "<<REQUIRED: Zuora object id or Liquid reference>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object", "object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "scoping",
      "writes": [
        { "to_template": "Data.{self.object}", "shape": "removed", "fields": "(may unbind the singular Data.<object> Hash if it matches the deleted Id)", "notes": "Delete does NOT add new Data. When Data.<object> is a singular Hash whose Id matches the deleted record, it is removed; otherwise no change. Linter treats Delete as no-op for downstream availability." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_delete.html.erb (renders shared partials/_object_id_type.html.erb)",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `object` and `object_id` columns are governed by common_params at top level.",
      "$model": "app/models/tasks/delete.rb",
      "$model_validation": "validates :object, presence: true (L2). validates :object_id, presence: true (L3). No task_setup_validation -- the SOAP delete is exercised at runtime, not save.",
      "$task_process_use": "task_process L9-37: parses object_id_parsed (Liquid), issues SOAP `delete` with [object_id_parsed], records the response and any per-row errors. No new keys are written to Data; on success, downstream tasks must NOT assume Data.<object> still exists.",
      "fields": [
        {
          "$field": "object",
          "purpose": "Zuora SOAP-deletable object name.",
          "source": "describe-call",
          "options_source": "@appinstance.describe_helper(...).keys filtered by partials/_object_id_type.html.erb L9-12 (Create/Query/Update/Delete share this list, minus OBJECTS_WITHOUT_SOAP_FIELD).",
          "controller_permit": "object (top-level via common_params)",
          "model_validation": "validates :object, presence: true (delete.rb L2)",
          "default": null
        },
        {
          "$field": "object_id",
          "purpose": "Id of the record to delete. Liquid expression supported.",
          "source": "user-input-or-liquid",
          "options_source": "UI shows a dropdown of `accessible_payload` Ids from upstream tasks (partials/_object_id_type.html.erb L26).",
          "controller_permit": "object_id (top-level via common_params)",
          "model_validation": "validates :object_id, presence: true (delete.rb L3)",
          "default": null,
          "notes": "If you reference {{Data.<object>.Id}} from an upstream Query/Export, the linter cross-checks the producer."
        }
      ]
    }
  },

  "Delay": {
    "description": "Pause workflow execution until a specific time or for a duration.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Delay",
      "parameters": {
        "delay_time": "<<REQUIRED: seconds as string OR absolute Time string OR Liquid>>",
        "blocking": "false",
        "strict_variables": "true"
      },
      "action_type": "Delay",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["delay_time"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["blocking", "strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "delay_time", "shape": "liquid" }
      ],
      "opaque": false,
      "routing_only": true,
      "$notes": "Pure pause - writes nothing to Data; just defers downstream task scheduling."
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_delay.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash.",
      "$model": "app/models/tasks/delay.rb",
      "$model_validation": "task_setup_validation (L2-14): delay_time required; if present, parsed via enqueue_time and rejected when the resolved future time is more than 30 days out OR when the format isn't recognized as either an all-digit seconds count or a Time-parseable string.",
      "$task_process_use": "task_process (L28-44): renders parameters.delay_time via Liquid, classifies it as either pure-digit seconds (Time.now + N) or Time.parse(...). If the future timestamp is within 10 seconds, it `sleep`s in place; otherwise it enqueues itself with restart_at = target_enqueue_time and raises PendingTask. transient_fields['delayed_executed'] is the resume marker.",
      "fields": [
        { "$field": "parameters.delay_time", "purpose": "Either an all-digit number of seconds (e.g. '300') OR an absolute time string (anything Time.parse accepts -- e.g. '2026-04-25 09:00 UTC'). Liquid is supported.", "source": "user-input (text)", "controller_permit": "parameters: any-hash", "model_validation": "Required; resolved value must be <= 30 days from now (delay.rb L5).", "default": "(none)", "notes": "An all-digit value is treated as a duration; anything else is treated as an absolute timestamp. The 30-day max is hard." },
        { "$field": "parameters.blocking", "purpose": "When 'true', a pending Delay task blocks any future scheduled run of this workflow definition from starting (per-definition queue lock).", "source": "boolean-string (hidden checkbox)", "controller_permit": "parameters: any-hash", "default": "\"false\"", "notes": "UI hides this control by default; set explicitly only when you need scheduled runs to queue behind a long-running Delay." }
      ]
    },
    "notes": "delay_time may be 'all-digit' seconds (max 30 days out) OR an ISO time string. Validated by enqueue_time at import time."
  },

  "Data::Aqua": {
    "description": "Export data via AQuA stateful/stateless job.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "AQuA",
      "parameters": {
        "fields": { "<<REQUIRED: ObjectName>>": { "<<REQUIRED: FieldName>>": "true" } },
        "where_clause": "",
        "strict_variables": "true"
      },
      "action_type": "Data::Aqua",
      "object": "<<REQUIRED: Zuora object>>",
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 5,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields"],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{aqua_zip_filename} (job archive)",
      "writes": [
        { "to_template": "Data.Files.{aqua_zip_filename}", "shape": "File", "fields": "Zip archive of CSV exports", "notes": "AQuA produces a downloaded ZIP file of exports placed under Data.Files.<filename>; the exact filename is computed from the AQuA job/object." }
      ],
      "reads": [
        { "from_param": "fields", "shape": "object" },
        { "from_param": "where_clause", "shape": "liquid" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "Data::BillingPreviewRun": {
    "description": "Run a billing preview for a set of accounts or subscriptions.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "BillingPreviewRun",
      "parameters": {
        "TargetDate": "<<REQUIRED: date or Liquid>>",
        "IncludingDraftItems": "false",
        "strict_variables": "true"
      },
      "action_type": "Data::BillingPreviewRun",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["TargetDate"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["IncludingDraftItems", "strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "BillingPreviewRun",
      "scope_template": "Data.BillingPreviewRun",
      "writes": [
        { "to_template": "Data.BillingPreviewRun", "shape": "Hash", "fields": "id, status, errorCode, errorMessage, accountIds, batches, targetDate, assumeRenewal, includingEvergreenSubscription, includingDraftItems, chargeTypeToExclude, createdById, createdDate, updatedById, updatedDate, batchSize, success", "notes": "Concrete fields enumerated in Data::BillingPreviewRun#data_structure." }
      ],
      "reads": [
        { "from_param": "TargetDate", "shape": "liquid" }
      ],
      "opaque": false
    }
  },

  "Data::Link": {
    "description": "Data Query task for SQL-style row queries and joins whose results can feed downstream Workflow tasks.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Link",
      "parameters": {
        "query": "<<REQUIRED: link query>>",
        "dsv_delimiter": ",",
        "strict_variables": "true"
      },
      "action_type": "Data::Link",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["query"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Link",
      "scope_template": "Data.{parameters.placement | 'Link'}",
      "writes": [
        { "to_template": "Data.{parameters.placement | 'Link'}", "shape": "Array<Hash>", "fields": "from_param:query (joined columns from the link expression)", "notes": "Result rows of the link query - field names are derived from the SELECT/JOIN columns in the query DSL. Linter cannot statically validate column names." }
      ],
      "reads": [
        { "from_param": "query", "shape": "liquid" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "UI::Page": {
    "description": "Render a UI page (for interactive workflows) with named routes.",
    "hooks": ["Success", "Failure"],
    "$hooks_dynamic": true,
    "$hooks_from": "parameters.route (each non-blank value becomes Page:<route>)",
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Page",
      "parameters": {
        "route": ["<<REQUIRED: route name 1>>"],
        "generate_code": "true",
        "parse_liquid": "true",
        "strict_variables": "true"
      },
      "action_type": "UI::Page",
      "object": null,
      "object_id": null,
      "call_type": "UI",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["route"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["generate_code", "parse_liquid", "strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "route", "shape": "string" }
      ],
      "opaque": false,
      "routing_only": true,
      "$notes": "UI::Page only routes execution along its named hooks (Page:<route>); no positive Data.* writes."
    }
  },

  "UI::Stop": {
    "description": "Halt a UI workflow at an explicit stop point.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Stop",
      "parameters": { "strict_variables": "true" },
      "action_type": "UI::Stop",
      "object": null,
      "object_id": null,
      "call_type": "UI",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [],
      "opaque": false,
      "routing_only": true,
      "$notes": "UI::Stop terminates the UI workflow at this point. No Data.* writes."
    }
  },

  "UI::WebShare": {
    "description": "Web-share action inside a UI workflow.",
    "hooks": ["Upload", "Timeout", "Success", "Failure"],
    "$hooks_dynamic": true,
    "$hooks_from": "parameters.route (each non-blank value becomes Webshare:<route>)",
    "template": {
      "id": "<<TASK_ID>>",
      "name": "WebShare",
      "parameters": {
        "route": [],
        "strict_variables": "true"
      },
      "action_type": "UI::WebShare",
      "object": null,
      "object_id": null,
      "call_type": "UI",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "route", "shape": "string" }
      ],
      "opaque": false,
      "routing_only": true,
      "$notes": "Web-share branches via Webshare:<route> hooks; no Data.* writes."
    }
  },

  "CustomObject::Create": {
    "description": "Create a Custom Object record. parameters.fields is a NESTED hash: parameters.fields.<self.object>.<FieldName> = <value>. Do NOT put fields flat under parameters.fields (Rails digs fields[self.object] only and treats a flat map as empty). object is `<namespace>__<object>` WITHOUT a trailing `__c` (e.g. default__Vendor, never default__Vendor__c — Rails rpartition('__') would parse object_name as 'c'). There is NO placement parameter; output always lands at Data.<self.object>. Field names MUST exist in the Custom Object schema.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CustomObjectCreate",
      "parameters": {
        "fields": {
          "<<self.object>>": {
            "<<REQUIRED: FieldName__c>>": "<<REQUIRED: value (Liquid OK)>>"
          }
        },
        "strict_variables": "true",
        "disable_validation": "false",
        "delete_payload_paths": []
      },
      "action_type": "CustomObject::Create",
      "object": "<<REQUIRED: Custom Object API name (e.g. default__Vendor — NOT default__Vendor__c)>>",
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields"],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables", "disable_validation"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "<self.object>",
      "scope_template": "Data.{self.object}",
      "writes": [
        { "to_template": "Data.{self.object}", "shape": "Hash", "fields": "from_param:fields.<self.object> + 'Id' + standard CO fields (CreatedDate, UpdatedDate, CreatedById, UpdatedById)", "notes": "Created Custom Object record returned by /objects/records POST. Always written under Data.<self.object> — Create has no placement/alternate_location. Downstream Liquid must use Data.<object>.Id (e.g. Data.default__Vendor.Id), not a task-name alias." }
      ],
      "reads": [
        { "from_param": "fields.<self.object>[*]", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/custom_object/_create.html.erb (renders shared `partials/object_id_type` for object selection and `partials/custom_object/_field_select.html.erb` for the field picker, then per-field `partials/_field.html.erb`)",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash. `object` rides on `common_params`.",
      "$model": "app/models/tasks/custom_object/create.rb (extends CustomObject::CustomObjectTask)",
      "$model_validation": "validates :object, presence: true (L2). task_setup_validation (L5-17): when object present, parameters.fields[<self.object>] must be non-empty AND every required custom field (those ending in __c that the schema marks `required: true`) must appear in the supplied keys; otherwise 'Missing fields: ...'. The schema is fetched via CustomObjectTask#get_schema -> ZuoraAPI describe.",
      "$task_process_use": "task_process (L19-57): for each (k, v) in parameters.fields[self.object], rejects unknown keys with WorkflowError; coerces v via template_parse + properties[k]['type'] (number=>to_f, integer=>to_i, boolean=>to_bool, else=>string). POST {records:[record]} to <base_url>/objects/records/<namespace>/<object_name>; on success write_data(object_name: object, object_data: record_returned) and iterate Success.",
      "$anti_patterns": "Do NOT use SOAP Create shape (flat parameters.fields). Do NOT append __c to object. Do NOT invent parameters.placement — Create always writes Data.<object>. Do NOT put the record id in parameters.id.",
      "fields": [
        { "$field": "object", "purpose": "Custom Object API name (`<namespace>__<object>`, e.g. `default__Vendor`). NEVER end with `__c` — that is a field suffix; Rails CustomObjectTask#object_name uses rpartition('__') so `default__Vendor__c` becomes object_name=`c`.", "source": "describe-call (Custom Object describe)", "options_source": "Thread.current[:appinstance].get_custom_objects(entity_id: get_entity_id) -> {namespace: {object: schema}}", "required_when": "always", "controller_permit": "object (top-level via common_params)", "model_validation": "Required (create.rb L2)." },
        { "$field": "parameters.fields", "purpose": "Nested hash keyed by `<self.object>` whose value is the {FieldName: value} map of fields to set on the new record.", "source": "describe-driven nested form (per-field partial inputs added by clicking the picker)", "options_source": "schema.properties (filtered to origin != 'system')", "required_when": "always", "controller_permit": "parameters: any-hash", "model_validation": "Non-empty AND all required __c fields present (create.rb L7-15).", "notes": "Top-level `parameters.fields[<FieldName>]` IS NOT supported -- the model dereferences `parameters['fields'][self.object][key]` directly. Use the exact `object` API name as the inner key. Linter E188 rejects flat field maps." }
      ]
    }
  },

  "CustomObject::Update": {
    "description": "Update a Custom Object record by UUID. parameters.fields is a NESTED hash: parameters.fields.<self.object>.<FieldName> = <value>. The record id is the TOP-LEVEL task attribute object_id (Liquid OK), NOT parameters.id. object is `<namespace>__<object>` WITHOUT a trailing `__c`. Idempotent.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CustomObjectUpdate",
      "parameters": {
        "fields": {
          "<<self.object>>": {
            "<<REQUIRED: FieldName__c>>": "<<REQUIRED: value (Liquid OK)>>"
          }
        },
        "strict_variables": "true",
        "disable_validation": "false",
        "delete_payload_paths": []
      },
      "action_type": "CustomObject::Update",
      "object": "<<REQUIRED: Custom Object API name (e.g. default__Vendor — NOT default__Vendor__c)>>",
      "object_id": "<<REQUIRED: Custom Object record UUID (Liquid OK; must resolve to canonical 8-4-4-4-12 UUID). Use Data.<object>.Id from an upstream CustomObject::Create — e.g. {{ Data.default__Vendor.Id }}>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields"],
    "required_at_import": ["object", "object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables", "disable_validation"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "<self.object>",
      "scope_template": "Data.{self.object}",
      "writes": [
        { "to_template": "Data.{self.object}", "shape": "Hash", "fields": "from_param:fields.<self.object> + 'Id'", "notes": "Updated Custom Object record. Field set = keys in parameters.fields[<self.object>] plus Id (templated from top-level object_id). Custom Objects do NOT echo back unmodified fields, so downstream tasks can only rely on the keys you actually sent." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" },
        { "from_param": "fields.<self.object>[*]", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/custom_object/_update.html.erb (renders `partials/object_id_type` with `id: true` and the same field picker partial as Create; `fieldsToNull` is special-cased to a multi-picklist).",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash. `object` and `object_id` ride on `common_params`.",
      "$model": "app/models/tasks/custom_object/update.rb (extends CustomObject::CustomObjectTask)",
      "$model_validation": "validates :object, presence: true (L2). validates :object_id, presence: true (L3). task_setup_validation (L7-13): parameters.fields[<self.object>] must be non-empty (else 'Please select at least 1 field...'). is_idempotent? returns true.",
      "$task_process_use": "task_process (L19-73): rejects when object blank or when any key in parameters.fields[<self.object>] is missing from schema.properties (raises WorkflowError - 'referencing fields which are not part of the Custom Object definition'). Coerces values per properties[k]['type']. template_parses object_id and matches against /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/; PATCH `/objects/records/<namespace>/<object_name>/<uuid>` with `Content-Type: application/merge-patch+json`. On success write_data(object: object, object_data: record.merge('Id'=>object_id)) and iterate Success.",
      "$anti_patterns": "Do NOT put the record id in parameters.id — it is top-level object_id. Do NOT use a flat parameters.fields map. Do NOT append __c to object.",
      "fields": [
        { "$field": "object", "purpose": "Custom Object API name (`<namespace>__<object>`). NEVER end with `__c`.", "source": "describe-call (Custom Object describe)", "options_source": "Thread.current[:appinstance].get_custom_objects(entity_id: get_entity_id)", "required_when": "always", "controller_permit": "object (top-level via common_params)", "model_validation": "Required (update.rb L2)." },
        { "$field": "object_id", "purpose": "UUID of the Custom Object record to update. Liquid expressions are template_parsed at runtime. TOP-LEVEL task attribute — not parameters.id.", "source": "user-input (text, Liquid OK)", "required_when": "always", "controller_permit": "object_id (top-level via common_params)", "model_validation": "Required (update.rb L3) AND must template_parse to a canonical UUID at runtime (raises WorkflowError otherwise)." },
        { "$field": "parameters.fields", "purpose": "Nested hash keyed by `<self.object>` whose value is the {FieldName: value} map of fields to update.", "source": "describe-driven nested form (per-field partial inputs)", "options_source": "schema.properties (filtered to origin != 'system'). `fieldsToNull` is forced to multi-picklist.", "required_when": "always", "controller_permit": "parameters: any-hash", "model_validation": "Non-empty (update.rb L9-11) AND every key must exist in schema.properties (else WorkflowError at task_process).", "notes": "Linter E188 rejects flat field maps; E189 rejects parameters.id." }
      ]
    }
  },

  "CustomObject::Delete": {
    "description": "Delete a Custom Object record by UUID. If the workflow had previously bound Data.<object> to a single record matching this Id (or an Array containing it), that entry is removed from new_data. No positive Data.* writes; idempotent. object is `<namespace>__<object>` WITHOUT a trailing `__c`. Record id is top-level object_id, not parameters.id.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CustomObjectDelete",
      "parameters": { "strict_variables": "true", "disable_validation": "false", "delete_payload_paths": [] },
      "action_type": "CustomObject::Delete",
      "object": "<<REQUIRED: Custom Object API name (e.g. default__Vendor — NOT default__Vendor__c)>>",
      "object_id": "<<REQUIRED: Custom Object record UUID (Liquid OK)>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object", "object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables", "disable_validation"],
    "data_contract": {
      "predictability": "scoping",
      "writes": [
        { "to_template": "Data.{self.object}", "shape": "removed", "fields": "(may unbind the singular Data.<object> Hash if it matches the deleted Id, or splice from an Array)", "notes": "Like Delete - no positive Data.* output. Linter treats as no-op for downstream availability." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" }
      ],
      "opaque": false,
      "routing_only": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/custom_object/_delete.html.erb (renders shared `partials/object_id_type` with object: true, id: true; no per-task fields).",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash. `object` and `object_id` ride on `common_params`.",
      "$model": "app/models/tasks/custom_object/delete.rb (extends CustomObject::CustomObjectTask)",
      "$model_validation": "validates :object, presence: true (L2). validates :object_id, presence: true (L3). task_setup_validation is empty (no extra checks). is_idempotent? returns true.",
      "$task_process_use": "task_process (L14-29): template_parse object_id, DELETE `/objects/records/<namespace>/<object_name>/<uuid>`. On non-200, raise WorkflowError. On success, if new_data[<object>] is a Hash whose Id matches, delete the entry; if Array, delete_if {Id == uuid}. Then iterate Success.",
      "fields": [
        { "$field": "object", "purpose": "Custom Object API name (`<namespace>__<object>`). NEVER end with `__c`.", "source": "describe-call (Custom Object describe)", "options_source": "Thread.current[:appinstance].get_custom_objects(entity_id: get_entity_id)", "required_when": "always", "controller_permit": "object (top-level via common_params)", "model_validation": "Required (delete.rb L2)." },
        { "$field": "object_id", "purpose": "UUID of the Custom Object record to delete (Liquid OK). TOP-LEVEL task attribute — not parameters.id.", "source": "user-input (text, Liquid OK)", "required_when": "always", "controller_permit": "object_id (top-level via common_params)", "model_validation": "Required (delete.rb L3); template_parsed at runtime." }
      ]
    }
  },

  "CustomObject::Query": {
    "description": "Query Custom Object records via Lucene `parameters.query` and/or an `parameters.ids=...&ids=...` URL fragment. Output is written as an Array<Hash> at `Data.<alternate_location | object>` (write_data with overwrite=true, so it REPLACES any prior binding at that scope). Use parameters.alternate_location — NOT parameters.placement (that is the SOAP Query param; CustomObject::Query ignores placement). object is `<namespace>__<object>` WITHOUT a trailing `__c`. At least one of query or ids must be present. Idempotent.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CustomObjectQuery",
      "parameters": {
        "ids": "",
        "query": "<<OPTIONAL: Lucene filter expression (Liquid OK), e.g. `Status__c:Submitted`. At least one of query|ids required>>",
        "alternate_location": "<<OPTIONAL: alternate Data.* placement; defaults to self.object. NOT 'placement'.>>",
        "zero_result_stop": "false",
        "strict_variables": "true",
        "disable_validation": "false",
        "delete_payload_paths": []
      },
      "action_type": "CustomObject::Query",
      "object": "<<REQUIRED: Custom Object API name (e.g. default__Vendor — NOT default__Vendor__c)>>",
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables", "zero_result_stop", "disable_validation"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "<self.object>",
      "scope_template": "Data.{parameters.alternate_location | self.object}",
      "writes": [
        { "to_template": "Data.{parameters.alternate_location | self.object}", "shape": "Array<Hash>", "fields": "schema.properties keys (origin != 'system') + 'Id'", "notes": "write_data(object_name: payload_location, object_data: records, overwrite: true) - REPLACES any prior Hash/Array at that scope. Inside an Iterate For-Each branch over this scope, the binding rebinds to a single Hash row. payload_location = parameters.alternate_location || self.object (query.rb) — parameters.placement is ignored." }
      ],
      "reads": [
        { "from_param": "query", "shape": "liquid" },
        { "from_param": "ids", "shape": "liquid" }
      ],
      "opaque": false
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/custom_object/_query.html.erb (renders shared `partials/object_id_type` with object: true; query / ids text areas; alternate_location and zero_result_stop fields).",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash. `object` rides on `common_params`.",
      "$model": "app/models/tasks/custom_object/query.rb (extends CustomObject::CustomObjectTask)",
      "$model_validation": "validates :object, presence: true (L3). task_setup_validation (L6-12): object required AND at least one of parameters.query / parameters.ids must be present (else 'Please specify the query or ids...'). is_idempotent? returns true.",
      "$task_process_use": "task_process (L30-59): builds GET <base_url>/objects/records/<namespace>/<object_name>/?q=<query>&<ids>. URL-encodes the query, strips zero-width chars from the ids fragment. Sets `Zuora-Realtime-Read: true`. Streams the response with a remaining_size_limit guard (DataLimitError if > Thread.current[:appinstance].data_size_limit). On 200, write_data(object_name: payload_location, object_data: records, overwrite: true). When count == 0 and parameters.zero_result_stop == 'true', returns without iterating (halts downstream). Otherwise iterate Success.",
      "$anti_patterns": "Do NOT use parameters.placement (SOAP Query). Use parameters.alternate_location. Do NOT append __c to object.",
      "fields": [
        { "$field": "object", "purpose": "Custom Object API name (`<namespace>__<object>`). NEVER end with `__c`.", "source": "describe-call (Custom Object describe)", "options_source": "Thread.current[:appinstance].get_custom_objects(entity_id: get_entity_id)", "required_when": "always", "controller_permit": "object (top-level via common_params)", "model_validation": "Required (query.rb L3)." },
        { "$field": "parameters.query", "purpose": "Lucene-style filter expression (Liquid OK), e.g. `status:active AND activeDate:<2018`.", "source": "user-input (textarea, Liquid OK)", "required_when": "parameters.ids is blank", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.ids", "purpose": "URL-fragment of `ids=<uuid>&ids=<uuid>...` repeated query params (Liquid OK; commonly built with `{{ Data.xyz | map: 'id' | join: '&ids=' }}`).", "source": "user-input (textarea, Liquid OK)", "required_when": "parameters.query is blank", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.alternate_location", "purpose": "Override the Data.* scope where results are written (default = self.object). NOT parameters.placement — that is ignored by CustomObject::Query.", "source": "user-input (text)", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.zero_result_stop", "purpose": "When 'true', halt downstream tasks if the query returns 0 records.", "source": "boolean-string", "controller_permit": "parameters: any-hash", "default": "\"false\"" }
      ]
    }
  },

  "Attachment": {
    "description": "Attach a file to a Zuora object.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Attachment",
      "parameters": {
        "files": {},
        "strict_variables": "true"
      },
      "action_type": "Attachment",
      "object": "<<REQUIRED: Target object (Invoice, Account, Subscription, etc.)>>",
      "object_id": "<<REQUIRED: target object id>>",
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object", "object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{file_label}",
      "writes": [
        { "to_template": "Data.Files.{parameters.files keys}", "shape": "File", "fields": "file blobs (already in Data.Files when consumed)", "notes": "Attachment uploads files referenced by parameters.files keys to the target Zuora object. It does not add new Data scopes; it consumes Data.Files entries." }
      ],
      "reads": [
        { "from_param": "files", "shape": "object" },
        { "from_param": "object_id", "shape": "liquid" }
      ],
      "opaque": false,
      "$notes": "Side-effect (file attached to target object); no positive Data.* writes."
    }
  },

  "File::CustomPDF::CustomDocument": {
    "description": "Generate a custom PDF document.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CustomDocument",
      "parameters": {
        "template": "<<REQUIRED: document template>>",
        "strict_variables": "true"
      },
      "action_type": "File::CustomPDF::CustomDocument",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["template"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{output filename}",
      "writes": [
        { "to_template": "Data.Files.{output filename}", "shape": "File", "fields": "PDF blob", "notes": "Generated PDF document is registered under Data.Files for downstream tasks (Email, Upload::*, Attachment) to consume by file label." }
      ],
      "reads": [
        { "from_param": "template", "shape": "string" }
      ],
      "opaque": false
    }
  },

  "File::DownloadFile": {
    "description": "Download a file from Zuora or an external URL.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "DownloadFile",
      "parameters": {
        "source": "zuora",
        "files": [],
        "strict_variables": "true"
      },
      "action_type": "File::DownloadFile",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["source"],
    "required_at_import": [],
    "param_enums": {
      "source": ["zuora", "external"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{file label per parameters.files entries}",
      "writes": [
        { "to_template": "Data.Files.{file_label}", "shape": "File", "fields": "file blob", "notes": "Each entry in parameters.files becomes a Data.Files.<label> binding downstream tasks can consume." }
      ],
      "reads": [
        { "from_param": "files", "shape": "array" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "File::ZuoraImport": {
    "description": "Bulk-import a file via the Zuora legacy import API.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "ZuoraImport",
      "parameters": {
        "action": "<<REQUIRED: import action>>",
        "strict_variables": "true"
      },
      "action_type": "File::ZuoraImport",
      "object": "<<REQUIRED: file label/key to import>>",
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["action"],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "ZuoraImport",
      "scope_template": "Data.ZuoraImport (job summary)",
      "writes": [
        { "to_template": "Data.ZuoraImport", "shape": "Hash", "fields": "id, status, importType, message, totalCount, processedCount, errorCount", "notes": "Zuora legacy bulk-import job summary." }
      ],
      "reads": [
        { "from_param": "action", "shape": "string" },
        { "from_param": "object", "shape": "string" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "File::FileOperations": {
    "description": "Perform file-level operations (zip/unzip, encrypt, split, etc.).",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "FileOperations",
      "parameters": {
        "action": "<<REQUIRED: Zip | Unzip | Encrypt | Decrypt | SplitCSV | PDF | ZipFiles>>",
        "strict_variables": "true"
      },
      "action_type": "File::FileOperations",
      "object": "<<REQUIRED: file label>>",
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["action"],
    "required_at_import": ["object"],
    "param_enums": {
      "action": ["Zip", "Unzip", "CSVCreation", "PDFCreation", "FileEncryption", "FileDecryption", "ZipFiles", "AttachXMLtoPDF"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{output filename(s)} (per action)",
      "writes": [
        { "to_template": "Data.Files.{output_filename}", "shape": "File", "fields": "file blob(s) - per action: Zip/ZipFiles produces a single archive, Unzip produces multiple, FileEncryption/FileDecryption/CSVCreation/PDFCreation/AttachXMLtoPDF produce one transformed file", "notes": "Output filename(s) come from action-specific parameters (zipped_filename, encrypted_filename, decrypted_filename, csv_filename, pdf_filename, attached_filename). Field shape under Data.Files is bytes, not structured fields." }
      ],
      "reads": [
        { "from_param": "action", "shape": "string" },
        { "from_param": "object", "shape": "string" }
      ],
      "opaque": false,
      "fields_partial_known": true,
      "$notes": "When the action does not consume an upstream file (PDFCreation, CSVCreation), parameters.object is not required."
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/file/_file_operations.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; `object` column governed by common_params.",
      "$model": "app/models/tasks/file/file_operations.rb",
      "$model_validation": "task_setup_validation (L19-69) requires action; per-action: requires :object except for PDFCreation/CSVCreation, requires public_key+encrypted_filename for FileEncryption, private_key+passphrase+decrypted_filename for FileDecryption, field_name+columns for CSVCreation, pdf_content for PDFCreation, filename_regex+zipped_filename for ZipFiles, object+xml_file+attached_filename for AttachXMLtoPDF.",
      "$task_process_use": "task_process dispatches by parameters.action and writes the resulting file(s) to Data.Files using the action-specific filename parameter. The UI auto-submits on action change so the field set is always consistent with the chosen action.",
      "fields": [
        {
          "$field": "parameters.action",
          "purpose": "Which file operation to perform.",
          "source": "dropdown-static",
          "options_source": "_file_operations.html.erb L17: ['Zip', 'Unzip', 'CSVCreation', 'PDFCreation', 'FileEncryption', 'FileDecryption', 'ZipFiles', 'AttachXMLtoPDF'].",
          "controller_permit": "parameters: any-hash (fall-through branch)",
          "model_validation": "Required (file_operations.rb task_setup_validation L19-22).",
          "default": null,
          "notes": "Selecting a value triggers form auto-save in the UI so the rest of the field set rerenders."
        },
        {
          "$field": "object",
          "purpose": "Upstream file to operate on (selected from prior tasks' Files).",
          "source": "dropdown-from-parent-task",
          "options_source": "f.object.setup_file_list -- enumerates Files entries written by upstream Export / File / Iterate tasks (_file_operations.html.erb L21-23, 28).",
          "controller_permit": "object (top-level via common_params)",
          "model_validation": "Required when action in [Zip, Unzip, FileEncryption, FileDecryption, AttachXMLtoPDF].",
          "default": null,
          "required_when": "action in ['Zip', 'Unzip', 'FileEncryption', 'FileDecryption', 'AttachXMLtoPDF']",
          "visible_when": "same as required_when"
        },
        {
          "$field": "parameters.encrypted_filename",
          "purpose": "Filename for the encrypted output.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'FileEncryption'.",
          "required_when": "action == 'FileEncryption'",
          "visible_when": "action == 'FileEncryption'"
        },
        {
          "$field": "parameters.public_key",
          "purpose": "PGP public key (PEM) used to encrypt.",
          "source": "user-input-textarea",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'FileEncryption'. Also accepted optionally on FileDecryption (sender's public key for signature verification).",
          "required_when": "action == 'FileEncryption'",
          "visible_when": "action in ['FileEncryption', 'FileDecryption']"
        },
        {
          "$field": "parameters.signer",
          "purpose": "Optional signer identity (FileEncryption signing).",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "visible_when": "action == 'FileEncryption'"
        },
        {
          "$field": "parameters.signer_passphrase",
          "purpose": "Passphrase for the signer's private key.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "visible_when": "action == 'FileEncryption'"
        },
        {
          "$field": "parameters.signer_private_key",
          "purpose": "Signer's PGP private key (PEM).",
          "source": "user-input-textarea",
          "controller_permit": "parameters: any-hash",
          "visible_when": "action == 'FileEncryption'"
        },
        {
          "$field": "parameters.decrypted_filename",
          "purpose": "Filename for the decrypted output.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'FileDecryption'.",
          "required_when": "action == 'FileDecryption'",
          "visible_when": "action == 'FileDecryption'"
        },
        {
          "$field": "parameters.private_key",
          "purpose": "PGP private key (PEM) used to decrypt.",
          "source": "user-input-textarea",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'FileDecryption'.",
          "required_when": "action == 'FileDecryption'",
          "visible_when": "action == 'FileDecryption'"
        },
        {
          "$field": "parameters.passphrase",
          "purpose": "Passphrase for the recipient's private key.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'FileDecryption'.",
          "required_when": "action == 'FileDecryption'",
          "visible_when": "action == 'FileDecryption'"
        },
        {
          "$field": "parameters.signer_public_key",
          "purpose": "Sender's PGP public key for signature verification (FileDecryption).",
          "source": "user-input-textarea",
          "controller_permit": "parameters: any-hash",
          "visible_when": "action == 'FileDecryption'"
        },
        {
          "$field": "parameters.pdf_filename",
          "purpose": "Filename for the generated PDF.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "required_when": "action == 'PDFCreation'",
          "visible_when": "action == 'PDFCreation'"
        },
        {
          "$field": "parameters.pdf_content",
          "purpose": "HTML/Liquid template that renders into the PDF body.",
          "source": "code-editor",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'PDFCreation'.",
          "required_when": "action == 'PDFCreation'",
          "visible_when": "action == 'PDFCreation'"
        },
        {
          "$field": "parameters.csv_filename",
          "purpose": "Filename for the CSV created from input files.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "required_when": "action == 'CSVCreation'",
          "visible_when": "action == 'CSVCreation'"
        },
        {
          "$field": "parameters.field_name",
          "purpose": "Array of column header names for the generated CSV.",
          "source": "form-array",
          "controller_permit": "parameters: any-hash (UI submits as parameters[field_name][])",
          "model_validation": "Required when action == 'CSVCreation' (file_operations.rb).",
          "required_when": "action == 'CSVCreation'",
          "visible_when": "action == 'CSVCreation'"
        },
        {
          "$field": "parameters.columns",
          "purpose": "Per-input-file mapping from column index in the source file to the new CSV column.",
          "source": "form-matrix",
          "controller_permit": "parameters: any-hash (UI submits as parameters[columns][<file_idx>][])",
          "model_validation": "Required when action == 'CSVCreation'.",
          "required_when": "action == 'CSVCreation'",
          "visible_when": "action == 'CSVCreation'"
        },
        {
          "$field": "parameters.filename_regex",
          "purpose": "Regex matching upstream file names to bundle into the zip.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'ZipFiles'. Soft cap of 10,000 input files surfaced in the UI.",
          "required_when": "action == 'ZipFiles'",
          "visible_when": "action == 'ZipFiles'"
        },
        {
          "$field": "parameters.zipped_filename",
          "purpose": "Output filename for the multi-file zip.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'ZipFiles'.",
          "required_when": "action == 'ZipFiles'",
          "visible_when": "action == 'ZipFiles'"
        },
        {
          "$field": "parameters.xml_file",
          "purpose": "XML file (from Files) to attach to the PDF.",
          "source": "dropdown-from-parent-task",
          "options_source": "Same setup_file_list as `object`.",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'AttachXMLtoPDF'.",
          "required_when": "action == 'AttachXMLtoPDF'",
          "visible_when": "action == 'AttachXMLtoPDF'"
        },
        {
          "$field": "parameters.attached_filename",
          "purpose": "Name the XML attachment will have inside the PDF.",
          "source": "user-input",
          "controller_permit": "parameters: any-hash",
          "model_validation": "Required when action == 'AttachXMLtoPDF'.",
          "required_when": "action == 'AttachXMLtoPDF'",
          "visible_when": "action == 'AttachXMLtoPDF'"
        }
      ]
    }
  },

  "File::FileStreamingUpload": {
    "description": "Stream-upload a file to an S3 path.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "FileStreamingUpload",
      "parameters": {
        "s3_path": "<<REQUIRED: target S3 path>>",
        "strict_variables": "true"
      },
      "action_type": "File::FileStreamingUpload",
      "object": "<<REQUIRED: file label>>",
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["s3_path"],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "s3_path", "shape": "liquid" },
        { "from_param": "object", "shape": "string" }
      ],
      "opaque": false,
      "$notes": "Streams a Data.Files.<label> file out to S3; no positive Data.* writes."
    }
  },

  "File::BulkDataLoader": {
    "description": "Bulk load data using the Zuora Bulk Data Loader API.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "BulkDataLoader",
      "parameters": {
        "name": "<<REQUIRED: job name>>",
        "objectType": "<<REQUIRED: object type>>",
        "jobType": "<<REQUIRED: insert | update | upsert | delete>>",
        "mappings": {},
        "strict_variables": "true"
      },
      "action_type": "File::BulkDataLoader",
      "object": "<<REQUIRED: source file key>>",
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["name", "objectType", "jobType"],
    "required_at_import": ["object"],
    "param_enums": {
      "jobType": ["insert", "update", "upsert", "delete"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "BulkDataLoader",
      "scope_template": "Data.BulkDataLoader",
      "writes": [
        { "to_template": "Data.BulkDataLoader", "shape": "Hash", "fields": "id, name, objectType, jobType, status, totalCount, errorCount, createdById, createdDate, updatedById, updatedDate, success", "notes": "Bulk Data Loader job summary (commonly assumed scope; may include error file URL)." }
      ],
      "reads": [
        { "from_param": "name", "shape": "liquid" },
        { "from_param": "objectType", "shape": "string" },
        { "from_param": "jobType", "shape": "string" },
        { "from_param": "object", "shape": "string" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "Reporting::RunReport": {
    "description": "Execute a Zuora Reporting report and stream the results back as a CSV file holder. The selected report (object_id) and viewType determine the columns; rows can also be parsed back into Data.Report via Iterate over Data.Files.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "RunReport",
      "parameters": {
        "label_id": "<<REQUIRED: report label/folder ID returned by /reportlabels>>",
        "viewType": "Detail",
        "default_filters": "false",
        "pivoted": "false",
        "filename": "<<AUTO-FILLED by UI: <dsName>-<reportName>-<viewType>.csv>>",
        "selected_fields": "<<AUTO-FILLED by UI: JSON map header_key->header_label, used by generate_hash_from_csv at iterate time>>",
        "strict_variables": "true"
      },
      "action_type": "Reporting::RunReport",
      "object": null,
      "object_id": "<<REQUIRED: Zuora Reporting report ID>>",
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["label_id"],
    "required_at_import": ["object_id"],
    "param_enums": {
      "viewType": ["Detail", "Summary"]
    },
    "boolean_string_params": ["strict_variables", "default_filters", "pivoted"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.<filename without ext>__<task_id>.<ext>  (+ Data.Report after a downstream Iterate)",
      "writes": [
        { "to_template": "Data.Files.<filename without ext>__<task_id>.<ext>", "shape": "File", "fields": "CSV blob", "when": "always", "notes": "task_process polls /reportruns/<id> until COMPLETED, then GET /reportruns/export/<id>?pivoted=<bool>, uploads via Task#upload_file. The file holder name follows file_holder_name (`<filename>__<task_id>.<ext>`)." },
        { "to_template": "Data.Report", "shape": "object|Array<object>", "fields": "keys derived from parameters.selected_fields (parameterized via .parameterize(separator:'_'))", "when": "downstream Iterate consumes the file holder via generate_hash_from_csv", "notes": "RunReport itself does NOT populate Data.Report; Iterate over the Files holder triggers the helper to project rows." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "string (Zuora report ID)" },
        { "from_param": "label_id", "shape": "string" },
        { "from_param": "viewType", "shape": "enum" },
        { "from_param": "default_filters", "shape": "bool-string" },
        { "from_param": "pivoted", "shape": "bool-string" },
        { "from_param": "username", "shape": "liquid (only when target_login is OAuth)" },
        { "from_param": "password", "shape": "liquid (only when target_login is OAuth)" }
      ],
      "opaque": false,
      "fields_partial_known": true
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/reporting/_run_report.html.erb (plus app/helpers/reporting_helper#nested_report_menu)",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash. `object_id` rides on common_params.",
      "$model": "app/models/tasks/reporting/run_report.rb",
      "$model_validation": "validates :object_id, presence: true (L5). custom_initialize (L43-47) caches default_filters and viewType (default 'Detail'). task_setup_validation (L16-37): rejects environments other than Sandbox/Production/Performance/Test; calls fetch_report_details to ensure no askUser filters are set, and that the chosen viewType has matching definition fields (selectedFields for Detail, rowFields/colFields/valFields for Summary). is_idempotent? returns true.",
      "$task_process_use": "task_process (L131-201): if no transient reportId, POST /reports/<object_id>/reportrun?viewType=<viewType> (body = report.definition.filters when default_filters else []). Polls /reportruns/<reportId> every 60s up to 200 times until status=COMPLETED (raises on ERROR). Then GET /reportruns/export/<reportId>?pivoted=<bool> as a file, uploads it via Task#upload_file (file holder named by file_holder_name -> `<filename without ext>__<task_id>.<ext>`), and iterate_tasks(linkage_type:'Success').",
      "fields": [
        { "$field": "object_id", "purpose": "Zuora Reporting report ID returned by /reports/reportlabels/<label>/report-details.", "source": "dropdown-from-api (Reporting API: fetch_available_reports(label_id))", "options_source": "GET <reporting_url>/reports/reportlabels/<label_id>/report-details -> response.reports.map{|r| [r.name, r.id]} (excluding deleted)", "required_when": "always", "controller_permit": "object_id (top-level via common_params)", "model_validation": "Required (run_report.rb L5)." },
        { "$field": "parameters.label_id", "purpose": "Report folder/label ID used to scope the report dropdown.", "source": "tree-select (fetch_labels -> Reporting API /reportlabels, cached 24h in Redis)", "options_source": "GET <reporting_url>/reportlabels -> response (recursive children); rendered via reporting_helper.nested_report_menu", "required_when": "always", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.viewType", "purpose": "Whether to run the report in Detail (raw rows) or Summary (pivoted aggregates) mode.", "source": "dropdown-static", "options_source": "['Detail', 'Summary']", "controller_permit": "parameters: any-hash", "default": "\"Detail\"", "model_validation": "task_setup_validation cross-checks the chosen viewType against report.definition (Detail requires selectedFields; Summary requires rowFields/colFields/valFields)." },
        { "$field": "parameters.default_filters", "purpose": "When 'true', send the report's saved filters (definition.filters) on /reportrun; otherwise send [].", "source": "checkbox-with-hidden", "controller_permit": "parameters: any-hash", "default": "\"false\"" },
        { "$field": "parameters.pivoted", "purpose": "When 'true', include ?pivoted=true on the export call so Summary reports are flattened by Reporting API.", "source": "checkbox-with-hidden", "controller_permit": "parameters: any-hash", "default": "\"false\"" },
        { "$field": "parameters.filename", "purpose": "Display filename used to build the file holder name (file_holder_name appends `__<task_id>` before the extension).", "source": "computed (UI hidden field, set to `<dsName>-<reportName>-<viewType>.csv` from fetch_report_details)", "controller_permit": "parameters: any-hash", "notes": "DO NOT hand-edit; it is regenerated whenever object_id changes." },
        { "$field": "parameters.selected_fields", "purpose": "JSON map of `<parameterized_header>` -> `<human label>` used by generate_hash_from_csv (and downstream Iterate) to project Data.Report rows.", "source": "computed (UI hidden field via Reporting::RunReport#get_file_header)", "controller_permit": "parameters: any-hash", "notes": "Stored as a JSON string. data_header_lookup / new_fields read it at iterate time so Data.Report keys match the CSV columns." },
        { "$field": "parameters.username", "purpose": "Reporting Basic-auth username override.", "source": "user-input (text)", "visible_when": "appinstance.target_login.client.class == ZuoraAPI::Oauth", "controller_permit": "parameters: any-hash", "notes": "Reporting APIs require Basic credentials -- supply via global constants in production." },
        { "$field": "parameters.password", "purpose": "Reporting Basic-auth password override.", "source": "user-input (text)", "visible_when": "appinstance.target_login.client.class == ZuoraAPI::Oauth", "controller_permit": "parameters: any-hash", "notes": "Reporting APIs require Basic credentials -- supply via global constants in production." }
      ]
    }
  },

  "Reporting::OracleFusionReport": {
    "description": "Run an Oracle Fusion report.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "OracleFusionReport",
      "parameters": {
        "report_name": "<<REQUIRED: report name>>",
        "strict_variables": "true"
      },
      "action_type": "Reporting::OracleFusionReport",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "OracleFusionReport",
      "scope_template": "Data.{parameters.placement | 'OracleFusionReport'}  (+ Data.Files.{report_name}.csv)",
      "writes": [
        { "to_template": "Data.{parameters.placement | 'OracleFusionReport'}", "shape": "Array<Hash>", "fields": "Oracle Fusion report columns (varies by report)", "notes": "Report rows. Column names depend on the Oracle Fusion report definition." },
        { "to_template": "Data.Files.{report_name}.csv", "shape": "File", "fields": "CSV blob", "notes": "Report download under Data.Files." }
      ],
      "reads": [
        { "from_param": "report_name", "shape": "string" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "NewProduct": {
    "description": "Add a product amendment (NewProduct) to a subscription.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "NewProduct",
      "parameters": {
        "strict_variables": "true"
      },
      "action_type": "NewProduct",
      "object": null,
      "object_id": "<<REQUIRED: subscription id>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "Subscription",
      "scope_template": "Data.Subscription",
      "writes": [
        { "to_template": "Data.Subscription", "shape": "Hash", "fields": "SubscriptionId, AmendmentIds, InvoiceId, CreditMemoId, TotalDeltaTcv, success, AccountId, AccountNumber, ContractEffectiveDate, ServiceActivationDate, CustomerAcceptanceDate, ApplyCreditBalance, CollectPayment, CurrentTerm, CurrentTermPeriodType, RenewalTerm, RenewalTermPeriodType, NotesKnox, RatePlanData", "notes": "Result of NewProduct amendment - subscription id, amendment ids, optional invoice/credit memo." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" }
      ],
      "opaque": false
    }
  },

  "RemoveProduct": {
    "description": "Remove a product amendment from a subscription.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "RemoveProduct",
      "parameters": {
        "strict_variables": "true"
      },
      "action_type": "RemoveProduct",
      "object": null,
      "object_id": "<<REQUIRED: subscription id>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "Subscription",
      "scope_template": "Data.Subscription",
      "writes": [
        { "to_template": "Data.Subscription", "shape": "Hash", "fields": "SubscriptionId, AmendmentIds, InvoiceId, CreditMemoId, TotalDeltaTcv, success", "notes": "Result of RemoveProduct amendment." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" }
      ],
      "opaque": false
    }
  },

  "Suspend": {
    "description": "Suspend a subscription via SOAP amendment.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Suspend",
      "parameters": {
        "suspend_options": {},
        "strict_variables": "true"
      },
      "action_type": "Suspend",
      "object": null,
      "object_id": "<<REQUIRED: subscription id>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "Subscription",
      "scope_template": "Data.Subscription",
      "writes": [
        { "to_template": "Data.Subscription", "shape": "Hash", "fields": "SubscriptionId, AmendmentIds, InvoiceId, CreditMemoId, TotalDeltaTcv, success", "notes": "Result of Suspend amendment. parameters.suspend_options sets SuspendDate / SuspendPeriods etc." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" },
        { "from_param": "suspend_options", "shape": "object" }
      ],
      "opaque": false
    }
  },

  "Resume": {
    "description": "Resume a suspended subscription via SOAP amendment.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Resume",
      "parameters": {
        "resume_options": {},
        "strict_variables": "true"
      },
      "action_type": "Resume",
      "object": null,
      "object_id": "<<REQUIRED: subscription id>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "Subscription",
      "scope_template": "Data.Subscription",
      "writes": [
        { "to_template": "Data.Subscription", "shape": "Hash", "fields": "SubscriptionId, AmendmentIds, InvoiceId, CreditMemoId, TotalDeltaTcv, success", "notes": "Result of Resume amendment. parameters.resume_options sets ResumeDate / ResumeSpecificDate etc." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" },
        { "from_param": "resume_options", "shape": "object" }
      ],
      "opaque": false
    }
  },

  "Cancel": {
    "description": "Legacy SOAP amendment task for subscription cancellation. For new API stack cancellation, prefer a Zuora Callout to Orders API with orderActions[].type = \"CancelSubscription\".",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Cancel",
      "parameters": {
        "cancel_options": {},
        "strict_variables": "true"
      },
      "action_type": "Cancel",
      "object": null,
      "object_id": "<<REQUIRED: subscription id>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "Subscription",
      "scope_template": "Data.Subscription",
      "writes": [
        { "to_template": "Data.Subscription", "shape": "Hash", "fields": "SubscriptionId, AmendmentIds, InvoiceId, CreditMemoId, TotalDeltaTcv, success", "notes": "Result of legacy SOAP Cancel amendment. parameters.cancel_options sets CancellationPolicy / CancellationEffectiveDate etc. Use Orders API CancelSubscription for new-stack subscription cancellation unless the workflow explicitly requires legacy amendments." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" },
        { "from_param": "cancel_options", "shape": "object" }
      ],
      "opaque": false
    }
  },

  "Billing::BillRun": {
    "description": "Create or replicate a bill run.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "BillRun",
      "parameters": {
        "BillRunMode": "single",
        "InvoiceDate": "<<REQUIRED: date or Liquid>>",
        "TargetDate": "<<REQUIRED: date or Liquid>>",
        "strict_variables": "true"
      },
      "action_type": "Billing::BillRun",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["BillRunMode"],
    "required_at_import": [],
    "param_enums": {
      "BillRunMode": ["single", "batch", "replicate"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "BillRun",
      "scope_template": "Data.BillRun",
      "writes": [
        { "to_template": "Data.BillRun", "shape": "Hash", "fields": "AutoRenewal, InvoiceDate, Id, NumberOfInvoices, UpdatedDate, BillCycleDay, AutoEmail, AutoPost, NoEmailForZeroAmountInvoice, NumberOfAccounts, BillRunNumber, Status, CreatedDate, CreatedById, UpdatedById, ExecutedDate, Batch, InvoicesEmailed, TargetDate", "notes": "Concrete fields are enumerated in Billing::BillRun#data_structure (app/models/tasks/billing/bill_run.rb)." }
      ],
      "reads": [
        { "from_param": "InvoiceDate", "shape": "liquid" },
        { "from_param": "TargetDate", "shape": "liquid" },
        { "from_param": "AccountId", "shape": "liquid" },
        { "from_param": "SourceBillRunId", "shape": "liquid" }
      ],
      "opaque": false
    },
    "notes": "replicate mode requires parameters.SourceBillRunId. single mode requires parameters.AccountId. OOTB Billing::BillRun supports standard bill-run fields and v1 single-account/subscription filters via AccountId/SubscriptionIds; it does not support arbitrary billRunFilters such as BatchNumberFilter, AccountNumberFilter, or APM/ProductRatePlanCharge IDs. Use a custom Zuora Callout to the bill run API for unsupported filters."
  },

  "InvoiceGenerate": {
    "description": "Generate an invoice via SOAP InvoiceGenerate.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "InvoiceGenerate",
      "parameters": {
        "fields": {
          "Invoice": {
            "InvoiceDate": "<<REQUIRED: date or Liquid>>",
            "TargetDate": "<<REQUIRED: date or Liquid>>"
          }
        },
        "strict_variables": "true"
      },
      "action_type": "InvoiceGenerate",
      "object": null,
      "object_id": "<<REQUIRED: Account id>>",
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["fields.Invoice.InvoiceDate", "fields.Invoice.TargetDate"],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "Invoice",
      "scope_template": "Data.Invoice + Data.CreditMemo",
      "writes": [
        { "to_template": "Data.Invoice", "shape": "Array<Hash>", "fields": "json_output['invoices']", "notes": "Generated invoices. Empty array if no invoice generated." },
        { "to_template": "Data.CreditMemo", "shape": "Array<Hash>", "fields": "json_output['creditMemos']", "notes": "Generated credit memos (when invoice settlement enabled). Empty array otherwise." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" },
        { "from_param": "fields.Invoice.InvoiceDate", "shape": "liquid" },
        { "from_param": "fields.Invoice.TargetDate", "shape": "liquid" }
      ],
      "opaque": false
    }
  },

  "WriteOff": {
    "description": "Write off an invoice via amendment or settlement.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "WriteOff",
      "parameters": {
        "method": "settlement",
        "AdjustmentDate": "<<REQUIRED: date or Liquid>>",
        "strict_variables": "true"
      },
      "action_type": "WriteOff",
      "object": null,
      "object_id": "<<REQUIRED: Invoice id>>",
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["method", "AdjustmentDate"],
    "required_at_import": ["object_id"],
    "param_enums": {
      "method": ["settlement", "amendment"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "WriteOff",
      "scope_template": "Data.WriteOff",
      "writes": [
        { "to_template": "Data.WriteOff", "shape": "Hash", "fields": "AdjustmentId, CreditMemoId, RefundId, success", "notes": "Result of the WriteOff operation - varies slightly by method (settlement uses CreditMemoId; amendment uses AdjustmentId)." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" },
        { "from_param": "method", "shape": "string" },
        { "from_param": "AdjustmentDate", "shape": "liquid" }
      ],
      "opaque": false
    }
  },

  "Billing::ReverseInvoice": {
    "description": "Reverse an invoice.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "ReverseInvoice",
      "parameters": { "strict_variables": "true" },
      "action_type": "Billing::ReverseInvoice",
      "object": null,
      "object_id": "<<REQUIRED: Invoice id>>",
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "Invoice",
      "scope_template": "Data.Invoice (reversed) + Data.CreditMemo (offsetting)",
      "writes": [
        { "to_template": "Data.Invoice", "shape": "Hash", "fields": "Id, Status, success", "notes": "The reversed invoice (Status=Reversed)." },
        { "to_template": "Data.CreditMemo", "shape": "Hash", "fields": "Id, Number, success", "notes": "Offsetting credit memo created when invoice settlement is enabled." }
      ],
      "reads": [
        { "from_param": "object_id", "shape": "liquid" }
      ],
      "opaque": false
    }
  },

  "Billing::CurrencyConversion": {
    "description": "Convert currency via the configured vendor (Oanda).",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CurrencyConversion",
      "parameters": {
        "currency_vendor": "Oanda",
        "currency_fields": [],
        "strict_variables": "true"
      },
      "action_type": "Billing::CurrencyConversion",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["currency_vendor", "currency_fields"],
    "required_at_import": [],
    "param_enums": {
      "currency_vendor": ["Oanda"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "CurrencyConversion",
      "scope_template": "Data.CurrencyConversion",
      "writes": [
        { "to_template": "Data.CurrencyConversion", "shape": "Hash", "fields": "rate, from_currency, to_currency, converted_amount, vendor", "notes": "Conversion result for each entry in parameters.currency_fields. Field names are conversion metadata; the converted values are written by field key under here." }
      ],
      "reads": [
        { "from_param": "currency_fields", "shape": "array" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "Billing::CustomBillingDocument": {
    "description": "Generate a custom billing document from a PDF template.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "CustomBillingDocument",
      "parameters": {
        "template": "<<REQUIRED: PDF template>>",
        "strict_variables": "true"
      },
      "action_type": "Billing::CustomBillingDocument",
      "object": null,
      "object_id": "<<REQUIRED: source document id>>",
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["template"],
    "required_at_import": ["object_id"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{output filename}",
      "writes": [
        { "to_template": "Data.Files.{output filename}", "shape": "File", "fields": "PDF blob", "notes": "Generated custom billing document (PDF) is registered under Data.Files for downstream consumption." }
      ],
      "reads": [
        { "from_param": "template", "shape": "string" },
        { "from_param": "object_id", "shape": "liquid" }
      ],
      "opaque": false
    }
  },

  "Payment::PaymentRun": {
    "description": "Create, update, get, or delete a payment run.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "PaymentRun",
      "parameters": {
        "action": "create",
        "strict_variables": "true"
      },
      "action_type": "Payment::PaymentRun",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["action"],
    "required_at_import": [],
    "param_enums": {
      "action": ["create", "update", "get", "delete"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "PaymentRun",
      "scope_template": "Data.PaymentRun (+ Data.PaymentRunSummary when mode == 'file_data')",
      "writes": [
        { "to_template": "Data.PaymentRun", "shape": "Hash", "fields": "id, number, targetDate, runDate, status, accountId, executedOn, completedOn, applyCreditBalance, useCustomPaymentMethods, consolidatedPayment, processPaymentWithClosedPM, collectPayment, createdDate, createdById, updatedDate, updatedById, success", "notes": "Always written by 'create' action; concrete fields enumerated in Payment::PaymentRun#data_structure." },
        { "to_template": "Data.PaymentRunSummary", "shape": "Hash", "fields": "numberOfErrors, numberOfReceivables, numberOfPayments, numberOfUnprocessedReceivables, numberOfCreditBalanceAdjustments, numberOfInputData, numberOfProcessedInputData, numberOfErrorInputData, totalValues, success", "notes": "Only written when parameters.mode == 'file_data'." }
      ],
      "reads": [],
      "opaque": false
    }
  },

  "Payment::GatewayReconciliation": {
    "description": "Reconcile payment gateway records (settle, reverse, reject).",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "GatewayReconciliation",
      "parameters": {
        "action": "settle",
        "strict_variables": "true"
      },
      "action_type": "Payment::GatewayReconciliation",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["action"],
    "required_at_import": [],
    "param_enums": {
      "action": ["settle", "reverse", "reject"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "deterministic",
      "default_scope_when_unset": "GatewayReconciliation",
      "scope_template": "Data.GatewayReconciliation",
      "writes": [
        { "to_template": "Data.GatewayReconciliation", "shape": "Hash", "fields": "action, paymentId, refundId, success, errorMessage", "notes": "Result of the gateway reconciliation operation per payment/refund." }
      ],
      "reads": [
        { "from_param": "action", "shape": "string" }
      ],
      "opaque": false
    }
  },

  "Usage::ImportUsage": {
    "description": "Import usage data from a file.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "ImportUsage",
      "parameters": { "strict_variables": "true" },
      "action_type": "Usage::ImportUsage",
      "object": "<<REQUIRED: usage file label>>",
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": ["object"],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "ImportUsage",
      "scope_template": "Data.ImportUsage",
      "writes": [
        { "to_template": "Data.ImportUsage", "shape": "Hash", "fields": "Id, Status, ImportedCount, ErrorMessage, success", "notes": "Usage import job summary." }
      ],
      "reads": [
        { "from_param": "object", "shape": "string" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "Notifications::SMS": {
    "description": "Send an SMS notification via Twilio. message and numbers are Liquid-parsed at save time and again at runtime.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "SMS",
      "parameters": {
        "sms": {
          "numbers": ["<<REQUIRED: array of E.164 phone number strings (Liquid OK)>>"],
          "message": "<<REQUIRED: SMS body (Liquid OK)>>"
        },
        "strict_variables": "true"
      },
      "action_type": "Notifications::SMS",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["sms"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "sms.message", "shape": "liquid" },
        { "from_param": "sms.numbers", "shape": "liquid (array of strings)" }
      ],
      "opaque": false,
      "routing_only": false,
      "$notes": "Pure side-effect (sends SMS); writes nothing to Data.*."
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/notifications/_sms.html.erb",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash. Strong params allows nested parameters.sms.{numbers, message} via the same loose permit.",
      "$model": "app/models/tasks/notifications/sms.rb",
      "$model_validation": "before_validation :cast (L2 + L18-20) strips blanks from sms.numbers. task_setup_validation (L4-12) requires both sms.message and sms.numbers; when present, BOTH fields are template_parsed in strict+validate mode against fake_payload so Liquid syntax errors fail save.",
      "$task_process_use": "task_process (L22-48) instantiates Twilio::REST::Client with credentials from Rails.application.secrets.twilio, then iterates self.numbers (each entry template_parsed at runtime) and sends client.messages.create(from: secrets.twilio.number, to: number, body: self.message). Each send is logged as an ApiCall record. Twilio errors (especially code 21408, 'permission to send to that destination') are surfaced as WorkflowError. Then iterate_tasks(linkage_type: 'Success').",
      "fields": [
        { "$field": "parameters.sms", "purpose": "Container for the SMS payload. MUST be a nested hash -- the Rails model dereferences `parameters['sms']['message']` and `parameters['sms']['numbers']` directly.", "source": "form-fields_for :sms (Rails-nested)", "controller_permit": "parameters: any-hash (the sms sub-hash is permitted opaquely)" },
        { "$field": "parameters.sms.numbers", "purpose": "Array of recipient phone numbers (E.164 strings). Liquid expressions are template_parsed at runtime.", "source": "multi-tag select (Select2) backed by accessible_payload + free-form input", "required_when": "always", "controller_permit": "parameters.sms.numbers", "model_validation": "Required non-empty (sms.rb L6); blank entries stripped before save." },
        { "$field": "parameters.sms.message", "purpose": "SMS body (max 1600 chars per Twilio). Liquid OK.", "source": "code-editor (textarea)", "required_when": "always", "controller_permit": "parameters.sms.message", "model_validation": "Required non-blank (sms.rb L5); template_parsed at save with strict+validate to catch Liquid syntax errors." }
      ]
    },
    "notes": "message and numbers are Liquid-parsed at save time (validate: true). Top-level `parameters.numbers` IS NOT supported -- always nest under parameters.sms."
  },

  "AsynchronousCallout": {
    "description": "Async HTTP callout with polling.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "AsynchronousCallout",
      "parameters": {
        "url": "<<REQUIRED: target URL>>",
        "method": "POST",
        "polling_url": "<<REQUIRED: polling URL>>",
        "polling_interval": "30",
        "headers": [],
        "authorization": { "type": "none" },
        "validation": { "status_codes": ["200"] },
        "retry_rules": { "retry_count": "0", "retry_window": "30" },
        "strict_variables": "true"
      },
      "action_type": "AsynchronousCallout",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["url", "method", "polling_url"],
    "required_at_import": [],
    "param_enums": {
      "method": ["GET", "POST", "PUT", "PATCH", "DELETE"],
      "authorization.type": ["none", "zuora", "basic_auth", "hmac", "oauth_2.0", "oauth_1.0"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "Callout",
      "scope_template": "Data.{parameters.validation.payload_location | 'Callout'}  AND  Data.{parameters.polling_validation.polling_payload_location | 'Callout'}",
      "writes": [
        { "to_template": "Data.{parameters.validation.payload_location | 'Callout'}", "shape": "object", "fields": "OPAQUE", "notes": "Initial async response body. When include_response_code is true or omitted, fields live under ResponseBody." },
        { "to_template": "Data.{parameters.polling_validation.polling_payload_location | 'Callout'}", "shape": "object", "fields": "OPAQUE", "notes": "Polling response body once finish_status is reached. When polling_include_response_code is true or omitted, fields live under ResponseBody." }
      ],
      "reads": [
        { "from_param": "url", "shape": "liquid" },
        { "from_param": "polling_url", "shape": "liquid" },
        { "from_param": "raw_body", "shape": "liquid" }
      ],
      "opaque": true,
      "opaque_prompt_required": true
    },
    "configuration_contract": {
      "$ui_partial": "Same shape as `_callout.html.erb`, with a Polling tab that mirrors the Headers/Body/Authentication/Response tabs but with a `polling_` prefix on every key.",
      "$controller_branch": "tasks_controller.rb L654-741 -- explicit `when :asynchronous_callout` branch with two parallel allowlists: the initial-callout keys (mirroring Callout's L597-641) and the polling_* counterparts (L676-728). Top-level `polling_form_datas_attributes`, `polling_headers_attributes`, `polling_datas_attributes` ride alongside the standard ones. Files coercion (L735-740) is identical to Callout.",
      "$model": "app/models/tasks/asynchronous_callout.rb (extends Callout)",
      "$model_validation": "task_setup_validation (L2-7) calls super (Callout) plus async_task_setup_validation twice (polling: false then polling: true). Each pass enforces the same rules as Callout but on the polling_* prefix.",
      "$task_process_use": "task_process (L59+) issues the initial HTTP request, then polls the polling_url every parameters.polling_interval seconds, evaluating parameters.response_path (Liquid) against each response. Polling stops when the result equals one of parameters.finish_status. Initial body lands at Data.<validation.payload_location | 'Callout'>; polling body lands at Data.<polling_validation.polling_payload_location | 'Callout'>.",
      "fields": [
        { "$field": "Initial-callout fields (parameters.url, method, raw_body, body_type, headers_attributes, form_datas_attributes, files, file_binary, authorization, basic_auth/oauth1/oauth2/hmac/cert/netsuite_tba, retry_rules, validation, validate_response, validation_scheme, include_response_code, notification_history_*, event_name)", "purpose": "Identical semantics to Callout. See Callout.configuration_contract for per-field details.", "source": "see Callout", "controller_permit": "tasks_controller.rb L657-721", "model_validation": "Callout#task_setup_validation (callout.rb L11-49)" },
        { "$field": "Polling-leg fields (parameters.polling_url, polling_method, polling_raw_body, polling_body_type, polling_headers_attributes, polling_form_datas_attributes, polling_files, polling_file_binary, polling_authorization, polling_basic_auth/oauth1/oauth2/hmac/cert/netsuite_tba, polling_retry_rules, polling_validation, polling_validate_response, polling_validation_scheme, polling_include_response_code, polling_notification_history_*, polling_event_name)", "purpose": "Mirror of every initial-callout field, but applied to each polling request.", "source": "see Callout (with polling_ prefix)", "controller_permit": "tasks_controller.rb L676-728", "model_validation": "asynchronous_callout.rb#async_task_setup_validation(polling: true)" },
        { "$field": "parameters.polling_interval", "purpose": "Seconds between polling attempts.", "source": "user-input (number)", "controller_permit": "parameters.polling_interval (L675)", "model_validation": "Numeric.", "default": "30" },
        { "$field": "parameters.response_path", "purpose": "Liquid expression that extracts the polling status from each response body.", "source": "code-editor", "controller_permit": "parameters.response_path (L676)", "default": null, "notes": "Compared against parameters.finish_status to decide when polling completes." },
        { "$field": "parameters.finish_status", "purpose": "Status value(s) that mark the async job as done.", "source": "multi-select-with-tags", "controller_permit": "parameters.finish_status: [] (L695)", "default": "[]" }
      ],
      "$see_controller_permit": "tasks_controller.rb#task_params L654-741. Each field appears twice (initial + polling_ prefix); silently dropped if a hand-edited JSON misspells the prefix."
    }
  },

  "Notifications::Kafka": {
    "description": "Send a message to a Kafka topic (internal access only).",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Kafka",
      "parameters": {
        "topic_name": "<<REQUIRED: Kafka topic>>",
        "message": "<<REQUIRED: message body (Liquid)>>",
        "strict_variables": "true"
      },
      "action_type": "Notifications::Kafka",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["topic_name", "message"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "topic_name", "shape": "string" },
        { "from_param": "message", "shape": "liquid" }
      ],
      "opaque": false,
      "$notes": "Pure side-effect (publishes to Kafka); writes nothing to Data.*."
    }
  },

  "Execute::WorkflowTask": {
    "description": "Invoke another workflow as a subroutine.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "ExecuteWorkflow",
      "parameters": {
        "workflow_id": "<<REQUIRED: target workflow id>>",
        "required_params": {},
        "strict_variables": "true"
      },
      "action_type": "Execute::WorkflowTask",
      "object": null,
      "object_id": null,
      "call_type": "ASYNC",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["workflow_id"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "ExecuteWorkflow",
      "scope_template": "Data.{parameters.placement | 'ExecuteWorkflow'}",
      "writes": [
        { "to_template": "Data.{parameters.placement | 'ExecuteWorkflow'}", "shape": "object", "fields": "OPAQUE", "notes": "Sub-workflow's exposed return payload (depends on the target workflow_id and what its final tasks publish). The linter cannot statically inspect another workflow definition." }
      ],
      "reads": [
        { "from_param": "workflow_id", "shape": "liquid" },
        { "from_param": "required_params[*]", "shape": "liquid" }
      ],
      "opaque": true,
      "opaque_prompt_required": true
    }
  },

  "Approval": {
    "description": "Human-in-the-loop approval step with Approve/Reject branches; supports zuoraInbox / email / slack / webex / teams delivery channels.",
    "hooks": ["Approve", "Reject", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Approval",
      "parameters": {
        "delivery_method": "zuoraInbox",
        "approver_emails": ["<<REQUIRED: Zuora username or '*' for any user>>"],
        "approvalNote": "",
        "strict_variables": "true"
      },
      "action_type": "Approval",
      "object": null,
      "object_id": null,
      "call_type": "SOAP",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["delivery_method"],
    "required_at_import": [],
    "param_enums": {
      "delivery_method": ["zuoraInbox", "email", "slack", "webex", "teams"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "scoping",
      "default_scope_when_unset": "Approval",
      "scope_template": "Data.Approval",
      "writes": [
        { "to_template": "Data.Approval", "shape": "Hash", "fields": "action ('approve'|'reject'), user, data", "notes": "After the approver/rejecter responds, task_process writes {action, user, data} to Data.Approval (approval.rb L378/L382/L444/L448). 'data' is the optional approvalData captured from the approve/reject API call." }
      ],
      "reads": [
        { "from_param": "approver_emails", "shape": "liquid" },
        { "from_param": "slackBody|webexBody|teamsBody|emailBody", "shape": "liquid" },
        { "from_param": "approvalNote", "shape": "liquid" }
      ],
      "opaque": false,
      "routing_only": false,
      "branch_partitioning": "Approve and Reject hooks split downstream paths; Data.Approval is set on whichever branch fires."
    },
    "configuration_contract": {
      "$ui_partial": "app/views/tasks/partials/_approval.html.erb (zuoraInbox approver list); React tabs for Slack / Webex / Teams / Email / Batch Review live in app/javascript/components/tasks/ApprovalTask.js.",
      "$controller_branch": "fall-through `else` (tasks_controller.rb L800-802) -- accepts `parameters` as an OPAQUE hash; common fields (assignment, error_handler, etc.) ride on common_params.",
      "$model": "app/models/tasks/approval.rb",
      "$model_validation": "task_setup_validation (L15-70) skips when workflow.is_business_process?, otherwise enforces: (1) for zuoraInbox -- approver_emails non-empty AND each value is either '*' or a non-blank Zuora username (valid_username regex L92-94); (2) for non-zuoraInbox -- the matching `<channel>_approver_emails` list is non-empty AND every entry is '*' or a valid email; (3) per-channel field presence (slackEndpoint/Token/Body, webexEndpoint/Token/Body, teamsTenantId/ClientId/ClientSecret/Username/Password/Body, senderEmail+subjectEmail+emailBody). before_save :convert_hash_arrays_to_arrays (L13) re-coerces review_columns_config / row_actions_config from Rails-flattened hashes back to arrays.",
      "$task_process_use": "task_process (L349-452): if business-process workflow, use formated_zuora_approver to derive approvers from selected_approvers and publish to Kafka. Otherwise, in zuoraInbox mode: validate approver usernames, set self.assignment, render approvalNote via Liquid, raise PendingTask until approver/rejecter is set. For other channels: validate ALLOWED_DELIVERY_METHODS (zuoraInbox|slack|webex|email|teams), then dispatch handle_<channel>_delivery. Hooks (L84-86) declare Approve / Reject / Failure (NO Success).",
      "fields": [
        { "$field": "parameters.delivery_method", "purpose": "Notification channel for the approver.", "source": "dropdown-static", "options_source": "ALLOWED_DELIVERY_METHODS = %w[zuoraInbox slack webex email teams] (approval.rb L8)", "controller_permit": "parameters: any-hash (fall-through)", "model_validation": "Defaults to zuoraInbox at runtime when blank (approval.rb L412-413).", "default": "zuoraInbox" },
        { "$field": "parameters.approver_emails", "purpose": "Zuora usernames allowed to approve (zuoraInbox mode). Use '*' to allow any user.", "source": "user-input (multi-tag select pre-populated with current entity's Zuora users)", "required_when": "parameters.delivery_method blank OR 'zuoraInbox' OR parameters.zuoraInbox=='true'", "controller_permit": "parameters: any-hash", "model_validation": "Each entry must be '*' or pass valid_username regex (`^(?!\\s).+(?<!\\s)$`).", "default": "[]", "notes": "Liquid expressions are supported -- entries containing `{{ ... }}` are template_parsed, then split on commas." },
        { "$field": "parameters.approvalNote", "purpose": "Liquid-rendered free-form note shown to approvers (and used as Kafka message description for business-process workflows).", "source": "code-editor (Liquid)", "controller_permit": "parameters: any-hash", "default": "\"\"" },
        { "$field": "parameters.slackEndpoint", "purpose": "Slack incoming-webhook URL (Liquid OK).", "required_when": "parameters.delivery_method == 'slack'", "controller_permit": "parameters: any-hash", "model_validation": "Required non-blank for slack mode (approval.rb L51)." },
        { "$field": "parameters.slackToken", "purpose": "Slack bot OAuth token used to look up user IDs.", "required_when": "parameters.delivery_method == 'slack'", "controller_permit": "parameters: any-hash", "model_validation": "Must satisfy valid_slack_token? (approval.rb L52)." },
        { "$field": "parameters.slackBody", "purpose": "Liquid-rendered message body sent to Slack.", "required_when": "parameters.delivery_method == 'slack'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.slack_approver_emails", "purpose": "External email/identifier list for Slack approvers.", "required_when": "parameters.delivery_method == 'slack'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.webexEndpoint", "purpose": "Webex API URL.", "required_when": "parameters.delivery_method == 'webex'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.webexToken", "purpose": "Webex bot token (validated via valid_webex_token?).", "required_when": "parameters.delivery_method == 'webex'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.webexBody", "purpose": "Liquid-rendered Webex message body.", "required_when": "parameters.delivery_method == 'webex'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.webex_approver_emails", "purpose": "External email list for Webex approvers.", "required_when": "parameters.delivery_method == 'webex'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.teamsTenantId", "purpose": "Microsoft Teams tenant id.", "required_when": "parameters.delivery_method == 'teams'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.teamsClientId", "purpose": "Teams app client id.", "required_when": "parameters.delivery_method == 'teams'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.teamsClientSecret", "purpose": "Teams app client secret.", "required_when": "parameters.delivery_method == 'teams'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.teamsUsername", "purpose": "Teams sender username.", "required_when": "parameters.delivery_method == 'teams'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.teamsPassword", "purpose": "Teams sender password.", "required_when": "parameters.delivery_method == 'teams'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.teamsBody", "purpose": "Liquid-rendered Teams message body.", "required_when": "parameters.delivery_method == 'teams'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.teams_approver_emails", "purpose": "External email list for Teams approvers.", "required_when": "parameters.delivery_method == 'teams'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.senderEmail", "purpose": "Sender address used when delivery_method == 'email'.", "required_when": "parameters.delivery_method == 'email'", "controller_permit": "parameters: any-hash", "model_validation": "Required non-blank (approval.rb L66)." },
        { "$field": "parameters.subjectEmail", "purpose": "Subject line for the email approval request.", "required_when": "parameters.delivery_method == 'email'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.emailBody", "purpose": "Liquid-rendered HTML body for the approval email.", "required_when": "parameters.delivery_method == 'email'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.email_approver_emails", "purpose": "External email list for email approvers.", "required_when": "parameters.delivery_method == 'email'", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.selected_approvers", "purpose": "Array of user objects {core_id, ...} used by business-process workflows; the formatted core_id list is sent on the Kafka approval message.", "source": "form-array (business-process tab)", "visible_when": "workflow.is_business_process?", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.review_items_template", "purpose": "Liquid template producing the rows shown in the business-process batch-review UI.", "source": "code-editor", "visible_when": "workflow.is_business_process?", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.review_columns_config", "purpose": "Column definitions for the batch-review table ([{field, title, width}]).", "source": "form-array", "visible_when": "workflow.is_business_process?", "controller_permit": "parameters: any-hash", "notes": "Rails flattens this to hash form on submit; before_save :convert_hash_arrays_to_arrays restores it to an array." },
        { "$field": "parameters.row_actions_config", "purpose": "Per-row action buttons for batch review ([{value, label, icon}]).", "source": "form-array", "visible_when": "workflow.is_business_process?", "controller_permit": "parameters: any-hash", "notes": "Same hash/array coercion as review_columns_config." },
        { "$field": "parameters.preset_conditions", "purpose": "Preset filter conditions for the batch-review UI.", "source": "form-array", "visible_when": "workflow.is_business_process?", "controller_permit": "parameters: any-hash" },
        { "$field": "parameters.approver", "purpose": "(server-set) Username/email of the user who approved -- written by _approve API.", "source": "computed", "controller_permit": "parameters: any-hash", "notes": "Do NOT pre-populate; emitting this in design-time JSON makes the task look already-handled." },
        { "$field": "parameters.rejecter", "purpose": "(server-set) Username/email of the user who rejected -- written by _reject API.", "source": "computed", "controller_permit": "parameters: any-hash", "notes": "Do NOT pre-populate." }
      ]
    }
  },

  "Upload::FTP": {
    "description": "Upload files to an FTP server.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "UploadFTP",
      "parameters": {
        "host": "<<REQUIRED: ftp host>>",
        "username": "<<REQUIRED: ftp username>>",
        "password": "<<REQUIRED: ftp password>>",
        "directory": "/",
        "selected_files": {},
        "strict_variables": "true"
      },
      "action_type": "Upload::FTP",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["host", "username"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "host", "shape": "liquid" },
        { "from_param": "selected_files", "shape": "object" }
      ],
      "opaque": false,
      "$notes": "Pure side-effect (uploads files to FTP); no Data.* writes."
    }
  },

  "Upload::SFTP": {
    "description": "Upload files to an SFTP server.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "UploadSFTP",
      "parameters": {
        "host": "<<REQUIRED: sftp host>>",
        "port": "22",
        "username": "<<REQUIRED: sftp username>>",
        "auth_type": "password",
        "password": "",
        "private_key": "",
        "directory": "/",
        "files": {},
        "strict_variables": "true"
      },
      "action_type": "Upload::SFTP",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["host", "username"],
    "required_at_import": [],
    "param_enums": {
      "auth_type": ["password", "private_key"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "host", "shape": "liquid" },
        { "from_param": "files", "shape": "object" }
      ],
      "opaque": false,
      "$notes": "Pure side-effect (uploads files to SFTP); no Data.* writes."
    }
  },

  "Upload::S3": {
    "description": "Upload files to an S3 bucket.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "UploadS3",
      "parameters": {
        "bucket": "<<REQUIRED: s3 bucket>>",
        "region": "us-east-1",
        "access_key_id": "",
        "secret_access_key": "",
        "s3_path": "/",
        "files": {},
        "strict_variables": "true"
      },
      "action_type": "Upload::S3",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["bucket"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "bucket", "shape": "liquid" },
        { "from_param": "files", "shape": "object" }
      ],
      "opaque": false,
      "$notes": "Pure side-effect (uploads files to S3); no Data.* writes."
    }
  },

  "Download::SFTP": {
    "description": "Download files from an SFTP server.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "DownloadSFTP",
      "parameters": {
        "host": "<<REQUIRED: sftp host>>",
        "port": "22",
        "username": "<<REQUIRED: sftp username>>",
        "auth_type": "password",
        "password": "",
        "private_key": "",
        "remote_path": "<<REQUIRED: remote path>>",
        "strict_variables": "true"
      },
      "action_type": "Download::SFTP",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["host", "username", "remote_path"],
    "required_at_import": [],
    "param_enums": {
      "auth_type": ["password", "private_key"]
    },
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{remote filename(s)}",
      "writes": [
        { "to_template": "Data.Files.{remote filename}", "shape": "File", "fields": "file blob(s)", "notes": "Each file at parameters.remote_path is registered under Data.Files for downstream tasks (Email, Upload, Attachment, File::*) to consume." }
      ],
      "reads": [
        { "from_param": "host", "shape": "liquid" },
        { "from_param": "remote_path", "shape": "liquid" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "Download::S3": {
    "description": "Download files from an S3 bucket.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "DownloadS3",
      "parameters": {
        "bucket": "<<REQUIRED: s3 bucket>>",
        "region": "us-east-1",
        "access_key_id": "",
        "secret_access_key": "",
        "s3_path": "<<REQUIRED: remote key or prefix>>",
        "strict_variables": "true"
      },
      "action_type": "Download::S3",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["bucket", "s3_path"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "semi-deterministic",
      "default_scope_when_unset": "Files",
      "scope_template": "Data.Files.{remote key/prefix filename(s)}",
      "writes": [
        { "to_template": "Data.Files.{remote filename}", "shape": "File", "fields": "file blob(s)", "notes": "Each S3 object at parameters.s3_path is registered under Data.Files for downstream consumption." }
      ],
      "reads": [
        { "from_param": "bucket", "shape": "liquid" },
        { "from_param": "s3_path", "shape": "liquid" }
      ],
      "opaque": false,
      "fields_partial_known": true
    }
  },

  "UsageMediation::Source": {
    "description": "Source step in a usage mediation pipeline.",
    "hooks": ["next", "error"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Source",
      "parameters": {
        "operator": "source",
        "schema": {},
        "strict_variables": "true"
      },
      "action_type": "UsageMediation::Source",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["operator"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "schema", "shape": "object" }
      ],
      "opaque": false,
      "$notes": "Usage mediation pipelines stream data row-by-row through internal channels - not via Data.* JSONB. The agent should not attempt Liquid {{ Data.X }} resolution across mediation steps."
    },
    "notes": "Usage mediation tasks use lowercase hooks 'next' and 'error' - differs from Success/Failure used elsewhere."
  },

  "UsageMediation::Watermark": {
    "description": "Watermark step in a usage mediation pipeline.",
    "hooks": ["next", "error"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Watermark",
      "parameters": { "strict_variables": "true" },
      "action_type": "UsageMediation::Watermark",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [],
      "opaque": false,
      "$notes": "Mediation pipeline step (streamed); no Data.* writes."
    }
  },

  "UsageMediation::Filter": {
    "description": "Filter step in a usage mediation pipeline.",
    "hooks": ["next", "error"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Filter",
      "parameters": {
        "filter_clause": "<<REQUIRED: filter expression>>",
        "strict_variables": "true"
      },
      "action_type": "UsageMediation::Filter",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["filter_clause"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "filter_clause", "shape": "string" }
      ],
      "opaque": false,
      "$notes": "Mediation pipeline filter step (streamed); no Data.* writes."
    }
  },

  "UsageMediation::Map": {
    "description": "Map step in a usage mediation pipeline.",
    "hooks": ["next", "error"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Map",
      "parameters": {
        "mapping": {},
        "strict_variables": "true"
      },
      "action_type": "UsageMediation::Map",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["mapping"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "mapping", "shape": "object" }
      ],
      "opaque": false,
      "$notes": "Mediation pipeline mapping step (streamed); no Data.* writes."
    }
  },

  "UsageMediation::Group": {
    "description": "Group/aggregate step in a usage mediation pipeline.",
    "hooks": ["next", "error"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Group",
      "parameters": {
        "group_by": [],
        "strict_variables": "true"
      },
      "action_type": "UsageMediation::Group",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [
        { "from_param": "group_by", "shape": "array" }
      ],
      "opaque": false,
      "$notes": "Mediation pipeline group/aggregate step (streamed); no Data.* writes."
    }
  },

  "UsageMediation::Sink": {
    "description": "Sink step in a usage mediation pipeline.",
    "hooks": ["next", "error"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "Sink",
      "parameters": { "strict_variables": "true" },
      "action_type": "UsageMediation::Sink",
      "object": null,
      "object_id": null,
      "call_type": "BATCH",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": [],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "none",
      "writes": [],
      "reads": [],
      "opaque": false,
      "$notes": "Mediation pipeline sink (publishes to downstream); no Data.* writes."
    }
  },

  "Mediation::SendEvents": {
    "description": "Send mediated events to a downstream system.",
    "hooks": ["Success", "Failure"],
    "template": {
      "id": "<<TASK_ID>>",
      "name": "SendEvents",
      "parameters": {
        "meter_id": "<<REQUIRED: meter id>>",
        "json_body": "<<REQUIRED: JSON body with optional Liquid>>",
        "strict_variables": "true"
      },
      "action_type": "Mediation::SendEvents",
      "object": null,
      "object_id": null,
      "call_type": "REST",
      "task_id": "<<UPSTREAM_TASK_ID_OR_NULL>>",
      "css": { "top": "<<TOP>>", "left": "<<LEFT>>" },
      "concurrent_limit": 9999999,
      "tags": [],
      "priority": "Medium",
      "assignment": [],
      "zuora_org_id": null,
      "zuora_org_ids": []
    },
    "required_params": ["meter_id", "json_body"],
    "required_at_import": [],
    "param_enums": {},
    "boolean_string_params": ["strict_variables"],
    "data_contract": {
      "predictability": "opaque",
      "default_scope_when_unset": "SendEvents",
      "scope_template": "Data.{parameters.placement | 'SendEvents'}",
      "writes": [
        { "to_template": "Data.{parameters.placement | 'SendEvents'}", "shape": "object", "fields": "OPAQUE", "notes": "Downstream mediation API response (acks/ids vary). Treat as opaque - declare _expected_response_schema if downstream tasks need to read it." }
      ],
      "reads": [
        { "from_param": "meter_id", "shape": "string" },
        { "from_param": "json_body", "shape": "liquid" }
      ],
      "opaque": true,
      "opaque_prompt_required": true
    }
  }
}

SHA-256: 0c4302f8f355ce21b3995f7749effb6ab334f3959ce9d23f7c8af21f89ed5621