# Nexgate Recommendation System

# Event Catalogue — the full round trip, both directions

**Author**: Josh S. Sakweli, Backend Lead Team  
**Last Updated**: 2026-09-18  
**Version**: v1.0 (backend branch `fet/feed_baking`, schema version 1)

**Cluster**: Redpanda (Kafka API) · **Schema registry**: JSON Schema, subject `<topic>-value`, BACKWARD compatibility

**Short Description**: Everything that crosses the line between the backend and the recommendation service, in **both directions**. **Inbound** (§1–§6): the complete list of what the backend puts on Kafka — 12 topics, 27 event types — when each fires, what does *not* cause it, and **every payload field with its type, nullability and allowed values**, including the exact shape of each `*.deleted`. **Outbound** (§7): the eight Redis keys the model writes back, their exact format, and what the backend does with each one. **§8 draws the line between the two jobs** — everything the pipeline already does, so the model does not rebuild it. It is written to be self-contained: this document and a Kafka client are sufficient to begin.

> **Read [`06_RECOMMENDATION_DEVELOPER_GUIDE.md`](06_RECOMMENDATION_DEVELOPER_GUIDE.md) first.** That guide is how to connect, what to do with the messages and how to write recommendations back. **This document is the catalogue** — everything that arrives, in detail. `06` links here rather than repeating it.

**Hints**:

- **Nothing is emitted from service code.** Events come from committed database rows, so the delivered stream always matches the database — a rolled-back transaction publishes nothing.
- **A payload is always the full current state**, never a diff. It replaces the stored copy.
- **Order per key is guaranteed; order across keys is not.** Use `entityVersion`, never `occurredAt`.
- **Fields are only ever added.** Unrecognised fields are to be ignored.
- If a topic is ever lost, everything can be republished from the database: `POST /api/admin/feed/backfills`.

---

## 0. The round trip in one picture

```
  INBOUND — backend ➜ model                                              §1–§6
  ──────────────────────────────────────────────────────────────────────────────
  a row commits
  (post · product · shop · event · profile · follow · block · order · wishlist)
        └─► outbox ─► relay ─► 9 entity topics ......... compacted, "what exists"

  the app calls POST /api/v1/feed/interactions/batch        (impressions, views…)
  a row commits (like · comment · cart · order paid)        (seen by the backend)
        └─► nexgate.interactions.v1 ................. 90 days, "what people did"

  a feed page is served
        └─► nexgate.feed-served.v1 .................. 14 days, "what was shown"

                                    │
                                    ▼
                    ┌───────────────────────────────┐
                    │    RECOMMENDATION SERVICE     │
                    │   owned by the ML team       │
                    └───────────────┬───────────────┘
                                    │
  OUTBOUND — model ➜ backend        │                                       §7
  ──────────────────────────────────┼───────────────────────────────────────────
                                    ▼
        SET reco:<surface>:{id} <json> EX 172800        (8 keys, all optional)
                                    │
                                    ▼
        the feed pipeline reads reco:* while building a session;
        missing · invalid · stale ─► trending and interest pools fill in

```

**The boundary never moves.** The backend only ever *writes* Kafka and *reads* `reco:*`. The recommendation service only ever *reads* Kafka and *writes* `reco:*`. Neither side calls the other over HTTP, so neither can be made slow or unavailable by the other.

---

## 1. The envelope

Every message on the nine entity topics has the same eight fields:

```json
{
  "eventId": "9f2c1e8a-5b3d-4a91-8c77-2e6f0b4d1a35",
  "eventType": "product.updated",
  "entityId": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90",
  "entityVersion": 184022,
  "occurredAt": "2026-09-18T10:22:33.120Z",
  "schemaVersion": 1,
  "source": "nexgate-backend",
  "payload": { }
}

```

<table id="bkmrk-field-meaning-eventi"><thead><tr><th>Field</th><th>Meaning</th></tr></thead><tbody><tr><td>`eventId`</td><td>Unique per event. A repeat indicates a retry and is to be discarded</td></tr><tr><td>`eventType`</td><td>One of the 27 in this document</td></tr><tr><td>`entityId`</td><td>The thing this is about</td></tr><tr><td>`entityVersion`</td><td>**Only ever increases per entity.** The highest value seen is authoritative; lower values are ignored</td></tr><tr><td>`occurredAt`</td><td>When it happened. **Not** an ordering key — clocks and retries make it unreliable for that</td></tr><tr><td>`schemaVersion`</td><td>`1` today</td></tr><tr><td>`source`</td><td>Always `nexgate-backend`</td></tr><tr><td>`payload`</td><td>Full current state, or, for a `*.deleted`, the id plus a `reason`</td></tr></tbody></table>

The interactions and feed-served topics use a slightly different envelope — noted in their sections.

## 2. How a delete arrives

Always **two messages**, in this order:

1. `<entity>.deleted` with a `reason` — the entity is no longer available to consumers, and the reason states why.
2. A **tombstone**: same key, `value = null` — so log compaction can drop the key.

"Deleted" does not always mean the row was deleted. It means **the entity is no longer something to recommend**. A post set to private, a shop that closed, an account that was locked all arrive as `*.deleted`, with a reason that distinguishes them. If the entity becomes visible again, `*.created` follows.

Tombstones are retained for **1 day**. A consumer reading a compacted topic from offset 0 after that interval sees only live entities.

---

## 3. The topics at a glance

<table id="bkmrk-%23-topic-key-cleanup-"><thead><tr><th>\#</th><th>Topic</th><th>Key</th><th>Cleanup</th><th>Partitions</th><th>Event types</th></tr></thead><tbody><tr><td>1</td><td>`nexgate.posts.v1`</td><td>`postId`</td><td>compact</td><td>24</td><td>`post.created` · `post.updated` · `post.deleted`</td></tr><tr><td>2</td><td>`nexgate.products.v1`</td><td>`productId`</td><td>compact</td><td>24</td><td>`product.created` · `product.updated` · `product.deleted`</td></tr><tr><td>3</td><td>`nexgate.shops.v1`</td><td>`shopId`</td><td>compact</td><td>12</td><td>`shop.created` · `shop.updated` · `shop.deleted`</td></tr><tr><td>4</td><td>`nexgate.events.v1`</td><td>`eventId`</td><td>compact</td><td>12</td><td>`event.created` · `event.updated` · `event.deleted`</td></tr><tr><td>5</td><td>`nexgate.profiles.v1`</td><td>`accountId`</td><td>compact</td><td>24</td><td>`profile.created` · `profile.updated` · `profile.deleted`</td></tr><tr><td>6</td><td>`nexgate.follows.v1`</td><td>`followerId:followedId`</td><td>compact</td><td>24</td><td>`follow.created` · `follow.deleted`</td></tr><tr><td>7</td><td>`nexgate.blocks.v1`</td><td>`blockerId:blockedId`</td><td>compact</td><td>12</td><td>`block.created` · `block.deleted`</td></tr><tr><td>8</td><td>`nexgate.orders.v1`</td><td>`orderId`</td><td>compact</td><td>24</td><td>`order.created` · `order.updated` · `order.deleted`</td></tr><tr><td>9</td><td>`nexgate.wishlist.v1`</td><td>`accountId:productId`</td><td>compact</td><td>24</td><td>`wishlist.created` · `wishlist.updated` · `wishlist.deleted`</td></tr><tr><td>10</td><td>`nexgate.interactions.v1`</td><td>`accountId`</td><td>delete, 90 d</td><td>96</td><td>`interaction`</td></tr><tr><td>11</td><td>`nexgate.feed-served.v1`</td><td>`accountId`</td><td>delete, 14 d</td><td>48</td><td>`feed.served`</td></tr><tr><td>12</td><td>`nexgate.fanout-tasks.v1`</td><td>`postId:chunk`</td><td>delete, 3 d</td><td>48</td><td>*(internal — not for consumers)*</td></tr></tbody></table>

**Partition counts are fixed for good.** Adding partitions later would move keys to different partitions and break per-key ordering.

**Compacted topics are a live catalogue**: a read from offset 0 yields the current state of every entity, and continued consumption keeps it current.

---

# 4. Entity topics

Each section below is complete: every field of the payload, its type, whether it can be `null`, its allowed values where it is an enum, and the exact shape of the `*.deleted` payload.

