# Surfaces and flows

> A surface is a place in your app; a flow is its versioned recipe. Both move through a lifecycle you control.

Source: https://staging.patterna.nl/docs/surfaces-and-flows

A **surface** is one placement in your app that shows recommendations, such as the article list on Home. A **flow** is the recipe that fills it: which generators run, which filters apply, which ranker and how much exploration. A surface has one active flow version at a time. You change a surface's behaviour by activating another flow version, never by editing the active one.

## Surfaces

| Field | Meaning |
|---|---|
| `key` | stable id used in API calls, e.g. `home_articles` |
| `name` | label in the panel |
| `item_kinds` | which kinds may appear: `document`, `product`, `community`, `event`, `tip` |
| `slots` | how many items a slate has; default 6. A request may ask for 1 to 50 with `slots`. |
| `fallback` | the editorial list: `{ "kind": "newest" }` (default), `{ "kind": "curated", "item_ids": [...] }` or `{ "kind": "popular" }` (7-day popular). Newest fills any gap. Optional `by_anchor`: an object from an anchor item id to the editor's list for that page, for example `{ "kind": "newest", "by_anchor": { "article-12": ["p-1", "p-2"] } }`, see below |
| `exploration_share` | default share of explored requests when the flow sets none; default 0.2, at most 0.5 |
| `status` | `shadow` (default), `testers`, `live` or `off` |
| `min_slots` | set with `PATCH /surfaces/{key}`: 0 to 50, 0 = always shown. A slate shorter than this is answered with `shown: false`. Use 1 to hide a block that has nothing to show |
| `device_resort` | set with `PATCH /surfaces/{key}`: the app may re-sort the slate on the device, see below |

The editorial list has two jobs, and the flag on the slate tells them apart:

- **Control.** In a running holdout, the control arm gets the editorial list, logged with policy `editorial` and `fallback: false`. That is the comparison, not a failure, and it never counts in the fallback rate. On an anchored surface the list can follow the page. In order, control serves: your own list for this page from `context.control_item_ids` (logged `control_source: request`), else the surface's list for this anchor from `fallback.by_anchor` (`control_source: by_anchor`), else the surface's own list (`control_source: surface`). Every source obeys the request's rules: the anchor is never served, and `exclude_item_ids`, joined communities, candidate kinds, the premium policy and locale apply. Newest items that pass the rules fill the rest. `context.control_item_ids` needs no setting on the surface: send it on every request to the surface, in both arms, since you do not know the arm in advance; the treatment arm uses it only to fill a short slate.
- **Fallback.** When a request that should have been ranked times out or hits storage trouble, or the surface has no active flow, Patterna answers with the same list and `fallback: true`. That is the safety net, and it counts in the fallback rate.

## The lifecycle

```
shadow ──▶ testers ──▶ live ──▶ off
```

| Status | What Patterna returns | Arm | What you check |
|---|---|---|---|
| `shadow` | the ranked slate; your app keeps showing your own list | `none` | requests arrive, latency, fallback rate, slates look sane in the preview |
| `testers` | the ranked slate for ids on the testers list, the editorial list for everyone else | `none` | reasons render on device, seen and click reach the trace view by request id |
| `live` | the ranked slate; with a running holdout, control gets the editorial list | `treatment`/`control` during a holdout, else `none` | the ledger, SRM, fallback rate |
| `off` | nothing: `409 surface_off` | | |

The testers list holds Patterna user ids, or session ids in `non_profiled` mode. Your backend can also send the header `X-Patterna-Tester: 1` for its own staff, so your roster decides and nobody needs to look up random ids.

A request can also set `"shadow": true`. Patterna then logs and returns the slate with arm `none`, marked as not shown. The panel's preview uses this.

`off` is enforced by the engine: requests get `409 surface_off`, which your backend treats as "show your own list". Keep your own feature flag as well: it is the instant switch that needs no call at all.

## Flows

A flow config has these parts:

```json
{
  "generators": [
    { "kind": "content_affinity", "weight": 0.5 },
    { "kind": "trending", "weight": 0.3 },
    { "kind": "curated", "weight": 0.2 }
  ],
  "filters": {
    "exclude_seen_days": 7,
    "premium_policy": "cap",
    "premium_cap": 2,
    "per_collection_cap": 3,
    "freshness_days": null,
    "diversity": 0.2
  },
  "ranker": { "kind": "linear" },
  "exploration": { "kind": "plackett_luce", "temperature": 0.7, "share": 0.2 },
  "reasons": {
    "trending": { "nl": "Veel gelezen deze week", "en": "Read a lot this week" }
  }
}
```

