Files
jarvis/docs/IOS_CLIENT_EXPECTATIONS.md
Robin Kutesa 27d0e22767 Initial commit: Jarvis iOS app
SwiftUI client for a self-hosted RSS news-correlation platform: signal feed,
story detail, article reader, feed manager, and LAN⇄Tailscale connectivity.
Project generated from project.yml via XcodeGen.

Includes CI build matrix (macOS 14/15 × Debug/Release), issue templates,
backlog, and API/backend handoff docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:59 +03:00

189 lines
9.4 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.