**Type column**: `uuid`, `string`, `int`, `decimal`, `bool`, `timestamp` (ISO 8601 UTC), `json` (an opaque object whose structure may change and should not be relied upon). A field marked **nullable** may arrive as `null`; one marked **required** is present on every message of that topic.

**Fields are only ever added.** The schemas are open by design: unrecognised fields are ignored, and no message should be rejected because of one.

---

## 4.1 `nexgate.posts.v1` — posts and reels

`postId` · compacted · 24 partitions · schema `src/main/resources/feed-schemas/nexgate.posts.v1.json`

**A post is available to consumers while it is `PUBLISHED`, not deleted, and `PUBLIC` or `FOLLOWERS`.**

<table id="bkmrk-event-fires-when-pos"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`post.created`</td><td>A draft becomes published, **or** a hidden post becomes visible again</td></tr><tr><td>`post.updated`</td><td>A visible post, or any of its child rows, changes</td></tr><tr><td>`post.deleted`</td><td>It stops being visible — only if it was visible before</td></tr></tbody></table>

**Delete reasons**: `DELETED` (soft-deleted) · `UNPUBLISHED` (no longer `PUBLISHED`) · `VISIBILITY_RESTRICTED` (switched to `MENTIONED` or `PRIVATE`)

**What counts as a change.** Not just the post row — **eleven child tables** are watched, and a change to any one republishes the whole post: media, hashtags, attached products, attached shops, attached events, user mentions, shop mentions, links, collaborators, and the poll.

**What publishes nothing**: `likesCount`, `commentsCount`, `repostsCount`, `bookmarksCount`, `viewsCount`, `quotesCount`, `sharesCount`, `shortClipCount`, `lastVerifiedAt`, `updatedAt`. Engagement counters move constantly; republishing every post on every like would be most of the topic's traffic for none of the value. **The counts in the payload are therefore a snapshot taken at the last substantive change, not a live figure.** Live behaviour is carried by `nexgate.interactions.v1`.

A draft that is saved, edited and never published produces **nothing at all**.

### Payload — `post.created` / `post.updated`

<table id="bkmrk-field-type-notes-pos"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`postId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`postType`</td><td>string, nullable</td><td>`REGULAR` · `POLL`</td></tr><tr><td>`status`</td><td>string, nullable</td><td>`PUBLISHED` on every message delivered; `DRAFT`, `SCHEDULED` and `DELETED` are never published</td></tr><tr><td>`author.id`</td><td>uuid, nullable</td><td>Who wrote it</td></tr><tr><td>`author.userName`</td><td>string, nullable</td><td>Public username. No real name is ever published</td></tr><tr><td>`content`</td><td>string, nullable</td><td>The post text as written</td></tr><tr><td>`contentParsed.hashtags[]`</td><td>string\[\]</td><td>Normalised, **without** the `#`. Any script (Latin, Arabic, CJK …)</td></tr><tr><td>`contentParsed.mentionedUserIds[]`</td><td>uuid\[\]</td><td>Accounts mentioned in the text</td></tr><tr><td>`contentParsed.mentionedShopIds[]`</td><td>uuid\[\]</td><td>Shops mentioned in the text</td></tr><tr><td>`media[].fileId`</td><td>uuid, nullable</td><td>File-service id</td></tr><tr><td>`media[].mediaType`</td><td>string, nullable</td><td>`IMAGE` · `VIDEO`</td></tr><tr><td>`media[].order`</td><td>int</td><td>Display order within the post, from 0</td></tr><tr><td>`media[].status`</td><td>string, nullable</td><td>Processing state. `PENDING` means variants are not ready yet</td></tr><tr><td>`media[].variants`</td><td>json, nullable</td><td>Rendition URLs keyed by name (`hls`, `thumb`, …). Opaque — read keys, do not assume the set</td></tr><tr><td>`media[].shortClip`</td><td>bool</td><td>**`true` = this is a reel.** There is no separate reel topic</td></tr><tr><td>`media[].mediaId`</td><td>uuid, nullable</td><td>The clip's own id, as used by the reel endpoints</td></tr><tr><td>`media[].durationMs`</td><td>int, nullable</td><td>Length in milliseconds, for video</td></tr><tr><td>`attachments.products[]`</td><td>uuid\[\]</td><td>Products attached to the post</td></tr><tr><td>`attachments.shops[]`</td><td>uuid\[\]</td><td>Shops attached</td></tr><tr><td>`attachments.events[]`</td><td>uuid\[\]</td><td>Events attached</td></tr><tr><td>`engagement.likesCount`</td><td>int</td><td>Snapshot, see above</td></tr><tr><td>`engagement.commentsCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`engagement.sharesCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`engagement.viewsCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`engagement.bookmarksCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`engagement.repostsCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`engagement.quotesCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`privacySettings.visibility`</td><td>string, nullable</td><td>`PUBLIC` or `FOLLOWERS` on every message delivered</td></tr><tr><td>`privacySettings.whoCanComment`</td><td>string, nullable</td><td>Who may comment</td></tr><tr><td>`privacySettings.whoCanRepost`</td><td>string, nullable</td><td>Who may repost</td></tr><tr><td>`quotedPostId`</td><td>uuid, nullable</td><td>Set when this post quotes or reposts another. **A pure repost carries the original's engagement, not its own**</td></tr><tr><td>`collaboratorIds[]`</td><td>uuid\[\]</td><td>Co-authors</td></tr><tr><td>`hasPoll`</td><td>bool</td><td>Whether a poll is attached. Poll options and votes are **not** published</td></tr><tr><td>`externalLinkDomain`</td><td>string, nullable</td><td>The domain only, never the full URL</td></tr><tr><td>`isEdited`</td><td>bool</td><td>Whether it has been edited since publishing</td></tr><tr><td>`createdAt`</td><td>timestamp, nullable</td><td>When the row was created (may be long before publishing)</td></tr><tr><td>`publishedAt`</td><td>timestamp, nullable</td><td>**When the post went live. This is the field for recency, not `createdAt`**</td></tr><tr><td>`updatedAt`</td><td>timestamp, nullable</td><td>Last change</td></tr><tr><td>`interests[].id`</td><td>uuid</td><td>Interest id from the shared list (`interest_categories`)</td></tr><tr><td>`interests[].w`</td><td>decimal</td><td>Weight: **1.0** from attached products and events, **0.6** from hashtags. An interest reached twice keeps the higher weight. **Empty until an administrator maps the category or hashtag**</td></tr></tbody></table>

### Payload — `post.deleted`