- **Generators** score the candidates: `trending` (the 7-day tap rate on this surface, all locales together), `curated`, `item_to_item`, `session_items`, `content_affinity`, `persona_bayes`, `user_bayes`, `user_to_user`, `surface_specific`, and the silent `time_fit` and `format_fit`. Each score is normalised across the candidates and multiplied by the weight.
- **Filters** remove what must not show (expired, past events, out of stock, already seen, premium beyond the policy, too old), leave out what this person turned away or finished (dismissed, completed, paywalled, bought) and cap a slate (per collection, premium). `relevance_floor` hides an anchored block when nothing is related to the page. See [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals). `filters.diversity` (0 to 1) is the one diversity setting: a penalty on items similar to the ones already picked, applied inside the sampler so propensities stay exact. The panel shows it on the ranker node.
- **Ranker** is `linear` (the configured weights), `learned_linear` (per-generator weights the jobs fit nightly from logged taps) or `gbdt` (gradient-boosted trees the jobs fit nightly, evaluated in the engine). `lambdamart` is refused.
- **Exploration** runs on treatment slates and on slates outside a holdout (arm `none`); control is never explored. See [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities).
- **Reasons** are keyed by reason key or generator kind, with placeholders: see [Reasons](https://staging.patterna.nl/docs/reasons).

Every field, default and valid range is in the [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config). In the panel the flow is a node canvas; the JSON view shows the same config.

## One slate, several blocks

Sometimes one page shows the same kind of recommendation twice, for example one product near the top of an article and a carousel of six at the end. Ask for **one** slate of 7 and render position 0 in the first block and positions 1 to 6 in the second. Then the two blocks never repeat each other, and the propensities stay in one impression. Report each item with its slate `position` (0 to 6), whichever block it was in, and send the optional `block` name (up to 32 characters, for example `top` or `end`) on each interaction, so you can tell the blocks apart in the trace. The ledger can also give a row per block (`by=block`), over the slates reported seen in that block; the whole-slate row decides.

Two settings help here:

- **`min_slots`** on the surface. When fewer items than this pass the request's rules (exclusions, the anchor, joined communities, candidate kinds, premium, freshness; counted before the arm, so both arms hide alike), the answer carries `shown: false` and no items: hide the block. No recommendations means no block, so 1 is the usual value; 0 means always shown. The slate is logged `not_shown`, stays in its arm and counts as served with zero success in both arms (intention to treat). Never report it as abandoned: it is already counted, and abandon answers `409 not_shown`.
- **`context.measured`** on the request (default `true`). Set it to `false` when the user did not allow usage analytics and your app will not report seen and taps. Such slates are served as usual but left out of the ledger, power and learning in both arms.

See [Timeouts and what counts in the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger).

## Re-sorting on the device

A surface under a holdout must not be re-sorted by your app unless the surface allows it: the ledger and the estimators read the position Patterna served.

Set `device_resort: true` on the surface (`PATCH /surfaces/{key}`). Your app may then re-sort the slate with a rule of its own, and must send `displayed_position` on every interaction; without it the interaction is refused with `422 displayed_position_required`. The served position comes from the logged slate. The ledger stays intention to treat on served slates: both arms are re-sorted by the same rule, so the comparison holds. The estimators use `displayed_position`.

## Versions, activation and rollback

- **Saving creates a version.** Every save is a new, numbered, inactive version. Nothing changes on the surface.
- **Simulate first.** Run the version on logged traffic to see a SNIPS estimate and the twin curve.
- **Activate.** Activation is audited. A note is optional in the API and required in the panel. On a `live` surface with the two-person rule on, a direct activation answers `409 approval_required` and a second person approves the request.
- **Rollback is activation.** The previous versions stay. Rolling back means activating an older version, one click in Flows.

```curl
curl -sS "https://api.patterna.nl/v1/surfaces/home_articles/flows" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
```

```curl
curl -sS -X POST "https://api.patterna.nl/v1/surfaces/home_articles/flows/3/activate" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{"note": "Back to v3: v4 raised the fallback rate"}'
```

Every slate records its `policy_version` and `flow_version`, so the ledger and the trace view always know which recipe served which request.

## Validation

Saving a flow (`POST /surfaces/{key}/flows`) is refused with `400 bad_request` when:

- there is no generator, or a generator kind is unknown;
- `ranker.kind` is not `linear`, `learned_linear` or `gbdt`;
- `filters.diversity` is outside 0 to 1;
- `exploration.kind` is not `none` or `plackett_luce`, or `exploration.share` is outside 0 to 0.5.

Unknown reason keys, locales and placeholders are refused with `400 invalid_reason_template`. The flow editor shows the same checks on the canvas, plus nodes that cannot be reached.

## Read next

- [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config)
- [Roll back a flow](https://staging.patterna.nl/docs/roll-back-a-flow)
- [Go live in six steps](https://staging.patterna.nl/docs/go-live-in-six-steps)
