> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Node Configuration Reference

> Every UI knob on every Decision Flow node — what it does, how the engine consumes it, how to verify it changed behavior.

This page is the operator-facing reference for every configurable field on every Decision Flow node. For each knob you get:

* **Purpose** — what the field controls.
* **Engine behavior** — exactly what the engine does when this value changes.
* **Verification** — how to prove the field had an effect (test script, doc cross-link, or live API call).

Where a field is covered by an automated test we link the script. Where it's only documented or only tested end-to-end through the Studio UI we say so honestly.

***

## Phase 1 — Narrow

### Inventory node

| Field               | Purpose                                                                  | Engine behavior                                                             | Verification           |
| ------------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------- | ---------------------- |
| `scope`             | Constrain candidate set by category / subcategory / explicit offer list. | `WHERE` clause on the Offer query in `pipeline-runner.ts case "inventory"`. | T1 baseline recommend. |
| `categoryIds[]`     | Restrict to specific categories.                                         | `category_id IN (...)` predicate.                                           | T1, T2.                |
| `subCategoryIds[]`  | Restrict to specific sub-categories.                                     | `sub_category_id IN (...)` predicate.                                       | T1.                    |
| `offerIds[]`        | Whitelist specific offers (bypasses category scope).                     | `offer.id IN (...)` predicate.                                              | UI-tested.             |
| `includeStatuses[]` | Default `["active"]`; allow staged/test offers in development flows.     | Status filter on the Offer query.                                           | UI-tested.             |

### Match Creatives node

| Field                | Purpose                                                                                                                                           | Engine behavior                                                 | Verification              |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------- |
| `requireCreative`    | Drop candidates with no creative.                                                                                                                 | Filter `candidate.creative != null` after Inventory.            | T1.                       |
| `placementMatchMode` | `exact` keeps only creatives whose `placementId` matches request; `any` (default) keeps wildcard creatives too; `none` skips placement filtering. | Branches in `case "match_creatives"` (pipeline-runner.ts:1001). | T1, T8 (atomic coupling). |

### Enrich node