<table id="bkmrk-field-type-notes-pos-1"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`postId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`authorId`</td><td>uuid, nullable</td><td>The author, or `null` when the row itself is gone</td></tr><tr><td>`reason`</td><td>string</td><td>One of the delete reasons above</td></tr></tbody></table>

Followed by a tombstone: same key, `value = null`.

---

## 4.2 `nexgate.products.v1` — products

`productId` · compacted · 24 partitions

**A product is available to consumers while it is `ACTIVE` or `OUT_OF_STOCK`, not deleted, and its shop is visible.** Running out of stock is an **update**, not a disappearance: stock is carried in the payload and the consumer decides how to treat it.

<table id="bkmrk-event-fires-when-pro"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`product.created`</td><td>It becomes visible, including when its shop comes back</td></tr><tr><td>`product.updated`</td><td>The product or its installment plan changes</td></tr><tr><td>`product.deleted`</td><td>It stops being visible</td></tr></tbody></table>

**Delete reasons**: `DELETED` · `UNPUBLISHED` (`DRAFT`, `INACTIVE`, `ARCHIVED`) · `SHOP_UNAVAILABLE` (shop suspended, closed, unapproved or deleted)

**The shop cascade.** When a shop's visibility flips, **every product of that shop is republished**, in pages, at that moment. One suspension can produce thousands of `product.deleted` messages. Deliberate: a consumer watching only this topic would otherwise keep recommending products from a suspended shop.

**What publishes nothing**: `viewCount`, `cartAddCount`, `updatedAt`.

### Payload — `product.created` / `product.updated`

<table id="bkmrk-field-type-notes-pro"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`productId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`productName`</td><td>string, nullable</td><td></td></tr><tr><td>`productSlug`</td><td>string, nullable</td><td>URL slug</td></tr><tr><td>`productDescription`</td><td>string, nullable</td><td></td></tr><tr><td>`productType`</td><td>string, nullable</td><td>`PHYSICAL` · `DIGITAL`</td></tr><tr><td>`productMedia[].fileId`</td><td>uuid, nullable</td><td></td></tr><tr><td>`productMedia[].mediaType`</td><td>string, nullable</td><td>`IMAGE` · `VIDEO`</td></tr><tr><td>`productMedia[].order`</td><td>int, nullable</td><td>Display order</td></tr><tr><td>`productMedia[].status`</td><td>string, nullable</td><td>Processing state</td></tr><tr><td>`productMedia[].variants`</td><td>json, nullable</td><td>Rendition URLs</td></tr><tr><td>`categoryId`</td><td>uuid, nullable</td><td>**The key to interests** — mapped by an admin</td></tr><tr><td>`categoryName`</td><td>string, nullable</td><td></td></tr><tr><td>`parentCategoryId`</td><td>uuid, nullable</td><td>A category with no mapping inherits its parent's interests</td></tr><tr><td>`price`</td><td>decimal, nullable</td><td>Current price</td></tr><tr><td>`comparePrice`</td><td>decimal, nullable</td><td>"Was" price</td></tr><tr><td>`isOnSale`</td><td>bool</td><td></td></tr><tr><td>`discountPercentage`</td><td>decimal, nullable</td><td></td></tr><tr><td>`stockQuantity`</td><td>int</td><td></td></tr><tr><td>`isInStock`</td><td>bool</td><td></td></tr><tr><td>`isLowStock`</td><td>bool</td><td></td></tr><tr><td>`showStockToPublic`</td><td>bool</td><td>Whether the shop displays the number</td></tr><tr><td>`status`</td><td>string, nullable</td><td>`ACTIVE` or `OUT_OF_STOCK` on every message delivered</td></tr><tr><td>`condition`</td><td>string, nullable</td><td>`NEW` · `USED_LIKE_NEW` · `USED_GOOD` · `USED_FAIR` · `REFURBISHED` · `FOR_PARTS`</td></tr><tr><td>`urgencyTag`</td><td>string, nullable</td><td>`NONE` · `NEW_ARRIVAL` · `LIMITED_EDITION` · `LIMITED_OFFER` · `FEW_REMAINS`</td></tr><tr><td>`specifications`</td><td>json, nullable</td><td>Free-form, seller-entered. Opaque</td></tr><tr><td>`colors`</td><td>json, nullable</td><td>Free-form, seller-entered. Opaque</td></tr><tr><td>`hasGroupBuying`</td><td>bool</td><td></td></tr><tr><td>`groupPrice`</td><td>decimal, nullable</td><td>Price when bought as a group</td></tr><tr><td>`hasInstallments`</td><td>bool</td><td></td></tr><tr><td>`stockInfo.soldCount`</td><td>int</td><td>Units sold</td></tr><tr><td>`shopId`</td><td>uuid, nullable</td><td></td></tr><tr><td>`shopName`</td><td>string, nullable</td><td></td></tr><tr><td>`createdAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`updatedAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`interests[].id`</td><td>uuid</td><td>From the product's category, weight **1.0**</td></tr><tr><td>`interests[].w`</td><td>decimal</td><td></td></tr></tbody></table>

### Payload — `product.deleted`

<table id="bkmrk-field-type-notes-pro-1"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`productId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`shopId`</td><td>uuid, nullable</td><td>Useful when the cause was the shop</td></tr><tr><td>`reason`</td><td>string</td><td></td></tr></tbody></table>

---

## 4.3 `nexgate.shops.v1` — shops

`shopId` · compacted · 12 partitions

**A shop is available to consumers while it is `ACTIVE` or `TEMPORARILY_OFFLINE`, approved, and not deleted.**

<table id="bkmrk-event-fires-when-sho"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`shop.created`</td><td>It becomes visible</td></tr><tr><td>`shop.updated`</td><td>Anything meaningful on the shop changes</td></tr><tr><td>`shop.deleted`</td><td>It stops being visible</td></tr></tbody></table>

**Delete reasons**: `DELETED` · `SUSPENDED` · `CLOSED` (`PERMANENTLY_CLOSED`) · `UNPUBLISHED` (`PENDING`, or approval withdrawn)

Every one of these also republishes the shop's products (§4.2).

**What publishes nothing**: `subscriberCount`, `lastSeenTime`, `updatedAt`.

### Payload — `shop.created` / `shop.updated`

<table id="bkmrk-field-type-notes-sho"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`shopId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`shopName`</td><td>string, nullable</td><td></td></tr><tr><td>`shopSlug`</td><td>string, nullable</td><td></td></tr><tr><td>`shopDescription`</td><td>string, nullable</td><td></td></tr><tr><td>`logo.fileId` / `.mediaType` / `.status` / `.variants`</td><td>uuid / string / string / json, nullable</td><td>The shop logo</td></tr><tr><td>`banner.fileId` / `.mediaType` / `.status` / `.variants`</td><td>uuid / string / string / json, nullable</td><td>The shop banner</td></tr><tr><td>`ownerId`</td><td>uuid, nullable</td><td>The account that owns the shop</td></tr><tr><td>`status`</td><td>string, nullable</td><td>`ACTIVE` or `TEMPORARILY_OFFLINE` on every message delivered</td></tr><tr><td>`tempOfflineUntil`</td><td>timestamp, nullable</td><td>Set while the owner has gone offline deliberately</td></tr><tr><td>`city`</td><td>string, nullable</td><td></td></tr><tr><td>`district`</td><td>string, nullable</td><td></td></tr><tr><td>`region`</td><td>string, nullable</td><td>**The feed filters by region** — this is why location stays</td></tr><tr><td>`countryCode`</td><td>string, nullable</td><td></td></tr><tr><td>`isVerified`</td><td>bool</td><td></td></tr><tr><td>`verificationBadge`</td><td>string, nullable</td><td>Which badge</td></tr><tr><td>`trustScore`</td><td>decimal, nullable</td><td>Platform trust score</td></tr><tr><td>`subscriberCount`</td><td>int</td><td>Snapshot — a change to it alone does not republish</td></tr><tr><td>`approvedAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`createdAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`updatedAt`</td><td>timestamp, nullable</td><td></td></tr></tbody></table>

### Payload — `shop.deleted`

<table id="bkmrk-field-type-notes-sho-1"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`shopId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`reason`</td><td>string</td><td></td></tr></tbody></table>

**Never published**: phone, email, street address, landmark, coordinates. Many shops are run from someone's home.

---

## 4.4 `nexgate.events.v1` — events

`eventId` · compacted · 12 partitions

**An event is available to consumers while it is `PUBLISHED` or `HAPPENING`, `PUBLIC`, and not deleted.**

<table id="bkmrk-event-fires-when-eve"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`event.created`</td><td>It becomes visible</td></tr><tr><td>`event.updated`</td><td>The event or its tickets change — **including selling out**, which is an update, not a removal</td></tr><tr><td>`event.deleted`</td><td>It stops being visible</td></tr></tbody></table>

**Delete reasons**: `DELETED` · `ENDED` (`COMPLETED`) · `CANCELLED` · `UNPUBLISHED` (`DRAFT` or any other status) · `VISIBILITY_RESTRICTED` (`PRIVATE` or `UNLISTED`)

**Ticket statistics count sold tickets only.** Reservations change on every checkout attempt and expire by themselves, so publishing them would be noise.

**What publishes nothing**: `updatedAt`, `currentStage`, `completedStages`, `rsaKeys`.

### Payload — `event.created` / `event.updated`

<table id="bkmrk-field-type-notes-eve"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`eventId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`title`</td><td>string, nullable</td><td></td></tr><tr><td>`slug`</td><td>string, nullable</td><td></td></tr><tr><td>`description`</td><td>string, nullable</td><td></td></tr><tr><td>`category.id`</td><td>uuid, nullable</td><td>**The key to interests**</td></tr><tr><td>`category.name`</td><td>string, nullable</td><td></td></tr><tr><td>`bannerMedia`</td><td>json, nullable</td><td>Opaque media object</td></tr><tr><td>`eventFormat`</td><td>string, nullable</td><td>`IN_PERSON` · `ONLINE` · `HYBRID` · `TBA`</td></tr><tr><td>`visibility`</td><td>string, nullable</td><td>`PUBLIC` on every message delivered</td></tr><tr><td>`status`</td><td>string, nullable</td><td>`PUBLISHED` or `HAPPENING` on every message delivered</td></tr><tr><td>`schedule.startDateTime`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`schedule.endDateTime`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`schedule.timezone`</td><td>string, nullable</td><td>IANA zone</td></tr><tr><td>`venue.name`</td><td>string, nullable</td><td></td></tr><tr><td>`venue.address`</td><td>string, nullable</td><td>A public venue, unlike a shop's address</td></tr><tr><td>`venue.latitude` / `.longitude`</td><td>decimal, nullable</td><td></td></tr><tr><td>`pricing.minPrice` / `.maxPrice`</td><td>decimal, nullable</td><td>Across ticket types</td></tr><tr><td>`pricing.pricingType`</td><td>string, nullable</td><td>How the event is priced</td></tr><tr><td>`stats.ticketsSold`</td><td>int</td><td>**Sold only** — never reservations</td></tr><tr><td>`stats.ticketsAvailable`</td><td>int, nullable</td><td></td></tr><tr><td>`stats.isSoldOut`</td><td>bool</td><td>Selling out arrives as `event.updated`</td></tr><tr><td>`organizerId`</td><td>uuid, nullable</td><td></td></tr><tr><td>`organizerName`</td><td>string, nullable</td><td></td></tr><tr><td>`linkedProductIds[]`</td><td>uuid\[\]</td><td>Products tied to the event</td></tr><tr><td>`linkedShopIds[]`</td><td>uuid\[\]</td><td>Shops tied to the event</td></tr><tr><td>`createdAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`publishedAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`updatedAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`interests[].id` / `.w`</td><td>uuid / decimal</td><td>From the event's category, weight **1.0**</td></tr></tbody></table>

