# Changelog

> What changed in the engine, SDKs, jobs, contract and docs, newest first.

Source: https://staging.patterna.nl/docs/changelog

Newest first. Each entry says what changed and what it means for you.

## 2026-10-14: engine 0.3.3 (round 8)

- Hosting: Patterna moved to its own server on 15 October 2026 with its own domain. The API is `https://api.patterna.nl/v1`, the panel and these docs `https://patterna.nl`; staging is `api-staging.patterna.nl` and `staging.patterna.nl`. The previous `*.orven.am` names keep working (the API names as aliases, the panel names as redirects) so nothing built against them breaks; update your base URL when convenient.
- Engine and contract 0.3.3 (`engine_version` on `/health`). New generator `vector_retrieval`: per request the engine builds one query vector from what the person looked at (the session's items in every mode, the recent positives in personalized mode, with `session_items`' weights) and reads the `params.k` nearest items by content (default 100, at most 500) from a vector index over the catalog's embeddings; they join the candidates with their cosine as the score, so an item nothing else proposed can be recommended. Reason `similar`. `model_versions` names the embedding model as `embeddings`.
- Real-time session state: with Redis configured, a session's reports (`POST /interactions`) and signed `rec_*` / `item_engaged` events are kept for two hours with the session's running content vector, and the next slate of the session reflects them. Without Redis, or when it is down, the session is read from the log as before. Nothing new to send; `context.session_items` still comes first.
- Flow editor: the generator with its `k`; the signals cards and docs describe it in English and Dutch.
- New generator `sequence_model`: a small neural network trained every night on the order in which people open items, exported to ONNX and evaluated inside the engine (ONNX Runtime, no Python at serve time). It reads what this session looked at and, for personalized users, their last 50 items, and scores its best `top_items` (default 100) as the likely next ones, a shortlist that sits above every other item like `user_bayes`'s. Every mode, reason `similar`. The jobs activate a model only when it beats the plain path rule on held-out data; without a model or without the ONNX Runtime library on the engine host the generator scores nothing and the flow serves as before. `model_versions` carries `sequence`.
- Nothing new to send; the contract adds the kind to the generator enum only.
- A storage failure on the key or session lookup now answers `503 unavailable` with `Retry-After: 1` instead of `401 unauthorized`, and a serve request whose safety net could not read answers the same instead of `500`. Read 503 as "retry or fall back", never as a bad key. The SDKs already fall back on any non-200 from `recommend`.
- The engine's 60-second catalog refresh reads only what changed since the last one (new, changed, unpublished, deleted and re-tagged items), so a large catalog no longer costs a latency spike once a minute or gigabytes of memory; the first request after a start still builds the cache once. Several engine instances may now start with `AUTO_MIGRATE=true` at the same time (the migration takes a lock).

## 2026-10-14: engine 0.3.2, the audit round (r8-issues)

- Flow switch `ranker.gap_blend` (default true): a flow can turn the round-7 return-visit blend off, so a course or a playlist keeps its session weight on every visit. In the flow editor's ranker node and the signals grid; the gap bucket is still logged.
- The gradient-boosted ranker reads the visit gap (`ctx:gap:visit|hours|days|weeks|unknown`) as a context feature on both sides, so it can tell a return visit from an in-session request. Replay numbers in the Gorse comparison, "Round 8".
- `ranker.kind: learned_linear` now applies the nightly ranker's `feature_weights` (the item outcome rates fitted beside the generator multipliers), logged per candidate as `learned_features`; until now only the per-generator weights were read.
- Settings: the Learning window (`learn.history_days`, 30 to 730 days; empty = deployment default) is edited on the Recommendations tab.
- Example mode: the attribution card works on the example set. The demo seed and the weekly twin write `generator_attribution` rows flagged synthetic (engine migration 0020), and `GET /proof/attribution?include_synthetic=true` returns them, never mixed with real rows.
- Imports keep an editor's label: the jobs' item sink preserves `attributes.editor_label` across runs, as the engine does on `PUT /catalog/items`.
- Contract: `contracts/schema.sql` now carries the notifications, key usage and account tables (memberships, invites, password resets, sign-in attempts, mail outbox).

## 2026-10-13: SDKs 0.4.1