| Field                                | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | Engine behavior                                                                                                                                                                                                                                                                           | Verification                      |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| Decision context (implicit)          | The canonical entity-namespaced context — `customer.*`, `<entity>.*` (active `SchemaJoin`s with `autoEnrich=true`, one-to-many collapsed via the join's `aggregations`), `behavior.*` (behavioral-metric values by slugified metric name), `journey.*` (active enrollment state), `attributes.*` — is assembled automatically before the node loop. The Enrich node is an **override**, not a prerequisite. See [the decision context](/decisioning/decision-flows#the-decision-context-auto-assembled). | `assemblePipelineDecisionContext()` runs before the node loop in `executePipelineV2` (pipeline-runner.ts); the result is the base layer of every candidate's `enrichedData` (Enrich-node output wins on conflicts). Legacy `<rawschemaname>.*` alias keys are still emitted (deprecated). | Engine-tested.                    |
| `sources[].schemaId`                 | Manually attach a schema that isn't auto-joined, or override a join with custom fields.                                                                                                                                                                                                                                                                                                                                                                                                                  | Reads the schema's table, queries by `lookupKey`, prefixes results.                                                                                                                                                                                                                       | T14.                              |
| `sources[].lookupKey`                | FK column to use (default `customer_id`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                | `WHERE "<lookupKey>" = $customerId`.                                                                                                                                                                                                                                                      | UI-tested.                        |
| `sources[].fields[]`                 | Restrict to subset of columns (perf optimization).                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Generates a `SELECT col1, col2 ...` projection.                                                                                                                                                                                                                                           | UI-tested.                        |
| `sources[].prefix`                   | Namespace prefix in `enrichedData` (default `customer`).                                                                                                                                                                                                                                                                                                                                                                                                                                                 | Output keys become `<prefix>.<field>`.                                                                                                                                                                                                                                                    | UI-tested.                        |
| `sources[].cacheTtlSeconds`          | Per-source Redis cache TTL.                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | Cache key `enrich:<tenantId>:<schemaId>:<customerId>:<fields>` — TTL set per source.                                                                                                                                                                                                      | Logged but not asserted in tests. |
| `sources[].optional`                 | Soft-fail when the schema or row is missing.                                                                                                                                                                                                                                                                                                                                                                                                                                                             | `try/catch` around the lookup; missing data → field not set instead of throwing.                                                                                                                                                                                                          | UI-tested.                        |
| `sources[].multiRow` + `aggregation` | Roll up multiple rows (e.g. last-30-day transactions) into one summary.                                                                                                                                                                                                                                                                                                                                                                                                                                  | Triggers GROUP BY + aggregation function per field.                                                                                                                                                                                                                                       | UI-tested.                        |
| `transforms[]`                       | Apply record-level transforms after lookup (hash, mask\_pii, expression, etc.).                                                                                                                                                                                                                                                                                                                                                                                                                          | Each transform mutates the `enrichedData` map in-place.                                                                                                                                                                                                                                   | UI-tested.                        |
| `excludeJoinIds[]` (#168)            | Suppress specific auto-enriched joins for this flow only. UI: click `×` on a green chip.                                                                                                                                                                                                                                                                                                                                                                                                                 | Engine unions `excludeJoinIds` across the flow's Enrich nodes and passes them to `assemblePipelineDecisionContext()`; matching joins are skipped during context assembly.                                                                                                                 | Engine-tested.                    |

### Qualify node

| Field                    | Purpose                                                             | Engine behavior                                                            | Verification                                      |
| ------------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------- |
| `mode = "all"`           | Apply every globally-scoped active QualificationRule.               | Filters `rules.filter(isGlobalRule)`.                                      | T2.                                               |
| `mode = "selected"`      | Apply only the explicit rule IDs (any scope — operator chose them). | `rules.filter(r => ruleIdSet.has(r.id))`.                                  | T2.                                               |
| `mode = "none"`          | Skip qualification entirely.                                        | Early `break`.                                                             | UI-tested.                                        |
| `qualificationRuleIds[]` | The set of rule IDs when `mode = "selected"`.                       | Consumed at line 879 of pipeline-runner.ts.                                | T2.                                               |
| `logic` (AND/OR groups)  | Combinator tree for selected rules.                                 | Recursively evaluates groups; AND requires all-pass, OR requires any-pass. | Logged finding — not exercised in current proofs. |

### Contact Policy node

| Field                | Purpose                                                                                        | Engine behavior                                               | Verification                                                                                    |
| -------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `mode`               | Same shape as Qualify: `all` (every active policy), `selected` (explicit list), `none` (skip). | `filterByContactPolicies()` consumes the filtered policy set. | T3 (cooldown), T4 (frequency cap), T5 (category suppression), T6 (DNC), T7 (metric\_condition). |
| `contactPolicyIds[]` | Explicit policy list when `mode = "selected"`.                                                 | Same dispatch as Qualify.                                     | T3–T7.                                                                                          |

#### Implicit Contact Policy (#169 — engine-injected)

If a flow has **no explicit `contact_policy` node** and the flow's `skipContactPolicy` flag is **not true**, the engine injects an implicit `contact_policy` node with `mode: "all"` at the end of Phase 1. This is a safety rail — frequency caps, DNC, and cooldowns must apply by default. The implicit injection is centralized in `injectImplicitContactPolicy()` and unit-tested at `src/lib/__tests__/implicit-contact-policy.test.ts`.

| Setting                                     | Where              | Default | Effect                                                                                                                                              |
| ------------------------------------------- | ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DecisionFlow.skipContactPolicy`            | Flow-level boolean | `false` | When `true`, the engine does NOT inject the implicit node. Use only for flows that must bypass safety rails (transactional, OTP, system-of-record). |
| Explicit `contact_policy` node in `nodes[]` | Flow editor        | —       | When present (any `mode`), the engine respects it as-is — no additional implicit injection.                                                         |

### Filter node

| Field                   | Purpose                                                                                                                         | Engine behavior                        | Verification |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------ |
| `conditions[].field`    | Path into `enrichedData` or candidate attributes.                                                                               | Resolved via dot-path lookup.          | UI-tested.   |
| `conditions[].operator` | One of 13: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `contains`, `starts_with`, `regex`, `is_null`, `is_not_null`. | Pure boolean evaluation per candidate. | UI-tested.   |
| `conditions[].value`    | RHS of comparison.                                                                                                              | Type-checked at evaluation.            | UI-tested.   |
| `combinator`            | `AND` requires all conditions true; `OR` requires any.                                                                          | Boolean reduction.                     | UI-tested.   |

### Conditional (split) node

| Field                         | Purpose                                                                                                            | Engine behavior                                                            | Verification |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- | ------------ |
| `conditions[]` + `combinator` | Same syntax as Filter — the predicate that decides which branch to take.                                           | Evaluated per candidate.                                                   | UI-tested.   |
| `trueBranchFlowId`            | Sub-flow to execute for matching candidates.                                                                       | Loaded + executed via `executePipelineV2` (subject to depth/cycle guards). | UI-tested.   |
| `falseBranchFlowId`           | Sub-flow for non-matching candidates.                                                                              | Same dispatch. Optional.                                                   | UI-tested.   |
| `keepNonMatching`             | When `false` (default), non-matching candidates are filtered out. When `true`, they pass through to the next node. | Branches the rejection path.                                               | UI-tested.   |
| `label`                       | Operator-facing label rendered on the canvas.                                                                      | Display-only.                                                              | n/a.         |

***

## Phase 2 — Score & Rank

### Score node — the most-configurable node in the flow

See [Scoring Strategies](/decisioning/scoring-strategies) for the operator-facing decision guide. Reference of every field:

| Field                                              | Purpose                                                                                                                                                                                                                                                     | Engine behavior                                                                                                                                                                                                                                                   | Verification                                     |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ |
| `method`                                           | The base strategy: `priority_weighted`, `propensity`, or `formula` (PRIE).                                                                                                                                                                                  | Routes through one of three branches in `pipeline-runner.ts:1734-1995`.                                                                                                                                                                                           | T15 (3 sub-cases).                               |
| `modelKey` / `defaultModel`                        | Which AlgorithmModel the `propensity` and `formula` methods use when no adaptation data is present.                                                                                                                                                         | Resolved by `resolveScoreConfig()` then loaded via `prisma.algorithmModel.findFirst`.                                                                                                                                                                             | T15 sub-case 2.                                  |
| `formula`                                          | Inline PRIE weights `{propensityWeight, relevanceWeight, impactWeight, emphasisWeight}`. Must sum to 1.0.                                                                                                                                                   | Used in `case "formula"` line 1879. Profile mapping overrides if `strategyProfileId` set.                                                                                                                                                                         | T15 sub-case 3.                                  |
| `overrides[]` (per-scope ML model)                 | Pick a different AlgorithmModel per (offer, channel), per (category, channel), per (subcategory, channel), per offer, per category, per subcategory, or per channel. A matching override wins over `defaultModel`/`modelKey` and legacy `channelOverrides`. | `resolveOverrideModelForCandidate()` / `resolveModelForCandidate()` walk the priority chain (default: offer\_channel → category\_channel → subcategory\_channel → offer → category → subcategory → channel → default); composite keys are `<axisId>:<channelId>`. | T15 sub-case 8; Track B live proof (2026-07-26). |
| `overridePriority[]`                               | Override the default scope priority.                                                                                                                                                                                                                        | Drives the ladder walk in `scoring-resolver.ts`.                                                                                                                                                                                                                  | UI-tested.                                       |
| `championChallenger.enabled`                       | Activate A/B model routing.                                                                                                                                                                                                                                 | `selectVariantPersistent()` deterministically routes per `customerId` based on weights.                                                                                                                                                                           | UI-tested (Critical path #147).                  |
| `championChallenger.champion.{modelKey, weight}`   | The currently-serving model.                                                                                                                                                                                                                                | Weight gets normalized against challengers.                                                                                                                                                                                                                       | UI-tested.                                       |
| `championChallenger.challengers[]`                 | Models being tested at fractional traffic.                                                                                                                                                                                                                  | Weighted choice via FNV-1a hash of `customerId + ":cc"`.                                                                                                                                                                                                          | UI-tested.                                       |
| `channelOverrides[]` (deprecated, kept for compat) | Per-channel different method/model/formula.                                                                                                                                                                                                                 | `resolveScoreConfig()` consults this list first when channelId matches.                                                                                                                                                                                           | T15 sub-case 6.                                  |
| `strategyProfileId`                                | Default RankingProfile whose weights map to PRIE (`conversion → Wp`, `recency → Wr`, `margin → Wi`, `fairness → We`).                                                                                                                                       | Replaces `formula` field when set; resolution at line 1857.                                                                                                                                                                                                       | T15 sub-cases 4–5.                               |
| `strategyOverrides[]`                              | First-match-wins profile selector by `productType` / `category` / `channel`.                                                                                                                                                                                | Loops at line 1842; first matching scope wins.                                                                                                                                                                                                                    | T15 sub-case 7.                                  |
| `shadowModelKeys[]`                                | Models that ALSO score every candidate in parallel — recorded only, NEVER affects ranking.                                                                                                                                                                  | `applyShadowScoring()` runs after the active path.                                                                                                                                                                                                                | UI-tested.                                       |

#### Engine-wide settings that affect Score behavior

These live on `Tenant.settings` and apply to every flow's Score node:

| Setting                     | Default | Purpose                                                                                                                                                                                                                                |
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `propensitySmoothingWeight` | `10`    | Blend weight when offer has 1–49 outcomes — blends learned rate with category/global prior.                                                                                                                                            |
| `propensityScoreFloor`      | `0.05`  | Minimum propensity component. Prevents starvation when an offer has 50+ negative-only outcomes (raw `positiveRate = 0`). Clamped to `[0, 0.5]`. Verified live in T20 Test C — Platinum scored `0.0` before the floor and `0.05` after. |
| `modelMaturityThreshold`    | `100`   | Below this evidence count, scores are scaled down (maturity ramp).                                                                                                                                                                     |
| `rankingInfluencersEnabled` | `true`  | When true, sibling-offer outcomes influence a candidate's score via category propagation.                                                                                                                                              |

### Algorithm models (referenced from `modelKey`)

| `AlgorithmModel.modelType`               | Math                                         | Engine entry-point                   | Verification |
| ---------------------------------------- | -------------------------------------------- | ------------------------------------ | ------------ |
| `scorecard`                              | Weighted rules table → sigmoid normalization | `scoreScorecard`                     | T16.         |
| `bayesian`                               | Naive Bayes with Laplace smoothing           | `scoreBayesian`                      | T16.         |
| `logistic_regression`                    | Weighted sum → sigmoid                       | `scoreLogisticRegression`            | T16.         |
| `gradient_boosted` (operator alias: AGB) | Tree-ensemble margin → sigmoid               | `scoreGradientBoosted`               | T16.         |
| `thompson_bandit`                        | Beta-Bernoulli posterior sampling            | `scoreThompson`                      | T16.         |
| `epsilon_greedy`                         | ε-greedy exploitation with decay             | `scoreEpsilonGreedy`                 | T16.         |
| `online_learner`                         | Online SGD on numeric features               | `scoreOnline`                        | T16.         |
| `neural_cf`                              | Two-tower embeddings → MLP → sigmoid         | `scoreNeuralCF`                      | T16.         |
| `external_endpoint`                      | HTTP POST to operator-hosted service         | `scoreOfferSetExternal` (async path) | UI-tested.   |
| `onnx_imported`                          | ONNX runtime inference                       | `scoreOnnxImported` (async path)     | UI-tested.   |

### Optimize node (deprecated — folded into Score's strategyProfileId)

The Optimize node is kept for back-compat and now acts as a passthrough. Use `Score.strategyProfileId` instead.

### Rank node

| Field                        | Purpose                                                                                                                                                                                                                                                                                 | Engine behavior                                                         | Verification |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------ |
| `method = "topN"`            | Take the top N by score.                                                                                                                                                                                                                                                                | Sort by score descending, take first N.                                 | T1, T2.      |
| `method = "diversity"`       | Greedy diversity selector — penalizes consecutive same-category picks.                                                                                                                                                                                                                  | MMR-style selection at line 2202.                                       | UI-tested.   |
| `method = "round_robin"`     | Round-robin across categories until N reached.                                                                                                                                                                                                                                          | Round-robin pointer scan.                                               | UI-tested.   |
| `method = "explore_exploit"` | ε-greedy mix of top-N with random N′. **Default `explorationRate` is `0.1`**, so \~1 in 10 slots is a deliberately random pick from *outside* the top scores — even at cold-start. Expect a "wrong-looking" offer in the tail; set `explorationRate: 0` to disable until you have data. | Uses `explorationRate` to decide per-slot.                              | UI-tested.   |
| `maxCandidates`              | The N in top-N. **Defaults to `5`** — a flow with 20 eligible offers returns only 5, and the rest show up in the debug trace as `truncatedByRank`, not as disqualifications. Raise it to return more.                                                                                   | Result slice length.                                                    | T1.          |
| `maxPerCategory`             | Diversity cap.                                                                                                                                                                                                                                                                          | Hard ceiling — drop further candidates from a category once cap is hit. | UI-tested.   |
| `maxPerChannel`              | Same idea per channel.                                                                                                                                                                                                                                                                  | Same enforcement.                                                       | UI-tested.   |
| `explorationRate`            | ε in `explore_exploit` (default `0.1`).                                                                                                                                                                                                                                                 | Decides exploit-vs-explore per slot.                                    | UI-tested.   |

<Note>
  At **cold-start** (no interaction data yet), `propensity`/`formula` scoring returns a flat `0.5` for every candidate. The rank node breaks these ties by descending `Offer.priority`, so results still order by business priority rather than arbitrarily. See [How Scoring Works](/decisioning/how-scoring-works).
</Note>

### Group node

<Note>
  **Rank and Group are mutually exclusive.** A flow may contain a `rank` node **or** a `group` node, not both — the pipeline validator rejects a flow with both (`RANK_AND_GROUP_CONFLICT`, `pipeline-validator.ts`). Use `rank` for single-placement top-N; use `group` for multi-placement allocation across zones. (A `rank` node upstream of `group` throttles the candidate pool and starves placements.)
</Note>

| Field                                      | Purpose                                                                                                                                                                                                                                                                | Engine behavior                                                                                      | Verification                                      |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `allocationStrategy = "optimal"` (default) | Hungarian (Kuhn-Munkres) global assignment — maximizes total score with per-offer uniqueness across placements.                                                                                                                                                        | `optimalAllocate()` in `pipeline-group.ts` builds the cost matrix and runs the `hungarian()` solver. | T1, T8 (atomic coupling cascade), T9 (vs greedy). |
| `allocationStrategy = "greedy"`            | Fills placements in config order, taking the highest-scoring unassigned candidate per slot. Faster than optimal but does not globally maximize total score. Still enforces per-offer uniqueness — an offer used by an earlier placement is skipped (`usedOffers` set). | `greedyAllocate()`.                                                                                  | T9.                                               |
| `allocationStrategy = "priority_fill"`     | Fill slots in config order until done.                                                                                                                                                                                                                                 | Routes to `greedyAllocate()` — `priority_fill` and `greedy` share one allocator.                     | UI-tested.                                        |
| `placements[]` (optional override)         | A/B-test slot counts without forking the flow. When absent, slots derive from `request.placements[] × Placement.maxSlots`.                                                                                                                                             | The override merges with the placement catalog in `case "group"`.                                    | T1.                                               |
| `allowPartial`                             | **Deprecated** — channel coupling (below) is the supported lever.                                                                                                                                                                                                      | No-op (logs a one-time deprecation warning when set to `false`).                                     | n/a.                                              |

#### Channel coupling — the supported atomicity lever

Within-channel atomic coupling replaces the deprecated `allowPartial`. After the Group node allocates candidates to placements, `applyChannelCoupling()` (`pipeline-runner.ts:503`) runs a post-allocation pass:

| Aspect           | Behavior                                                                                                                                                                                                                                         |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Per-channel mode | Each placement's **Channel** carries `couplingMode` — `"partial"` (default) or `"atomic"`.                                                                                                                                                       |
| Effective mode   | `DecisionFlow.couplingOverride` (when set to `"atomic"` or `"partial"`) **beats** `Channel.couplingMode`. Resolution order: flow override → channel mode → `"partial"`.                                                                          |
| `"atomic"`       | If **any** placement in a channel is empty, the engine empties **all** of that channel's placements (all-or-nothing per channel).                                                                                                                |
| `"partial"`      | Placements are independent — an empty one does not affect its siblings.                                                                                                                                                                          |
| Trace            | The decision is recorded in `traceSummary.channelCoupling[]` as `{ channelId, channelName, mode, cascaded, emptyPlacements }`, so consumers can distinguish "suppressed because a sibling was empty" from "this placement was never configured." |

**Cross-channel coupling is intentionally not supported** — different channels are independent attention surfaces. Cross-channel suppression comes only from `do_not_contact` (unconditional), the consent stage, and `cross_channel_cap` (by volume).

***

## Phase 3 — Output

### Compute node — runs in any phase

The compute executor doesn't care about phase. What changes is which candidate set the formulas evaluate against. See [Computed Values](/tutorials/computed-values) for formula syntax.

| Field          | Purpose                                                                                                 | Engine behavior                                                                                | Verification |
| -------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------ |
| `overrides[]`  | Per-candidate computed fields named to OVERRIDE an existing category-level computed field.              | Evaluates formula then writes to `candidate.personalization[name]`, replacing any prior value. | T13.         |
| `extras[]`     | Per-candidate computed fields with new names (don't collide with category-level fields).                | Same as overrides but appends.                                                                 | T13.         |
| `transforms[]` | Record-level transforms on `personalization` after computed fields fire — e.g. `mask_pii`, `cast_type`. | Applied to the merged personalization map.                                                     | UI-tested.   |

**Placement choice** (T19):

* Phase 1 (after Enrich) → applies to every candidate that survived Qualify + Contact Policy. Use this when a downstream node (Score, Filter) needs to read the computed value.
* Phase 3 (after Rank/Group) → applies only to the final top-K. Use this for render-only personalization (greetings, computed credit limits, conditional CTA text).

### Set Properties node

| Field                          | Purpose                                                      | Engine behavior                                   | Verification |
| ------------------------------ | ------------------------------------------------------------ | ------------------------------------------------- | ------------ |
| `properties[].key`             | Property name surfaced in response under `properties.{key}`. | Direct assignment.                                | UI-tested.   |
| `properties[].value` (literal) | Static value.                                                | Written verbatim.                                 | UI-tested.   |
| `properties[].formula`         | Computed value via the same formula engine compute uses.     | `evaluateFormula()` over the candidate's context. | UI-tested.   |

### Response node

| Field                         | Purpose                                                | Engine behavior                                | Verification                     |
| ----------------------------- | ------------------------------------------------------ | ---------------------------------------------- | -------------------------------- |
| `includeDebugTrace`           | Include the full per-stage trace in the response body. | Adds `trace` to the response.                  | T2, T3, all trace-bearing tests. |
| `responseFormat = "standard"` | Default flat shape.                                    | One offer per response entry.                  | T1, T10.                         |
| `responseFormat = "grouped"`  | Group by placement.                                    | Wrap entries under `placements.{placementId}`. | T1, T8.                          |

***

## Cross-phase nodes

### Call Flow node

<Note>
  A `call_flow` node must sit in **Phase 1 or Phase 2** — the validator rejects it in Phase 3 (`CALL_FLOW_WRONG_PHASE`). Sub-flow nesting is capped at depth 2 and circular references are rejected (`CALL_FLOW_MAX_DEPTH` / `CALL_FLOW_CIRCULAR`, `validateCallFlowDepth`).

  **Where that is enforced:** on `POST` and `PUT` to `/api/v1/decision-flows`, which return `422` naming the offending path (`A → B → A`). The check is **transitive** — a flow is refused when a cycle is reachable through anything it calls, not only when it closes the loop itself. Since a target must exist before another flow can point at it, a cycle is nearly always closed by an *edit*, so `PUT` is the path that matters most.
</Note>

<Warning>
  `optional` defaults to **true**, and it governs more than a missing sub-flow: with `optional` unset, a depth-exceeded or circular reference detected at run time is **skipped with a log line**, and the decision is returned without that branch. Set `optional: false` on a sub-flow whose contribution is required, so the run fails loudly instead of quietly producing a different answer.
</Warning>

| Field                   | Purpose                                                           | Engine behavior                                                                                                                                 | Verification |
| ----------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| `flowId`                | Target DecisionFlow\.id.                                          | Loads the target flow, runs via `executePipelineV2`.                                                                                            | UI-tested.   |
| `passContext`           | Forward request attributes + enrichedData to sub-flow.            | When true, sub-flow inherits parent context.                                                                                                    | UI-tested.   |
| `mergeMode = "replace"` | Sub-flow result replaces parent candidates.                       | Standard.                                                                                                                                       | UI-tested.   |
| `mergeMode = "append"`  | Sub-flow result is unioned with parent candidates.                | Concatenation.                                                                                                                                  | UI-tested.   |
| `optional`              | Soft-fail if sub-flow missing **or unreachable**. Default `true`. | Try/catch around dispatch; also governs the depth and circular-reference branches, which skip rather than throw when `optional` is not `false`. | UI-tested.   |

### Extension Point node

| Field                  | Purpose                                                                                                                      | Engine behavior                                   | Verification       |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | ------------------ |
| `hookName`             | Lifecycle stage: `pre_score` / `score_override` / `post_rank`. Operator-facing label only — runtime treats them identically. | Recorded in trace; visible in flow editor.        | T17.               |
| `label`, `description` | Operator-facing strings.                                                                                                     | Display-only.                                     | n/a.               |
| `configured`           | Gate. When `false`, node is a no-op (safe placeholder in templates).                                                         | Early return at `pipeline-runner.ts:2533`.        | T17 case 1.a.      |
| `subFlowId`            | DecisionFlow\.id of the linked sub-flow.                                                                                     | Loaded with `status: "active"` filter + V2 check. | T17 cases 2.a–3.b. |
| Implicit depth cap (2) | DoS hardening — sub-flows can nest at most 2 levels deep.                                                                    | Skip at line 2542.                                | T17 case 2.c.      |
| Implicit cycle break   | A → B → A loops are detected via `visitedFlowIds` set.                                                                       | Skip at line 2547.                                | T17 case 3.a.      |

***

## Verification index

Each test below is reproducible from a clean checkout. Scripts run via `jiti` so they don't need the Next.js dev server.

| Test | Script                                              | What it locks                                                                                                                      |
| ---- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| T13  | `platform/scripts/test-compute-personalization.mts` | 22 compute-node personalization patterns through the live `evaluateFormula`.                                                       |
| T15  | `platform/scripts/proof-scoring-e2e.mts`            | All three scoring methods + channelOverrides + strategyOverrides + resolveModelForCandidate routing — through live engine helpers. |
| T16  | `platform/scripts/proof-algorithms-e2e.mts`         | Each of the 10 scoreable algorithm types (9 configurable model types + ONNX import) loads + scores against the same input.         |
| T17  | `platform/scripts/proof-extension-point.mts`        | All 7 gate/guard branches of the extension\_point dispatcher.                                                                      |

Coverage gaps tagged "UI-tested" above are exercised through the studio recommendation preview + decision-traces UI but don't yet have committed scripts. Adding them is the operator-friendly way to close the audit — each row gets a script + a doc cross-link.