### Payload — `event.deleted`

<table id="bkmrk-field-type-notes-eve-1"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`eventId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`reason`</td><td>string</td><td></td></tr></tbody></table>

**Never published**: the virtual meeting link, meeting id or passcode (they admit people into the room), and never the event's signing keys.

---

## 4.5 `nexgate.profiles.v1` — public profile facts

`accountId` · compacted · 24 partitions

**A profile is available to consumers once sign-up is complete** (a real username, not a `temp_…` placeholder) **and while the account is not locked.**

<table id="bkmrk-event-fires-when-pro-1"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`profile.created`</td><td>Sign-up completes, or a locked account is unlocked</td></tr><tr><td>`profile.updated`</td><td>One of the watched fields changes, or the person's declared interests change</td></tr><tr><td>`profile.deleted`</td><td>The account is locked, or the row is gone</td></tr></tbody></table>

**Delete reasons**: `LOCKED` · `DELETED`

**Only these changes republish**: `userName`, `accountType`, `accountTier`, `locked`, `isVerified`, `verificationBadgeType`, and rows in the user's declared interests. **A login, a password change or a new profile photo publishes nothing.**

### Payload — `profile.created` / `profile.updated`

<table id="bkmrk-field-type-notes-acc"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`accountId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`userName`</td><td>string, nullable</td><td>Public username. Never `temp_…` on a delivered message</td></tr><tr><td>`accountType`</td><td>string, nullable</td><td>`NORMAL` · `SYSTEM` · `VERIFIED`</td></tr><tr><td>`accountTier`</td><td>string, nullable</td><td>`FULL` · `RESTRICTED` · `MINOR`. **`MINOR` denotes a minor's account and warrants conservative treatment**</td></tr><tr><td>`isVerified`</td><td>bool</td><td></td></tr><tr><td>`verificationBadgeType`</td><td>string, nullable</td><td>Which badge</td></tr><tr><td>`followerCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`followingCount`</td><td>int</td><td>Snapshot</td></tr><tr><td>`declaredInterestIds[]`</td><td>uuid\[\]</td><td>What the person chose at onboarding, on the shared interest list</td></tr><tr><td>`createdAt`</td><td>timestamp, nullable</td><td>Account age — useful for cold-start</td></tr></tbody></table>

### Payload — `profile.deleted`

<table id="bkmrk-field-type-notes-acc-1"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`accountId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`reason`</td><td>string</td><td>`LOCKED` or `DELETED`</td></tr></tbody></table>

**Never published**: first, middle or last name, bio, location, phone, email, birth date, profile photo.

> ⚠️ **The `active` column must not be used, wherever it is encountered.** Sign-up sets it `false` and nothing ever sets it `true`, so it is `false` on every real account. It is deliberately not in the payload — relying on it would have tombstoned every profile on the platform.

---

## 4.6 `nexgate.follows.v1` — who follows whom

Key `followerId:followedId` · compacted · 24 partitions

**Only `ACCEPTED` follows are published.** A follow request (`PENDING`) emits nothing; if it is never accepted, nothing is emitted at all.

<table id="bkmrk-event-fires-when-fol"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`follow.created`</td><td>A request is accepted, or a public account is followed</td></tr><tr><td>`follow.deleted`</td><td>Unfollowed, or a request withdrawn — only if it had been accepted</td></tr></tbody></table>

### Payload — `follow.created`

<table id="bkmrk-field-type-notes-fol"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`followerId`</td><td>uuid</td><td>**required** — the one doing the following</td></tr><tr><td>`followedId`</td><td>uuid</td><td>**required** — the one being followed</td></tr><tr><td>`followedAt`</td><td>timestamp, nullable</td><td>When it was accepted</td></tr></tbody></table>

### Payload — `follow.deleted`

`followerId` (uuid, required) · `followedId` (uuid, required) · `reason` (string)

---

## 4.7 `nexgate.blocks.v1` — who blocked whom

Key `blockerId:blockedId` · compacted · 12 partitions

A block is published the moment the row is written — there is no visibility condition.

<table id="bkmrk-event-fires-when-blo"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`block.created`</td><td>Someone blocks someone</td></tr><tr><td>`block.deleted`</td><td>They unblock</td></tr></tbody></table>

**This topic is not optional.** A block hides content **in both directions**: neither party may be recommended the other's content.

### Payload — `block.created`

<table id="bkmrk-field-type-notes-blo"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`blockerId`</td><td>uuid</td><td>**required** — who blocked</td></tr><tr><td>`blockedId`</td><td>uuid</td><td>**required** — who was blocked</td></tr><tr><td>`blockedAt`</td><td>timestamp, nullable</td><td></td></tr></tbody></table>

### Payload — `block.deleted`

`blockerId` (uuid, required) · `blockedId` (uuid, required) · `reason` (string)

> **Mutes are not published on Kafka.** A mute is a per-viewer preference applied inside the feed and is not an entity. It does appear as a `MUTE_AUTHOR` interaction (§5).

---

## 4.8 `nexgate.orders.v1` — what was actually bought

`orderId` · compacted · 24 partitions

An order is published from creation onwards. This is the strongest purchase signal on the platform.

<table id="bkmrk-event-fires-when-ord"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`order.created`</td><td>An order is placed</td></tr><tr><td>`order.updated`</td><td>One of the watched fields changes</td></tr><tr><td>`order.deleted`</td><td>The order is soft-deleted (reason `DELETED`)</td></tr></tbody></table>

**Only these changes republish**: `productOrderStatus`, `deliveryStatus`, `totalAmount`, `currency`, `shippedAt`, `deliveredAt`, `completedAt`, `cancelledAt`, `isDeleted`.

### Payload — `order.created` / `order.updated`

<table id="bkmrk-field-type-notes-ord"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`orderId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`orderNumber`</td><td>string, nullable</td><td>Human-readable reference</td></tr><tr><td>`buyer.accountId`</td><td>uuid, nullable</td><td>Who bought</td></tr><tr><td>`shopId`</td><td>uuid, nullable</td><td>Who sold</td></tr><tr><td>`items[].productId`</td><td>uuid, nullable</td><td></td></tr><tr><td>`items[].quantity`</td><td>int</td><td></td></tr><tr><td>`items[].unitPrice`</td><td>decimal, nullable</td><td></td></tr><tr><td>`items[].subtotal`</td><td>decimal, nullable</td><td></td></tr><tr><td>`productOrderStatus`</td><td>string, nullable</td><td>`PENDING_PAYMENT` · `PENDING_SHIPMENT` · `SHIPPED` · `DELIVERED` · `AWAITING_BUYER_CONFIRM` · `COMPLETED` · `DISPUTED` · `CANCELLED` · `REFUNDED`</td></tr><tr><td>`deliveryStatus`</td><td>string, nullable</td><td>`PENDING` · `SHIPPED` · `DELIVERED` · `CONFIRMED` · `IN_TRANSIT` · `NOT_APPLICABLE`</td></tr><tr><td>`productOrderSource`</td><td>string, nullable</td><td>`DIRECT_PURCHASE` · `DIGITAL_PURCHASE` · `INSTALLMENT` · `GROUP_PURCHASE` · `CART_PURCHASE` · `CHAT_OFFER`</td></tr><tr><td>`totalAmount`</td><td>decimal, nullable</td><td></td></tr><tr><td>`currency`</td><td>string, nullable</td><td></td></tr><tr><td>`orderedAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`shippedAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`deliveredAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`completedAt`</td><td>timestamp, nullable</td><td>**The strongest signal available: payment settled and receipt confirmed by the buyer**</td></tr><tr><td>`cancelledAt`</td><td>timestamp, nullable</td><td></td></tr><tr><td>`updatedAt`</td><td>timestamp, nullable</td><td></td></tr></tbody></table>

> An order leaving `PENDING_PAYMENT` for any status other than `CANCELLED` or `REFUNDED` **also** produces a `PURCHASE` interaction per order item (§5). The order topic describes what the order *is*; the interaction records when the purchase *occurred*.

### Payload — `order.deleted`

<table id="bkmrk-field-type-notes-ord-1"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`orderId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`reason`</td><td>string</td><td>`DELETED`</td></tr></tbody></table>

