Files
jarvis/docs/IOS_CLIENT_EXPECTATIONS.md

189 lines
9.4 KiB
Markdown
Raw Permalink Normal View History

# 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 `<img>` | 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 23 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.