- SDKs 0.4.1 (Go, TypeScript, Python): `GetItem(ctx, kind, id)` / `getItem(kind, id)` / `get_item(kind, id)` reads one catalog item (`GET /catalog/items/{external_id}?kind=`, ingest key) with its `attributes`, `tags`, `status` and `enrichment`, so a backend can hydrate product cards from the catalog without a raw HTTP call. New sentinel `ErrItemNotFound` (`item_not_found`, 404, catalog) next to `ErrUnknownItem` (`unknown_item`, 422 from `/interactions`); the SDK docs had the two swapped. No wire changes: 0.4.0 keeps working. Install: npm `patterna-sdk@0.4.1`, Go `go get github.com/payam-h/patterna-sdk/go@v0.4.1` (tag `go/v0.4.1`), Python from tag `v0.4.1`.

## 2026-10-14: engine 0.3.2 (round 7)

- Engine and contract 0.3.2 (`engine_version` on `/health`). New generator `user_to_user`: what the person's nearest users (learned nightly from the same factorisation as `user_bayes`) did last, personalized mode only, weight off by default; its params `recent_items` and `half_life_days`. Reason `people_like_you` now also names it.
- Return visits: the engine logs the bucketed time since the person's previous visit (`gap` in the impression context: `visit`, `hours`, `days`, `weeks`, `unknown`) and, when a flow carries `session_items` next to a long-term generator, fades the session signal and raises the long-term ones on a return after hours or days. The blended weights are logged with the slate; nothing new to send.
- `session_items` defaults to `sequence: both`: the directed next-item shares where a session item has them, its neighbours otherwise; `next` and `neighbours` stay selectable.
- Exclusions read the whole history: `exclude_consumed_days` goes up to 3650 (0 keeps the rule off), fed by a nightly `user_consumed` set. Cold items reach `item_to_item` through their nearest established items by content.
- The learned gbdt ranker reorders the linear ranker's top 60 inside their score range instead of rescoring the whole pool; the ALS shortlist keeps every listed item above every unlisted one. On the public MovieLens benchmark the round-3 flow goes from 0.086 to 0.12 HitRate@10, the in-session flow to 0.21–0.22, return visits over an hour from 0.083 to 0.10 (offline, no live A/B yet).
- Settings: `learn.history_days` (30–730) sets how far back the collaborative learners read.

## 2026-10-13: engine 0.3.1, SDKs 0.4.0 published

- Engine and contract 0.3.1 (`engine_version` on `/health`). The contract now describes `429 quota_exceeded` with its `details` (`quota`, `used`, `resets_at`), makes `occurred_at` on interactions optional (the engine stores the receive time when absent), and says what the code does for `weekday` (UTC when absent), `tenure_days` (logged as a bucket, not ranked on) and `member_of` (every mode). The OpenAPI file is binding; if a page disagrees, the page is wrong.
- SDKs 0.4.0 are installable: npm `patterna-sdk@0.4.0` (unscoped), Go `go get github.com/payam-h/patterna-sdk/go@v0.4.0` (tag `go/v0.4.0`), Python from tag `v0.4.0` (not on PyPI, by decision). They type `weekday`, `subscription_tier`, `tenure_days`, the catalog attributes and the new events.
- Docs: one version scheme (engine 0.3.1, SDK 0.4.0); the Elvou import page is no longer public; the glossary gains round, tenant seed, Example mode, `SERVE_BUDGET`, `device_resort`, `blocks_shown`, pre-register, minimum detectable effect, shadow (status versus request flag), quota and `baseline_rate`; `llms-full.txt` has absolute links and a header listing its sections.

## 2026-10-13: engine 0.3.0 (round 5), the signals

The engine now reads every input listed on [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals), and nothing else. Contract 0.3.0 (unchanged version; the additions are optional fields), engine migration 0012.