**Never published**: delivery address or instructions, order notes, cancellation text, payment method, escrow or fee figures, tracking number, confirmation code, chat ids.

> **Event ticket bookings are not on this topic.** `nexgate.orders.v1` is product orders only. See §9.

---

## 4.9 `nexgate.wishlist.v1` — saved products

Key `accountId:productId` · compacted · 24 partitions

A product can be in a person's wishlist once, whatever group it is filed under.

<table id="bkmrk-event-fires-when-wis"><thead><tr><th>Event</th><th>Fires when</th></tr></thead><tbody><tr><td>`wishlist.created`</td><td>A product is added</td></tr><tr><td>`wishlist.updated`</td><td>The entry changes (for example, moved to another group)</td></tr><tr><td>`wishlist.deleted`</td><td>It is removed</td></tr></tbody></table>

### Payload — `wishlist.created` / `wishlist.updated`

<table id="bkmrk-field-type-notes-acc-2"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`accountId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`productId`</td><td>uuid</td><td>**required**</td></tr><tr><td>`wishlistId`</td><td>uuid, nullable</td><td>The row's own id</td></tr><tr><td>`groupId`</td><td>uuid, nullable</td><td>Which of the user's lists it sits in</td></tr><tr><td>`addedAt`</td><td>timestamp, nullable</td><td></td></tr></tbody></table>

### Payload — `wishlist.deleted`

`accountId` (uuid, required) · `productId` (uuid, required) · `reason` (string)

**Never published**: the group's **name**. It is free text a person writes about themselves ("Gifts for Mum"). The group id is enough to tell groups apart.

---

# 5. `nexgate.interactions.v1` — what people did

`accountId` · **delete, 90 days** · 96 partitions · schema `nexgate.interactions.v1.json`

One event type, `interaction`. Keyed by `accountId` so one person's actions stay in order.

This is the **only** topic anything outside the backend can write to, and it does so through exactly one endpoint: `POST /api/v1/feed/interactions/batch` ([`09_NEW_ENDPOINTS.md`](09_NEW_ENDPOINTS.md) A8). Every other topic is written from committed database rows, so no client can announce that an entity exists or changed.

### The envelope is different here

No `entityId` and no `entityVersion` — an interaction is not an entity. Instead:

<table id="bkmrk-field-type-notes-eve-2"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`eventId`</td><td>uuid</td><td>**required.** Deterministic (UUIDv5 of account + `clientEventId`), so a re-publish of the same action produces the same id. **Deduplicate on it**</td></tr><tr><td>`eventType`</td><td>string</td><td>Always the literal `interaction`</td></tr><tr><td>`occurredAt`</td><td>timestamp</td><td>**The device's clock**, clamped so it can never be in the future</td></tr><tr><td>`receivedAt`</td><td>timestamp</td><td>The server's clock, when it arrived</td></tr><tr><td>`schemaVersion`</td><td>int</td><td>`1`</td></tr><tr><td>`source`</td><td>string</td><td>`nexgate-backend`</td></tr><tr><td>`payload`</td><td>object</td><td>Below</td></tr></tbody></table>

```json
{
  "eventId": "…", "eventType": "interaction",
  "occurredAt": "2026-09-18T10:22:41.100Z", "receivedAt": "2026-09-18T10:22:41.480Z",
  "schemaVersion": 1, "source": "nexgate-backend",
  "payload": {
    "accountId": "…", "sessionId": "s-8812e0c4", "clientEventId": "c-000185",
    "action": "VIEW", "targetType": "POST", "targetId": "…",
    "context": { "surface": "REELS", "position": 3, "feedSessionId": "fs-77aa", "source": "RECO", "viaPostId": null, "query": null },
    "media": { "dwellMs": null, "watchMs": 9000, "mediaDurationMs": 6000, "loopCount": 1, "soundOn": true, "mediaIndex": 0 },
    "client": { "platform": "ANDROID", "appVersion": "2.4.1", "networkType": "CELLULAR" },
    "origin": "CLIENT"
  }
}

