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>
9.4 KiB
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)
decoder.dateDecodingStrategy = .iso8601 // Foundation .withInternetDateTime
decoder.keyDecodingStrategy = .convertFromSnakeCase
Three consequences that drive everything below:
- Dates:
YYYY-MM-DDTHH:MM:SSZonly — UTC, literalZ, no fractional seconds, no+00:00offset..iso8601rejects microseconds. This is the #1 break. - Key casing is forgiving — because of
.convertFromSnakeCase, eithersignalScoreorsignal_scoredecodes 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. - A Swift non-optional property that is missing OR
nullthrows — 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 /storiesquery params:limit(int, default 20),after(cursor),topic(slug),min_signal(int, snake_case — read this literal name). The app currently always sendslimit=20, sendstopiconly when a pill ≠ All is selected, and does not currently sendmin_signal(but support it).POST /feedsJSON body:{"url": ..., "name": ..., "pollIntervalSeconds": ...}(the app currently always sendspollIntervalSeconds: 900). Respond 201 + the Feed.DELETE /feeds/{id}→ must be HTTP 204, empty body. The client explicitly checksstatusCode == 204and 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 2–3 are the whole product; RSS gives you almost none of it.
GAPS / CORRECTIONS vs BACKEND_HANDOFF.md
sources[]is REQUIRED onGET /storieslist items (not just the contract's nice-to-have). It is absent onGET /stories/{id}(which hastimelineinstead). Easy to forget because the two endpoints differ — omittingsourceson the list endpoint makes the entire home feed decode fail → blank screen. Highest-risk item.- camelCase is NOT mandatory — the handoff overstated this.
.convertFromSnakeCasemeans 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.) - 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
healthenum and any required field being null. summarymust be non-null on both story endpoints. RSS has per-article descriptions, but the story summary is synthesized — don't leave it null.healthmust be exactlyactive/failing/dead. A stray value (e.g."error","ok","unknown") fails the whole/feedsarray → empty feed manager.min_signalis snake_case in the query string even though responses are camelCase — read the literal param namemin_signal.- 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.