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

9.4 KiB
Raw Permalink Blame 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)

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 /storiesPaginatedStories

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, topicImportanceall 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 /healthServerHealth

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.