```

### Payload — every field

<table id="bkmrk-field-type-notes-acc-3"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`accountId`</td><td>uuid</td><td>**required.** Who did it</td></tr><tr><td>`sessionId`</td><td>string, nullable</td><td>The app session, max 64 chars. `null` for server-produced events</td></tr><tr><td>`clientEventId`</td><td>string</td><td>**required.** The app's own id, or `srv:<action>:<rowId>` when the backend produced it</td></tr><tr><td>`action`</td><td>string</td><td>**required.** See the table below</td></tr><tr><td>`targetType`</td><td>string, nullable</td><td>`POST` · `PRODUCT` · `SHOP` · `EVENT` · `PROFILE`. `null` only for `SEARCH`</td></tr><tr><td>`targetId`</td><td>uuid, nullable</td><td>Lower-cased. `null` only for `SEARCH`</td></tr><tr><td>`context.surface`</td><td>string, nullable</td><td>`FEED` · `REELS` · `SEARCH` · `PRODUCT_PAGE` · `SHOP_PAGE` · `EVENT_PAGE` · `PROFILE_PAGE` · `RECOMMENDATION` · `NOTIFICATION` · `CHAT`</td></tr><tr><td>`context.position`</td><td>int, nullable</td><td>Where in the feed the item sat, from 0</td></tr><tr><td>`context.feedSessionId`</td><td>string, nullable</td><td>**Joins to `nexgate.feed-served.v1`** — max 64 chars</td></tr><tr><td>`context.source`</td><td>string, nullable</td><td>Why it was shown: `FOLLOWING` · `CELEBRITY` · `RECO` · `INTEREST` · `FALLBACK` · `SPONSORED` · `TRENDING` · `SEARCH`. **`RECO` indicates the item came from a supplied list**</td></tr><tr><td>`context.viaPostId`</td><td>uuid, nullable</td><td>The post a product was reached through</td></tr><tr><td>`context.query`</td><td>string, nullable</td><td>The search text, for `SEARCH`</td></tr><tr><td>`media.dwellMs`</td><td>int, nullable</td><td>Time on screen. 0 – 6 h</td></tr><tr><td>`media.watchMs`</td><td>int, nullable</td><td>Time watched. 0 – 6 h</td></tr><tr><td>`media.mediaDurationMs`</td><td>int, nullable</td><td>Length of the media. 0 – 12 h</td></tr><tr><td>`media.loopCount`</td><td>int, nullable</td><td>Replays. 0 – 1000</td></tr><tr><td>`media.soundOn`</td><td>bool, nullable</td><td>Whether sound was on</td></tr><tr><td>`media.mediaIndex`</td><td>int, nullable</td><td>Which item of a carousel. 0 – 100</td></tr><tr><td>`client.platform`</td><td>string, nullable</td><td>`ANDROID` · `IOS` · `WEB`</td></tr><tr><td>`client.appVersion`</td><td>string, nullable</td><td>e.g. `2.4.1`</td></tr><tr><td>`client.networkType`</td><td>string, nullable</td><td>`WIFI` · `CELLULAR` · `OFFLINE` · `UNKNOWN`</td></tr><tr><td>`origin`</td><td>enum, nullable</td><td>`CLIENT` or `SERVER`</td></tr></tbody></table>

`media` and `client` are always `null` on server-produced events — the backend did not witness a screen.

## 5.1 Two origins, never overlapping

<table id="bkmrk-origin-produced-by-c"><thead><tr><th>`origin`</th><th>Produced by</th><th>`clientEventId`</th></tr></thead><tbody><tr><td>`CLIENT`</td><td>The app, in a batch</td><td>the app's own id</td></tr><tr><td>`SERVER`</td><td>The backend, watching committed rows</td><td>`srv:<action>:<rowId>`</td></tr></tbody></table>

The split is enforced: the batch API **rejects** every server-owned action with `SENT_BY_SERVER`, so nothing is counted twice.

## 5.2 Every action, who sends it, and what triggers it

<table id="bkmrk-action-origin-target"><thead><tr><th>Action</th><th>Origin</th><th>Target</th><th>Exact trigger</th></tr></thead><tbody><tr><td>`IMPRESSION`</td><td>CLIENT</td><td>any</td><td>The item was on screen. **Use it as the denominator**</td></tr><tr><td>`VIEW`</td><td>CLIENT</td><td>any</td><td>Real attention: dwell ≥ 5 s, or a reel watched ≥ 50 %</td></tr><tr><td>`SKIP`</td><td>CLIENT</td><td>any</td><td>Scrolled past quickly</td></tr><tr><td>`CLICK`</td><td>CLIENT</td><td>any</td><td>Opened the item</td></tr><tr><td>`SEARCH`</td><td>CLIENT</td><td>**none**</td><td>A search was run — what matters is `context.query`</td></tr><tr><td>`NOT_INTERESTED`</td><td>CLIENT</td><td>any</td><td>Explicit negative</td></tr><tr><td>`HIDE`</td><td>CLIENT</td><td>any</td><td>Explicit negative, stronger</td></tr><tr><td>`LIKE`</td><td>SERVER</td><td>POST</td><td>A `post_likes` row inserted</td></tr><tr><td>`UNLIKE`</td><td>SERVER</td><td>POST</td><td>A `post_likes` row deleted</td></tr><tr><td>`COMMENT`</td><td>SERVER</td><td>POST</td><td>A `post_comments` row inserted</td></tr><tr><td>`BOOKMARK`</td><td>SERVER</td><td>POST</td><td>A `post_bookmarks` row inserted</td></tr><tr><td>`SHARE`</td><td>SERVER</td><td>POST</td><td>A `post_shares` row inserted</td></tr><tr><td>`REPORT`</td><td>SERVER</td><td>POST</td><td>A `post_reports` row inserted</td></tr><tr><td>`BLOCK`</td><td>SERVER</td><td>PROFILE</td><td>A `user_blocks` row inserted</td></tr><tr><td>`MUTE_AUTHOR`</td><td>SERVER</td><td>PROFILE</td><td>A `user_mutes` row inserted</td></tr><tr><td>`ADD_TO_CART`</td><td>SERVER</td><td>PRODUCT</td><td>A `cart_items` row inserted</td></tr><tr><td>`REMOVE_FROM_CART`</td><td>SERVER</td><td>PRODUCT</td><td>A `cart_items` row deleted</td></tr><tr><td>`PURCHASE`</td><td>SERVER</td><td>PRODUCT</td><td>An order left `PENDING_PAYMENT` for any status other than `CANCELLED`/`REFUNDED` — **one event per order item**</td></tr><tr><td>`PURCHASE`</td><td>SERVER</td><td>EVENT</td><td>An event booking became `CONFIRMED`</td></tr></tbody></table>

Server events are sent **after the transaction commits**. A rolled-back like sends nothing, and a like made through any code path is captured — nothing depends on a service remembering to emit.

## 5.3 Media measurements are raw, on purpose

`watchMs: 9000` against `mediaDurationMs: 6000` with `loopCount: 1` is a **rewatch**, not bad data. Nothing is clamped to "100 %" before delivery, because the fact that someone watched a clip one and a half times is exactly the signal worth having.

## 5.4 This topic is best-effort by design

If the event log is briefly unavailable, interactions are **dropped and counted**, not queued — they are signal, not records. A missing view is a missing sample; a queued backlog of stale views would be worse. The entity topics are the opposite: those are never lost, and can always be rebuilt with a backfill.

---

# 6. `nexgate.feed-served.v1` — what the feed actually showed

`accountId` · **delete, 14 days** · 48 partitions · **no registered schema**

One event type, `feed.served`. This is the ground truth for ranking evaluation: what was shown, in what order, and why.

### The envelope

No `entityId`, no `entityVersion`, no `receivedAt`: `eventId`, `eventType` (`feed.served`), `occurredAt`, `schemaVersion` (1), `source`, `payload`.

```json
{
  "eventId": "…",
  "eventType": "feed.served",
  "occurredAt": "2026-09-18T10:22:31.900Z",
  "schemaVersion": 1,
  "source": "nexgate-backend",
  "payload": {
    "feedSessionId": "fs-77aa31c8",
    "accountId": "…",
    "surface": "FEED",
    "items": [
      { "type": "POST", "id": "4f2a77e1-…", "position": 0, "source": "FOLLOWING" },
      { "type": "PRODUCT", "id": "8c31a0d2-…", "position": 1, "source": "RECO" }
    ]
  }
}

```

### Payload — every field

<table id="bkmrk-field-type-notes-fee"><thead><tr><th>Field</th><th>Type</th><th>Notes</th></tr></thead><tbody><tr><td>`feedSessionId`</td><td>string</td><td>**Joins to `context.feedSessionId` on interactions**</td></tr><tr><td>`accountId`</td><td>uuid</td><td>Who it was served to</td></tr><tr><td>`surface`</td><td>string</td><td>Currently always the literal `"FEED"` — see the warning below</td></tr><tr><td>`items[].type`</td><td>string</td><td>`POST` · `PRODUCT`</td></tr><tr><td>`items[].id`</td><td>uuid</td><td>The item</td></tr><tr><td>`items[].position`</td><td>int</td><td>Its place on the page, from 0</td></tr><tr><td>`items[].source`</td><td>string</td><td>Why it was there: `FOLLOWING` · `CELEBRITY` · `RECO` · `INTEREST` · `FALLBACK` · `SPONSORED` · `TRENDING`</td></tr></tbody></table>

Joined to the interactions topic on `feedSessionId`, it gives, per session: what was shown, in which position, from which source, and what the viewer did about it.

> ⚠️ **Published by the home feed only**, and `surface` is hard-coded to `"FEED"`. Reels, marketplace and events do not publish it. See §9.

# 7. The way back — what the model writes to Redis

Everything above travels **backend → model**. This section is the other direction: **model → backend**.

There is no API call and no callback. The recommendation service writes plain Redis keys; the feed reads them when it builds a session. If the model stops writing, the feed keeps working on trending and interest pools — quieter, never broken.

## 7.1 The eight keys

<table id="bkmrk-key-read-by-items-mu"><thead><tr><th>Key</th><th>Read by</th><th>Items must be</th><th>When it is read</th></tr></thead><tbody><tr><td>`reco:home:posts:{accountId}`</td><td>Home feed discovery</td><td>post ids</td><td>every home session build</td></tr><tr><td>`reco:home:products:{accountId}`</td><td>Home feed discovery</td><td>product ids</td><td>every home session build</td></tr><tr><td>`reco:reels:{accountId}`</td><td>Reels feed</td><td>post ids (with a short video)</td><td>every reels session build</td></tr><tr><td>`reco:market:products:{accountId}`</td><td>Marketplace "For You"</td><td>product ids</td><td>every marketplace session build</td></tr><tr><td>`reco:events:{accountId}`</td><td>Events "For You"</td><td>event ids</td><td>every events session build</td></tr><tr><td>`reco:shops:{accountId}`</td><td>Suggested shops strip</td><td>shop ids</td><td>every call to the strip</td></tr><tr><td>`reco:similar:products:{productId}`</td><td>"Similar items" on a product page</td><td>product ids</td><td>every call to the strip</td></tr><tr><td>`reco:meta:health`</td><td>backend alerting only</td><td>—</td><td>on every metrics scrape</td></tr></tbody></table>

The `{…}` braces are **part of the key** — they are Redis Cluster hash tags, and they decide which node the key lives on. Write them exactly as shown.

**Every key is optional.** A surface whose key is absent runs on trending and interest pools instead. You can ship one surface at a time.

## 7.2 The value

One JSON string, written with a **single** `SET key <value> EX 172800` (48 h). One command means a reader sees either the whole old list or the whole new one, never half of each.

```json
{
  "v": 1,
  "modelVersion": "ranker-2026-09-14",
  "generatedAt": "2026-09-18T02:10:00Z",
  "items": [
    { "id": "4f2a77e1-0c8b-4d2f-9a11-7b6c3e5d8f90", "score": 0.83 },
    { "id": "9b10c2d4-7e3f-4a52-b8c1-6d9e0f2a3b47", "score": 0.79 }
  ]
}

