# What the iOS Client Actually Requires (derived from client source) This is reverse-engineered from the Swift `Codable` models (`Jarvis/Models/Models.swift`) and the two decoders (`APIClient.swift`, `WebSocketManager.swift`) — i.e. the real arbiter of what the backend must emit. Where this disagrees with `BACKEND_HANDOFF.md`, **this wins**. Read alongside the pre-handover checklist. ## Decoder facts (both REST and WS use the same config) ```swift decoder.dateDecodingStrategy = .iso8601 // Foundation .withInternetDateTime decoder.keyDecodingStrategy = .convertFromSnakeCase ``` Three consequences that drive everything below: 1. **Dates: `YYYY-MM-DDTHH:MM:SSZ` only** — UTC, literal `Z`, **no fractional seconds, no `+00:00` offset**. `.iso8601` rejects microseconds. This is the #1 break. 2. **Key casing is forgiving** — because of `.convertFromSnakeCase`, **either** `signalScore` **or** `signal_score` decodes correctly (camelCase has no underscores so it passes through; snake_case is converted). So you are NOT forced into camelCase — but camelCase matches the contract examples, so prefer it and stay consistent. 3. **A Swift non-optional property that is missing OR `null` throws** — and because lists are decoded as `[StorySummary]` / `[Feed]`, **one bad element fails the entire array**, so the whole page/response is dropped and the screen goes empty. The store swallows the error silently (no crash, no log). So "mostly correct" = "empty feed." Every required field on every object must be present, non-null, and the right type. --- ## Required vs nullable — by endpoint Legend: **R** = required (must be present, non-null, correct type) · **N** = nullable (may be `null`, but the **key should still be present**). ### `GET /stories` → `PaginatedStories` | Field | Req | Type | Notes | |---|---|---|---| | `data` | R | array | each element a **StorySummary** (below) | | `nextCursor` | N | string\|null | pass back verbatim as `after` | | `hasMore` | R | bool | | | `total` | R | int | | **StorySummary** (one per `data[]`): | Field | Req | Type | Notes | |---|---|---|---| | `id` | R | string | | | `headline` | R | string | canonical story headline | | `summary` | R | string | **non-null** — must synthesize a cross-source summary | | `topic` | R | string | one of `finance/tech/politics/africa` | | `signalScore` | R | int | integer, not float/string | | `scoreBreakdown` | R | object | all 5 ints R (below); **sum == signalScore** | | `sourceCount` | R | int | | | `sources` | R | array | **REQUIRED here** (see gap #1); may be `[]` but key must exist | | `consensus` | N | string\|null | | | `conflict` | N | string\|null | | | `updatedAt` | R | date | `...Z` | | `createdAt` | R | date | `...Z` | **scoreBreakdown**: `sourceAuthority`, `freshness`, `localRelevance`, `crossSourceConfirmation`, `topicImportance` — **all R, all int**. **StorySource** (each `sources[]`): `id` R, `name` R, `url` R, `publishedAt` R (date), `isBreaking` R (bool). ### `GET /stories/{id}` → `StoryDetail` Same as StorySummary **except**: **no `sources` field**, and **adds `timeline`** (R, array). `consensus`/`conflict` N. `summary` R. **TimelineEntry** (each `timeline[]`): `articleId` R, `source` R, `headline` R, `publishedAt` R (date), `isBreaking` R (bool). Client renders the **first** entry with the orange node and shows a BREAKING badge where `isBreaking == true`. ### `GET /articles/{id}` → `Article` | Field | Req | Type | |---|---|---| | `id`, `storyId`, `source`, `sourceUrl`, `headline`, `body` | R | string | | `publishedAt` | R | date | | `imageUrl` | N | string\|null | | `author` | N | string\|null | `body` is shown as the full article — give the richest text you have (HTML-stripped). ### `GET /feeds` → `{ "data": [Feed] }` Must be wrapped in `{"data": [...]}`. **Feed**: | Field | Req | Type | Notes | |---|---|---|---| | `id`,`name`,`url` | R | string | | | `health` | R | enum | **exactly** `"active"`\|`"failing"`\|`"dead"` — any other string fails the whole array | | `pollIntervalSeconds` | R | int | | | `failureCount` | R | int | | | `lastFetchedAt` | N | date\|null | `...Z` or null | | `articleCountToday` | R | int | | ### `GET /health` → `ServerHealth` `status` R (string), `version` R (string), `storiesCount` R (int), `feedsCount` R (int), `uptime` R (int). All required — a missing one fails onboarding's connect check. --- ## What the client SENDS (match these exactly) - `GET /stories` query params: **`limit`** (int, default 20), **`after`** (cursor), **`topic`** (slug), **`min_signal`** (int, **snake_case** — read this literal name). The app currently always sends `limit=20`, sends `topic` only when a pill ≠ All is selected, and does **not** currently send `min_signal` (but support it). - `POST /feeds` JSON body: **`{"url": ..., "name": ..., "pollIntervalSeconds": ...}`** (the app currently always sends `pollIntervalSeconds: 900`). Respond **201** + the Feed. - `DELETE /feeds/{id}` → must be **HTTP 204**, empty body. The client explicitly checks `statusCode == 204` and treats anything else as a failure (it rolls back the deletion). --- ## WebSocket — minimum fields per event The client ignores events that lack the fields it needs (no error, just no effect). | Event `type` | Client requires | Effect | |---|---|---| | `story.updated` | **`storyId` + `signalScore` + `sourceCount`** (all three) | patches the row in place, re-sorts by score. Missing any → ignored. | | `story.created` | `type` only | triggers a full `GET /stories` refetch | | `story.stale` | `storyId` | removes that story from the feed | | `feed.health` | `feedId` + `health` (valid enum) | updates the feed's health dot; `failureCount` used if present | | `ping` | — | client auto-replies `{"type":"pong"}`; **server should send ping every 30s** | WS date fields (`updatedAt`/`createdAt`) are currently **not used** by the client after the in-place patch, so they're not load-bearing — but if you send them, still use `...Z`. --- ## RSS → iOS: what comes from the feed vs what you must MANUFACTURE The app never sees an RSS field directly — it sees **Stories** (clusters) and **Articles**. RSS only fills the article layer; everything story-level is computed. | iOS field | Source | Notes | |---|---|---| | `Article.headline` | RSS `title` | | | `Article.body` | RSS `content`/`description` | HTML-stripped; full text if you fetch the page | | `Article.publishedAt` | RSS `pubDate`/`published` | → UTC, fall back to now if absent | | `Article.author` | RSS `author`/`dc:creator` | nullable | | `Article.imageUrl` | RSS `media:content`/`enclosure`/first `` | nullable | | `Article.sourceUrl` | RSS `link` | | | `Article.source` | the **feed's** name | | | `Article.id`, `storyId` | **you generate / assign** | storyId from clustering | | **`Story.headline`** | **computed** | pick canonical from the cluster | | **`Story.summary`** | **computed** | cross-source summary — must be non-null | | **`Story.topic`** | **computed** | classify into finance/tech/politics/africa | | **`signalScore` + breakdown** | **computed** | the 5-component rubric | | **`sourceCount`, `sources[]`, `timeline[]`** | **computed** | cluster aggregation | | **`consensus`, `conflict`** | **computed** | nullable | | **`isBreaking`** | **computed** | heuristic (earliest in cluster / recency / RSS flag) | So plugging in real feeds = (1) parse entries → Articles, (2) **cluster** them into Stories, (3) **score + classify + summarize**, (4) shape into the exact objects above. Steps 2–3 are the whole product; RSS gives you almost none of it. --- ## GAPS / CORRECTIONS vs `BACKEND_HANDOFF.md` 1. **`sources[]` is REQUIRED on `GET /stories` list items** (not just the contract's nice-to-have). It is **absent** on `GET /stories/{id}` (which has `timeline` instead). Easy to forget because the two endpoints differ — omitting `sources` on the list endpoint makes the **entire home feed decode fail → blank screen**. Highest-risk item. 2. **camelCase is NOT mandatory** — the handoff overstated this. `.convertFromSnakeCase` means snake_case decodes fine too. The real hard requirement is the **date format**, not the casing. (Still: pick one and be consistent; camelCase matches the contract.) 3. **One bad object fails the whole response, not just that object.** Stricter than the handoff's "drops the object." Per-element validity matters — especially the `health` enum and any required field being null. 4. **`summary` must be non-null** on both story endpoints. RSS has per-article descriptions, but the *story* summary is synthesized — don't leave it null. 5. **`health` must be exactly `active`/`failing`/`dead`.** A stray value (e.g. `"error"`, `"ok"`, `"unknown"`) fails the whole `/feeds` array → empty feed manager. 6. **`min_signal` is snake_case** in the query string even though responses are camelCase — read the literal param name `min_signal`. 7. Numeric fields must be **JSON numbers**, not strings (`"signalScore": 91`, not `"91"`). --- ## Fastest way to de-risk before the app ever connects Decode-test each endpoint with Swift's exact rules. A 5-line check that mirrors `.iso8601`: every date must match `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`. Then assert `sources` exists on `/stories` items, `timeline` on `/stories/{id}`, and that no required field is null. (The pre-handover checklist §A/§B has runnable versions.) If those pass, the app will populate on first connect.