- **Request context.** `weekday` (0 = Monday), `subscription_tier` and `tenure_days` next to `hour_bucket`, `platform`, `region` and `session_items`. Bad values answer `400`. The context is logged with the impression for the learners and never stored on the user.
- **Catalog attributes the engine reads.** `format`, `duration_min` or `reading_time_min`, `difficulty`, `author`, `available` or `in_stock`, `expires_at`, `starts_at`, `repurchase_days`, next to `region` and `phase_fit`.
- **Hard rules, the same in both arms.** An item past `expires_at`, an event past `starts_at` and a product with `available: false` are never served (`filters.hide_unavailable`, on by default). `min_slots` counts on them.
- **Personal exclusions, relaxed when nothing else is left.** Dismissed or unpinned items stay out for `exclude_dismissed_days` (30), completed items for `exclude_consumed_days` (30, personalized), an item that paywalled a free user for `paywall_cooldown_days` (7), a bought product inside `repurchase_days` (30; the item's own attribute wins). The fallback fill honours them too.
- **Generators `time_fit` and `format_fit`.** The item's tap rate in the request's time bucket (else a duration rule), and the user's learned format preference (personalized). Both are silent: they move scores and never name a reason.
- **`filters.relevance_floor`** (0 to 1, off by default): on an anchored request only items similar enough to the anchor count toward `min_slots`, judged the same in both arms, so a block with nothing related hides.
- **Truthful reasons.** A slot names its winner only when the winner's own signal is positive; "trending" needs the surface's own log (before that it says "popular"), "people like you" a real persona, "new" an item under 30 days. Otherwise the new reason key `recommended` ("Aanbevolen" / "Recommended"). See [Reasons](https://staging.patterna.nl/docs/reasons#the-truthfulness-rule).
- **`pairs_with` only where it is true**: a recipe or a workout with a product, a community with a document, each with its own default text. Other cross-kind anchors say `more_about_tag`.
- **`curated` is a boost by rank** (the first item +2), not a z-score; a kind whose catalog has one collection is no longer capped per collection.
- **Ranker features by name** in `gbdt`: `engaged_rate`, `pullback_rate`, `activity_rate`, `purchase_rate`, the time-bucket tap rate, the hour, the weekend and the platform. Older models keep scoring.
- **Events** `community_post_created`, `community_reply`, `community_reaction` feed a community's activity rate. The learner's weight for every event, including `push_opened`, community participation, search picks and orders per product, is now listed in [Event types](https://staging.patterna.nl/docs/reference-event-types).
- **Products from a shop.** The jobs sync a Shopify store's public `products.json` feed (text, tags, price, stock, image; no API token) and read a Shopify orders export into co-purchase pairs without reading any customer field. See [Go live in six steps](https://staging.patterna.nl/docs/go-live-in-six-steps#products-from-your-shop).
- **SDK 0.4.0** types the new context fields, the catalog attributes, builders for the new events, the new flow filters and generators, and `recommended`. SDK 0.3.0 still works with this engine: it sends the new context fields in `context` as given.
- Docs: a new page, [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals); the event reference gets a weight per event (fixed in the learner, not a per-tenant setting); the flow config reference gets the ten generators and the new filters; trending is described as per surface (all locales together), which is what it always was.

## 2026-10-13: engine 0.3.0 (round 4) and SDKs 0.3.0

- Contract 0.3.0; `engine_version` on `/health` reports `0.3.0`.
- `context.region`: one of the region names you configure in Settings (coarse, such as a province; never coordinates; `400 unknown_region` otherwise). Items carry a `region` too, and `filters.region_boost` (0.3 by default) lifts same-region items; other items are never hidden.
- `context.session_items` and the `session_items` generator: neighbours of what the session just looked at, in every mode.
- Trending counts a tap followed by an engaged view three times; `engaged_rate` per item.
- `ranker.bandit`: a Thompson-sampling mixture over the generator weights, fitted nightly per surface; the drawn weights are logged, so propensities stay exact. Never in the control arm.
- No exploration on slates with `measured: false`.
- A monthly request quota per plan: a warning at 90 %, then `429 quota_exceeded`.
- SDKs 0.3.0 (Go, TypeScript, Python): `control_item_ids`, `member_of`, `control_source`, `fill_count` and `fill`, `shown` on every result (a `shown: false` slate is never abandoned), `mode_required` and `not_shown` errors, coach product events, `patterna surface apply`, ledger by block. Go: `go get github.com/payam-h/patterna-sdk/go@v0.3.0`; TypeScript and Python install from the repository at tag `v0.3.0`.

## 2026-10-13: engine 0.3 (contract 0.2.0)

Released. The contract is version 0.2.0; `engine_version` on `/health` reports that API version.

- Anchored control: control serves, in order, your `context.control_item_ids` (up to 50, no surface setting needed), else the surface's `fallback.by_anchor` list for the anchor (an object from anchor item id to item ids), else the surface's list. The request's rules apply (the anchor, `exclude_item_ids`, joined communities, kinds, premium, locale); newest items fill the rest. The answer says which in `control_source` (`request`, `by_anchor`, `surface`).
- `not_shown` slates count in their arm as served with zero success (intention to treat), in both arms; a `shown: false` answer carries no items, and abandon on it answers `409 not_shown`. `min_slots` is counted before the arm, from the items that pass the request's rules.
- Fallback fill: a treatment slate that comes out short is filled from the editorial list, like control. `fill_count` is on the answer and the impression; each filled slot carries `fill: true` and propensity 1 in the trace.
- `stratify_by: "persona"` is applied: the arm is drawn within each persona; users without one form their own stratum. Optional `strata_shares` per stratum.
- `readable` follows the power calculation (`readable_sample` per arm) instead of a fixed 200 units.
- Per-block ledger rows (`by=block` or `block=`) when interactions carry `block`.
- `model_versions` on every impression, in trace and export.
- Learning: the slate learners train on arms `treatment` and `none`, never control, from the interactions you report (joined to the slate by `request_id`) and signed `item_completed` events for served items; taps and dismissals on served slates also reach the per-user and per-item models. Filled slots are never ranking evidence.
- Opt-out (`PUT /users/{id}/mode` with `non_profiled`, or `consent_changed`): the request deletes the user row, keeps only a salted hash as the opt-out marker and files a deletion request holding only that hash; the hourly job unlinks the user's impressions (interactions stay with their slate) and deletes events, assignments and the shortlist. Serve treats a personalized or cohort request for that id as `non_profiled` and logs it without the id (audited once a day as `serve.optout_downgrade`) until the mode is set again.
- `PUT /users/{id}/profile` without `mode`: an unknown user gets `422 mode_required`, an opted-out user `422 mode_conflict`; a known user keeps the current mode.
- `context.member_of`: communities the user already joined are excluded in every mode.
- Events `coach_product_shown` and `coach_product_tapped` with `product_id`, `conversation_id` and `position`.
- `pairs_with` stays the reason for any cross-kind anchor; limiting it to a recipe anchor and a product is not built. Its default text names a recipe, so override it with a reason template. (Replaced in rounds 4 and 5: `pairs_with` is now said only per pairing.)
- `phase_fit` is sent only when the tenant turns `phase_tags` on (off by default).
- Unknown routes answer `404` JSON with `code: not_found`.
- `engine quickstart`: runs the migrations, creates a `demo` tenant with 40 example items, a live `home` surface and one key per scope, and prints the first three calls.
- SDK 0.2.0 has no typed fields yet for `control_item_ids`, `member_of`, `fill_count` and `control_source`.
- Docs: every page describes engine 0.3 as live; the "engine 0.3" markers for coming features are gone.

## 2026-10-13: engine 0.2 and SDKs 0.2.0

Released (release `7a7683e`).

### Engine

- Deadline: header `X-Deadline-Ms` or body `deadline_ms` (10 to 10000); the engine's own budget is 120 ms by default. See [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger).
- `POST /recommendations/{request_id}/abandon` answers `200` and flags the slate `client_abandoned`; it stays in its arm and counts in the fallback rate.
- A UUID you send as `X-Request-Id` becomes the slate's `request_id`.
- `assignment_unit` (64 hex characters, non_profiled only): one holdout arm per person. See [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors).
- `context.measured`; `context.anchor_item_id` resolved across kinds with the reason key `pairs_with`; `more_about_tag` split from `matches_goals`.
- Reason templates keyed by reason key or generator kind; placeholders `{anchor_title}`, `{tag}`, `{segment}`; bad templates answer `400 invalid_reason_template`. See [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys).
- Exploration also runs on slates outside a holdout (arm `none`); control is never explored.
- `X-Patterna-Tester: 1` puts a request on a surface's testers path (`422 bad_tester_id` otherwise).
- Surface settings `min_slots` (the slate comes back with `shown: false`) and `device_resort` (`422 displayed_position_required`).
- Interactions: every item must be in the slate (`422 unknown_item`), kind and served position come from the logged slate, optional `block`.
- Events judged one by one with a `code` per rejection (`pii_rejected`, `invalid_signature`, `no_signing_secret`, …); the batch still answers `202`.
- Catalog: `GET /catalog/items?changed_since=`, `DELETE /catalog/items/{external_id}` (`200` with counts), `locales` per item.
- Signing secrets are `pss_` strings used as is; `POST /settings/signing-secret` creates the first one. A secret from before this release has no `pss_` prefix: rotate once.
- Webhooks: PostHog with `X-Patterna-Token`; Shopify at `/webhooks/shopify/{tenant}`, HMAC only (`401 webhook_not_configured`, `401 bad_signature`).
- Metrics `second_item_rate`, `join_rate` (community surfaces only, `400 metric_not_applicable`) and `engaged_after_tap`; surface metrics gain `fallback_rate`, `client_abandoned_rate`, `unmeasured_share`.

### SDKs (Go, TypeScript, Python 0.2.0)

- `Abandon` / `AbandonSync`; `RecommendWithFallback` reports abandonment itself.
- A 120 ms deadline sent inside the 150 ms timeout (`Config.RecommendDeadline` in Go).
- `RecommendMany` for several surfaces on one screen; `WithTester()`; `Measured` and `AssignmentUnit` on the request.
- Catalog: `ListItems`, `ItemsChangedSince`, `DeleteItem`; `CreateSigningSecret`.
- An `admin` package and a `patterna` CLI for surfaces, flows, experiments and activation, so flow configs can live in your repository.
- Signing keys the HMAC with the secret string (`pss_` secrets).
- Published from `payam-h/patterna-sdk`: Go tag `go/v0.2.0` (`go get github.com/payam-h/patterna-sdk/go@v0.2.0`). TypeScript (`patterna-sdk`) is not on npm yet and Python (`patterna-sdk`) is not on PyPI; both install from that repository at tag `v0.2.0`.

### Docs

- Every page now describes engine 0.2 as live; the "engine 0.2 is next" markers are gone. What engine 0.3 adds is marked "engine 0.3".
- One budget everywhere: the engine's default is 120 ms.
- Corrected: exploration outside a holdout, reason placeholders, `trending` per surface and locale, the PostHog destination status, connector-minted ids (keyed under a secret Patterna holds), the coach event fields, SDK installation.

## 2026-10-13: earlier the same day

### Engine

- Built and smoke-tested locally against a real customer catalog export (210 items): catalog upsert, profiles, recommendations in all three consent modes, interactions, experiments with sticky arms, the ledger, trace, export and deletion.
- Exploration with sequential Plackett-Luce sampling and exact propensities. Caps (per collection, premium) and the diversity penalty (`filters.diversity`) are applied inside the sampler.
- Rankers: `linear`, `learned_linear` and `gbdt`; `lambdamart` is refused.
- Ledger intervals clustered on the randomised unit, with an SRM check. The primary metric is `tap_rate`.
- Deletion is synchronous: `DELETE /users/{id}` removes the rows before it answers.
- Signing-secret rotation: `POST /settings/signing-secret/rotate`.
- `POST /experiments/{id}/end`.
- MFA enrolment and confirmation, panel users with roles and invite tokens.
- Per-key token-bucket rate limits: serve 200 per second (burst 400), ingest 50 per second (burst 100).
- Optional Redis as a shared cache across engine instances (`REDIS_URL`); without it each instance caches on its own.
- A load-test harness for the p99 budget (`cmd/loadtest`).
- Not in this release: the S3 export target (exports go to a local directory) and the LightGBM ranker.

### SDKs

- TypeScript SDK `patterna-sdk`: serve and ingest client, typed errors, 150 ms / 5 s budgets, one retry for idempotent calls, consent gate, opaque-user-id checks, `withFallback`, offline `EventQueue`, HMAC signing. Not published to npm yet.
- Go SDK `github.com/payam-h/patterna-sdk/go`: the same, plus a circuit breaker per scope and fire-and-forget interaction reports drained on `Close`.

### Jobs

- LLM layer: Anthropic and OpenRouter behind one interface, pinned models per job, a daily cost ceiling per tenant, a content-hash cache and a 30-day audit.
- Enrichment: tagging into the tenant taxonomy with health-claim review, embeddings, the gold set, and reason-template drafts saved as inactive flow versions.
- Learning: per-persona and per-user models, item neighbours, the learned linear ranker and the gradient-boosted ranker, written nightly.
- Connectors: PostHog, GA4 via BigQuery, Shopify, quiz CSV and a customer database import.
- Maintenance: deletions, retention, the daily LLM cost ledger.
- Scheduler with `/healthz`, graceful shutdown, and a per-job lock so two workers never run the same job.

### Contract

- `contracts/openapi.yaml` 0.1.0 fixed without semantic change: descriptions that broke parsing, and `nullable: true` replaced by OpenAPI 3.1 `type: [T, 'null']`. Regenerate anything built from the old file.
- The `raw_payloads` table was dropped. Ingest stores no raw payloads; only the allowed fields of each event are kept.

### Docs

- The Docs hub in the panel: concepts, guides, connector setup, API pages and reference, in English and Dutch.
- One API host everywhere: `https://api.patterna.nl/v1`.
- New pages: [Flow config reference](https://staging.patterna.nl/docs/reference-flow-config), [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger), [Identity across connectors](https://staging.patterna.nl/docs/identity-across-connectors).
- Corrected: key prefixes (`pk_s_`, `pk_i_`, `pk_a_`), `mode_conflict` is `422`, the full error-code list, the signing model and the secret, positions start at 0, reason overrides are keyed by reason key, deletion is synchronous, the user endpoints take an ingest key.

## 2026-10-11

### Connectors

- PostHog mapping: `content_viewed`, `content_engaged`, `content_scroll_depth`, `community_joined` and `community_left` are mapped; cycle, breathing, location and screen events and all person properties are not ingested. See [Event types](https://staging.patterna.nl/docs/reference-event-types).