```

<table id="bkmrk-field-required-rule-"><thead><tr><th>Field</th><th>Required</th><th>Rule</th></tr></thead><tbody><tr><td>`generatedAt`</td><td>**Yes**</td><td>ISO 8601. Without it the whole list is **INVALID** and ignored</td></tr><tr><td>`items`</td><td>**Yes**</td><td>Must be an array. Best first. **At most 300 are read**; the rest are discarded</td></tr><tr><td>`items[].id`</td><td>**Yes**</td><td>A UUID of the right type for that key. Not a UUID → that item is skipped. Unknown or deleted → dropped silently at read time</td></tr><tr><td>`items[].score`</td><td>No</td><td>Kept when inside `[0, 1]`. Missing or out of range → rank is used instead: `1 / (1 + rank)`, so position 0 → 1.0, 1 → 0.5, 3 → 0.25</td></tr><tr><td>`modelVersion`</td><td>No</td><td>Carried into the backend debug trace, so a feed can be explained by the model that produced it</td></tr><tr><td>`v`</td><td>No</td><td>Format version</td></tr></tbody></table>

After each successful batch, write the health key:

```
SET reco:meta:health '{"finishedAt":"2026-09-18T02:40:00Z","modelVersion":"ranker-2026-09-14"}'

```

## 7.3 What the backend does with each list

Read, then judged in one of four states:

<table id="bkmrk-state-condition-used"><thead><tr><th>State</th><th>Condition</th><th>Used?</th></tr></thead><tbody><tr><td>**FRESH**</td><td>`generatedAt` within `reco.maxAgeHours` (default **30 h**)</td><td>Yes</td></tr><tr><td>**STALE**</td><td>Older than that, key still present</td><td>**Yes** — a stale list beats no list. Counted on the backend dashboards</td></tr><tr><td>**MISSING**</td><td>No key</td><td>No → fallback pools fill discovery</td></tr><tr><td>**INVALID**</td><td>Not JSON, no `items` array, or no `generatedAt`</td><td>No → same as missing, but **counted separately**, so a broken writer is visible rather than silent</td></tr></tbody></table>

Every read is counted as `feed.reco.lists{list, state}`, so the state of each list is visible per surface: fresh, stale, missing or invalid.

**How recommended items rank against fallbacks.** A reco item's score becomes `1 + the supplied score`. Fallback items score inside `[0, 1]`. **Every recommended item therefore outranks every fallback item** in the discovery lane, and the pools fill only what was not supplied. Where the same post appears in both, the RECO copy is kept.

**Where they land on the home feed.** Your items fill the **discovery** slots, interleaved with posts from people the user follows in the pattern `F, F, D, F, D, F, F, D` — never the same author twice within 4 slots. A session holds 120 items and is rebuilt on pull-to-refresh or when the user reaches the end, so a newly written list appears at the next session, not mid-scroll.

**The read budget is 200 ms.** Each reco read has a 200 ms timeout. Past that the source returns nothing for that session and the pools fill in. A slow Redis degrades the feed's *quality*, never its availability.

## 7.4 Access

You get a Redis user that can write **only** `reco:*`:

```
ACL SETUSER reco-writer on >******** ~reco:* -@all +set +get +del +expire +exists +ping +cluster|slots +cluster|shards +cluster|nodes

```

Anything outside `reco:*` answers `NOPERM`. Locally: user `reco-writer`, password `reco_writer_local`, seeds `localhost:7001,localhost:7002`.

> **Local docker-compose trap.** The containers announce internal addresses (`172.29.0.11–16`). A cluster client outside the compose network follows those announcements and cannot reach the nodes. The writer should run in a container on the `nexgate-feed-redis` network, or on the host network.

## 7.5 Verifying the integration

<table id="bkmrk-signal-where-means-f"><thead><tr><th>Signal</th><th>Where</th><th>Means</th></tr></thead><tbody><tr><td>`feed.reco.lists{state="FRESH"}` rising</td><td>backend dashboard</td><td>the lists are being read and used</td></tr><tr><td>`feed.reco.lists{state="INVALID"}` above zero</td><td>backend dashboard, alert after 15 min</td><td>the writer is producing something unparseable</td></tr><tr><td>`feed.reco.health.age.seconds`</td><td>backend dashboard, alert above 26 h</td><td>`reco:meta:health.finishedAt` is stale, indicating the batch has stopped. `-1` means no batch has ever been reported</td></tr><tr><td>`source: "RECO"` in a feed response</td><td>the app, and `GET /api/admin/feed/debug/{accountId}`</td><td>an item on a live screen originated from a supplied list</td></tr></tbody></table>

The per-account debug trace is the fastest way to prove the loop end to end: it shows every item the last session produced, its source, its base score and its final score — and every item a filter removed, with the filter's name.

---

# 8. Who does what — do not rebuild this

The backend is not a thin pipe that hands the feed to a model. It performs a great deal of work **after** a list is read, and most of it should not be duplicated — partly because the effort is wasted, and partly because the backend must do it regardless for correctness, so a second implementation can only diverge.

## 8.1 The backend already does all of this

**Visibility and safety** — applied to every candidate, whatever its source:

<table id="bkmrk-filter-removes-block"><thead><tr><th>Filter</th><th>Removes</th></tr></thead><tbody><tr><td>`Blocked`</td><td>either direction of a block</td></tr><tr><td>`Muted`</td><td>authors the viewer muted</td></tr><tr><td>`Self`</td><td>the viewer's own items</td></tr><tr><td>`Status`</td><td>deleted, unpublished, no longer visible</td></tr><tr><td>`DiscoveryPublic`</td><td>followers-only items reached through discovery</td></tr><tr><td>`HiddenItem`</td><td>moderation labels (§4.1, `sensitive_media` becomes an interstitial rather than a removal)</td></tr><tr><td>`Stock`</td><td>out-of-stock products where that matters</td></tr><tr><td>`Age`</td><td>items past the surface's age limit</td></tr><tr><td>`UnfollowedAuthor`</td><td>timeline leftovers from someone since unfollowed</td></tr><tr><td>`Seen` / `Served`</td><td>anything already shown, per surface</td></tr><tr><td>`HydrationFailed`</td><td>items whose card could not be loaded</td></tr></tbody></table>

**Blocked authors, muted authors, deleted items and already-seen items may therefore be left in a submitted list; the backend removes them regardless.** Model capacity spent on exclusions is wasted. `nexgate.blocks.v1` is worth consuming for *training* — a block is a strong negative signal — rather than for filtering the output list.

**Ranking, mixing and presentation** — applied after the supplied scores are read:

- **Lane interleaving per surface.** Home is `F,F,D,F,D,F,F,D` (follow / discovery), reels `D,D,F`, marketplace `D,F,D,D`, events `F,D,D`. You supply the discovery lane; the in-network lane is ours.
- **Author diversity.** An item whose author appeared in the last few slots waits for the first slot where it fits, rather than being dropped.
- **Recency**, **NewShopBoost** and **TimeToStart** (events) as scorers applied on top of the supplied score.
- **Sponsored slots, business rules and per-viewer ad caps** — commercial placement is not a modelling concern.
- **Sessions, cursors and paging.** A session is built once and paged from; filters re-run at page-read time so something deleted mid-scroll disappears.

**Cold start and fallbacks** — a viewer with no history, or a surface whose list is missing, stale, invalid or fully filtered, is served from trending pools, interest pools and recency. **You never need to produce a list for everyone, and a gap costs quality, not availability.**

**An interest profile per user, without a model** — every item carries `interests: [{id, w}]`, and the backend maintains `interest:{accountId}` from the interaction stream with decay, a floor for declared interests, and negative weights for `HIDE` / `NOT_INTERESTED`. It exists so that discovery works before any model is delivered, and so that a feed can be explained in plain language. **It is available as a feature and is not to be reimplemented** — though a learned representation over the raw stream is expected to outperform it, which is the point of §C.5.3.

## 8.2 Recommendation work the backend already does today

This is the part most worth reading, because it is recommendation logic, not plumbing — and it is **live right now, without a model**. Everything here is a baseline that may be used as a feature, or improved upon, but should not be rebuilt.

**Discovery pools, rebuilt every 10 minutes** (`FeedPoolsJob`, one container at a time). Each is built into a temporary key and `RENAME`d into place, so a reader never sees a half-built pool, and each expires after an hour — if the job stops, discovery empties rather than serving hours-old "trending".

<table id="bkmrk-pool-how-it-is-compu"><thead><tr><th>Pool</th><th>How it is computed</th></tr></thead><tbody><tr><td>`pool:{trending:posts}`</td><td>the last **24 hourly buckets** summed with weight `0.9 ^ hoursAgo`, top 500. This hour counts ×1, an hour ago ×0.9, two hours ago ×0.81</td></tr><tr><td>`pool:{trending:products}`</td><td>same decay, over product engagement</td></tr><tr><td>`pool:{trending:reels}`</td><td>trending posts that have a `READY` short clip, same scores</td></tr><tr><td>`pool:{new:reels}`</td><td>newest public clips of the last 7 days, scored by publish time</td></tr><tr><td>`pool:{newshops}`</td><td>products of shops younger than 30 days with fewer than 2 000 impressions, newest first, 200</td></tr><tr><td>`pool:{events:upcoming}`</td><td>public events starting within 60 days or happening now, scored `1 + confirmed bookings in the last 7 days`</td></tr><tr><td>`pool:shops:popular`</td><td>shops by subscriber count</td></tr><tr><td>`pool:cat:{categoryId}`</td><td>best sellers of a category — the fallback behind "similar items"</td></tr><tr><td>`pool:{int:<interestId>}:posts`</td><td>trending posts carrying that interest, score = trending score × interest weight</td></tr><tr><td>`pool:{int:<interestId>}:products`</td><td>in-stock products of that interest's categories, by quantity sold then newest</td></tr><tr><td>`pool:{int:<interestId>}:events`</td><td>upcoming events of that interest's categories, by start date</td></tr></tbody></table>

**A per-user interest profile, maintained from the same interaction stream** (`interest:{accountId}`, flushed to `user_interest_scores`):

```
score[interest] = decay(score[interest]) + actionWeight × itemWeight
decay halves every 14 days

```

with `VIEW` 1, `LIKE`/`BOOKMARK` 2, `COMMENT`/`SHARE` 3, `ADD_TO_CART` 4, purchase 6–8, `NOT_INTERESTED`/`HIDE` **−5**, and a floor of 5 for interests declared at onboarding. Each surface reads the user's **top 5** interests in proportion to score, plus **10 % explore** from outside the top 5 so the profile cannot close in on itself.

**The consequence:** a user who has never been modelled still receives interest-based discovery, and every surface has a defensible answer on day one. A supplied list does not compete with an empty feed; it competes with this baseline. Where both exist, **the supplied list wins**: a reco item scores `1 + the supplied score`, while every pool item scores inside `[0, 1]`.

**Fallbacks per surface, used when a key is missing, stale, invalid or fully filtered:**

<table id="bkmrk-surface-falls-back-t"><thead><tr><th>Surface</th><th>Falls back to</th></tr></thead><tbody><tr><td>Home discovery</td><td>trending posts + trending products + interest pools</td></tr><tr><td>Reels</td><td>trending reels, then new reels</td></tr><tr><td>Marketplace</td><td>interest products, trending products, new shops, products seen in posts</td></tr><tr><td>Events</td><td>interest events, then upcoming by bookings</td></tr><tr><td>Similar items</td><td>`pool:cat:{categoryId}` — best sellers of the same category</td></tr><tr><td>Suggested shops</td><td>new shops (&lt; 30 days), then most subscribers</td></tr></tbody></table>

A surface that has not yet been modelled therefore still works, and **a gap in coverage costs quality, not availability**. Keys may be delivered one at a time.

## 8.3 What the backend expects in return

<table id="bkmrk-candidate-generation"><thead><tr><th></th><th></th></tr></thead><tbody><tr><td>**Candidate generation and ranking, per surface**</td><td>the seven `reco:*` keys of §7.1 — that is the whole contract</td></tr><tr><td>**Similar items**</td><td>`reco:similar:products:{productId}`, the one key that is per item rather than per person</td></tr><tr><td>**Shop suggestions**</td><td>`reco:shops:{accountId}`</td></tr><tr><td>**Cross-domain learning**</td><td>the thing the backend fundamentally cannot do: that people who buy baby products watch parenting reels. One representation of a person from every domain</td></tr></tbody></table>

**The submission mechanism, precisely:** one `SET key <json> EX 172800` per key, per user, each time the batch runs. There is no API call, callback or schema negotiation — the full contract is §7.2, and the backend reads whatever is present the next time it builds a session. Completion is never announced; a fresher `generatedAt` is the signal.

**Where the baseline may already exceed an early model:** trending with hourly decay, category best-sellers for similar items, and the interest profile are all live. Where an early version cannot beat those on a given surface, the correct decision is to **withhold that key** — the fallback is stronger, and an absent key costs nothing. `feed.reco.lists{list,state}` reports, per surface, how often a supplied list is used.

## 8.4 The seam, in one sentence

**The recommendation service decides what is worth showing. The backend decides what is permitted to be shown, in what order, and alongside what.**

Exclusion logic, position rules and fallbacks should not be written: that code already exists in the backend, and two implementations will diverge the first time either one changes.

---

# 9. Known gaps

Stated plainly so nobody builds on an assumption:

<table id="bkmrk-gap-consequence-feed"><thead><tr><th>Gap</th><th>Consequence</th></tr></thead><tbody><tr><td>`feed-served` is published by the **home feed only**, with `surface` hard-coded to `"FEED"`</td><td>No served-ground-truth for reels, marketplace or events. Evaluation on those surfaces has to rely on impressions</td></tr><tr><td>**Event ticket bookings have no entity topic**</td><td>A booking arrives as a `PURCHASE` interaction with `targetType: EVENT`, but there is no order-like stream for bookings. `nexgate.orders.v1` is product orders only</td></tr><tr><td>**No JSON Schema is registered** for `nexgate.feed-served.v1` or `nexgate.fanout-tasks.v1`</td><td>The nine entity topics and interactions have schemas under `src/main/resources/feed-schemas/`; these two do not</td></tr><tr><td>**Interest weights are empty until an admin maps categories**</td><td>`interests: [{id, w}]` on posts, products and events is `[]` for any unmapped category or hashtag. 128 product categories and 12 event categories are still unmapped</td></tr><tr><td>**Follows and shop subscriptions are not yet interest signals**, and `SEARCH` is not matched to an interest</td><td>The backend's interest profile is weaker than the design describes. The delivered stream is unaffected</td></tr></tbody></table>

---

# 10. `nexgate.fanout-tasks.v1` — internal

Backend to backend: when a post is published, the work of writing it into followers' timelines is chunked and sent over this topic. Key is `postId:chunk`, retained 3 days, no schema registered.

**Ignore it.** It carries no signal a model can use, and its shape may change without notice.

---

**Related documents**

<table id="bkmrk-doc-what-it-covers-0"><thead><tr><th>Doc</th><th>What it covers</th></tr></thead><tbody><tr><td>[`06_RECOMMENDATION_DEVELOPER_GUIDE.md`](06_RECOMMENDATION_DEVELOPER_GUIDE.md)</td><td>Connecting, consuming, and writing lists back into Redis</td></tr><tr><td>[`02_KAFKA_CONTRACT.md`](02_KAFKA_CONTRACT.md)</td><td>The original agreement: why these topics, deletes, ordering, versioning, personal data</td></tr><tr><td>[`01_ARCHITECTURE.md`](01_ARCHITECTURE.md)</td><td>The design. §4 is the write path and the outbox; §4.6 is what is deliberately left out of payloads</td></tr><tr><td>[`05_INTERACTIONS_CLIENT_GUIDE.md`](05_INTERACTIONS_CLIENT_GUIDE.md)</td><td>The app's side of the interactions topic: exact definitions of every action</td></tr></tbody></table>