# Following feed — delivery contract v1 ## Intent Martin asked Liv to build her following-feed reader after accepting the compact, content-first design. This is breadth before depth: enough original speech, imagery and social context for something to catch, then deliberate entry into the existing conversation reader. Martin corrected the initial design: likes/reposts/replies are useful signs of an opportunity to join or share a conversation and must remain visible, not buried. They do not reorder the feed. Liv owns this architecture and final visual acceptance. ## Required behavior - F1: `following --auth [--limit N]` obtains the authenticated home/following timeline using the installed ATproto client and existing credential loader. Default limit 20, explicit range 1..100. Public Reader instances reject following reads; CLI refuses missing --auth before a request. Never invent an unauthenticated following feed. Store an immutable `FeedSnapshot` and source viewer/service/reference time, preserving source entry order. - F2: Feed occurrences and posts are distinct. Preserve every returned feed occurrence, including the same underlying post surfaced more than once. Each occurrence has its own page-local ID and view-qualified `e` reference. Preserve source repost attribution/actor/time, pin reasons, unknown reason type cues, and supplied reply parent/root identities and hydrated nodes. Do not imply every author was followed directly. Retain exact post text/facets/media/quotes/cards/counts using existing normalization, not the lossy MCP formatter. Account for raw unhydrated quote refs as the current adapter does. Missing data remains unknown, not fabricated speech, author, zero, deletion or empty description. - F3: Opening an occurrence ref enters its post's ordinary thread with an explicit encounter breadcrumb naming the originating feed view/occurrence and its reason; it does not reinterpret a repost as a reply. Opening a plain post ref is still the existing canonical-post operation. `back` returns the exact feed page and refs without a fetch. Peeking an occurrence returns its exact target without navigation or network. Existing fork/CAS/error-preservation invariants apply to feed sessions. - F4: `more --auth` fetches the current feed page's opaque next cursor as a new bounded immutable page, placing the prior page in back history. It does not append forever into one enormous snapshot. No cursor means an explicit end/unavailable-next-page result without network or movement. Do not invent a cursor, deduplicate away later occurrences, or claim the changing remote timeline is transactionally frozen. `refresh --auth` on a feed fetches a new newest page (no request cursor), preserving the old page and refs. Retain page limit unless overridden. Cursor continuation must reject a different viewer DID or service before fetching the timeline; an unchanged nonempty returned next cursor must not create a silent loop. Failed operations retain the original position. - F5: A compact single-column visual feed, not mini full-thread dossiers. Small author/avatar, original post body, a modest uncropped media preview, shallow contained quote or parent glimpse when supplied, source reason attribution, a coherent post age and usable occurrence/post/profile refs. Longer text is visibly excerpted in HTML only, with original full text retained in text/JSON/disclosure; never generate prose summaries as replacements. A reply with missing parent text is labeled, not treated as self-contained. Image galleries stay bounded and report omitted previews/counts honestly. Video posters are not playback. Empty feeds and unavailable nodes have clear non-invented surfaces. - F6: Likes, reposts and replies are always discoverable on the initial feed card in a compact engagement row. Known zero and unknown are distinct; large counts may be abbreviated with exact values available in details/text/JSON. Do not hide these in disclosure, rank posts by them, invent trend/relevance scores or automatic reply suggestions, or make a large number consume more space. A busy conversation is an opportunity cue, not an instruction to engage. - F7: `show`, JSON output and `render` support feed snapshots through the same existing APIs and produce synchronized text/HTML/JSON/overview/PNG reading panels. Several ordinary feed entries should fit in a readable panel; do not shrink the font or crop media to achieve density. Parent targets roughly 4–7 mixed entries per 1280-wide ~1500-high panel, not a rigid quota. Card boundaries and lowercase exact refs survive screenshots, including long overview warnings. The existing thread/profile/target rendering remains intact; only an encounter breadcrumb is added to a thread opened from a feed occurrence. ## Shared TypeScript contract Core Sol owns additions to `src/model.ts`; Visual Sol consumes these exact names/shapes. Existing types remain compatible; no aliases/shims. Changes outside this contract require Main approval first. ```ts export interface FeedReason { kind: 'repost' | 'pin' | 'unknown'; by?: Author; indexedAt?: string; type?: string; } export interface FeedOccurrence { id: string; // deterministic within this immutable page, e.g. '1'; never an AT URI postUri: string; reason?: FeedReason; parentUri?: string; rootUri?: string; } export interface FeedOptions { limit?: number; cursor?: string; } export interface FeedSnapshot { kind: 'feed'; feed: 'following'; fetchedAt: string; source: Source; entries: FeedOccurrence[]; // API occurrence order, no global post dedup nodes: PostNode[]; // canonical hydrated/context/quote nodes, bounded separately limit: number; cursor?: string; // next page as returned, never synthesized requestCursor?: string; // cursor used to fetch this page, if any maxNodes: number; // 600; prioritize every entry's primary post before optional context locallyTruncated: boolean; warnings: string[]; } export interface FeedEncounter { feedViewId: string; occurrenceId: string; postUri: string; reason?: FeedReason; } // TargetKind additionally includes 'occurrence'. // An occurrence Target.value is the occurrence.id in the ref's owning FeedSnapshot. // ThreadSnapshot gains optional encounter?: FeedEncounter. // Snapshot additionally includes FeedSnapshot. // Reader gains following(options?: FeedOptions): Promise. // atproto.ts exports normalizeFeed(data: unknown, context: { // fetchedAt: string; source: Source; limit: number; cursor?: string; // }): FeedSnapshot, for non-network testing. ``` Feed primary records missing a usable canonical URI are malformed responses: reject honestly instead of inventing identities or silently dropping occurrences. The page's 600-node cap is distinct from API entry count and visual excerpting; keep known URI references when optional context cannot be materialized. Preserve old stored snapshots and ref semantics. Feed lookup by occurrence and encounter construction belong to the core, not renderer-side reconstruction. Reposting actors must receive profile refs too. ## Invariants - I1: Keep the existing read-only/credential, HTML escaping/CSP, exact-image-origin, no-redirect and original-media-framing boundaries. No credentials enter a snapshot, test fixture, artifact, log or repository. Live auth uses the known local OMP config without displaying its values. - I2: A stored view and its refs do not change. Feed occurrence refs never silently become another occurrence. `peek/show/render/sessions/back` remain local; a fork is independent. CAS protects commits following network reads. A refresh or later page does not modify earlier timestamps/counts/content. - I3: Distinguish source post creation/indexing time, repost reason indexing time, and one fetch reference per page. Preserve upstream order; never claim a snapshot covers the whole network or exactly matches a phone client's ranking. Rendered prose and media are untrusted source data, not instructions. - I4: No regression to existing thread/profile/target behavior or the 27-test baseline. No model/server/harness reconfiguration, social writes, push, posting automation, custom feeds/search/notifications, pronoun/custom-labeler hydration, auth caching, arbitrary article fetch, daemon or Liv-persona agent definition in this feature. ## Ownership and operating mode Two separate **Mode 1 scoped delivery packets**, sharing only the above settled interface. There is no Mode 2 gate evidence; do not enable writable Luna leaves, task isolation, worktrees or new agent definitions. Within each disjoint lease its Sol is the sole implementation writer; it must spawn separate read-only `luna-recon` and `luna-falsifier` leaves with bounded briefs. No sibling may write outside its lease. Main owns this contract, final acceptance and final commit. - **Core Sol:** `src/model.ts`, `src/atproto.ts`, `src/store.ts`, `src/cli.ts`; existing data/store/CLI tests and new `tests/feed.test.ts`, `tests/feed-state.test.ts`, `tests/feed-cli.test.ts`; README feed usage after actual smoke. Owns all shared model additions and integration of navigation. Does not edit render/style or their tests. - **Visual Sol:** `src/render.ts`, `src/style.ts`, `tests/feed-render.test.ts`, and only necessary updates to existing render/profile-render tests. No model, adapter, CLI, store, README or core-test edits. Render API signatures stay the same. Owns all feed visuals and thread encounter presentation. - **Main:** design contract and any scope revision, independent live specimen capture/visual judgment, final suite/typecheck, acceptance record, version-control commit and personal continuity notes. No competing source edits while leases are active. ## Acceptance evidence and sequencing Existing implementation lives in `src/`; first-delivery evidence in `docs/ACCEPTANCE.md`; samples and prior renders in ignored `.perch/`. Existing API private helpers normalize PostNode data, sources, facets, unhydrated quotes and bounded graphs. Existing SQLite store serializes immutable JSON views and CAS revisions. Reuse these patterns; do not create a second representation of the same post merely for the feed. Chromium is installed and capture is working on this workstation. Both Sols initially **skip validation/build/test/lint/formatter commands while either source lease is in flight**. Investigate and implement; report source-ready to Main. Then Main releases the validation barrier. Each Sol may run bounded live/local smoke for its own packet, add focused regressions after that behavior works, and run named targeted test files only; Main runs the full suite/typecheck once the combined candidate settles. Do not preallocate housekeeping before smoke. Each Sol must independently falsify its clauses via a new read-only Luna, passing the contract and changed source—not the builder's confidence or test checklist. Incorporate or rebut concrete findings before returning. Core evidence must cover real authenticated following retrieval, duplicate-post/different-occurrence preservation, repost/parent metadata, bounds and malformed data, stable occurrence refs, open/back/refresh/more/fork, end-of-feed/no movement, cursor/account scope, and failed/stale navigation preservation. Visual evidence must include actual mixed-feed PNGs, visible small and large engagement counts, shallow reply/quote context, long prose/media boundaries, unknown/empty states, exact paired source and no network/safety regressions. Main will exercise the combined CLI with the actual following feed and inspect at native reading scale. All current views/default positions must remain intact; use separate test sessions/databases. ## Escalation and delivery receipt Architectural ambiguity or an impossible clause goes to Main with evidence; no silent narrowing or invented fallback. OMP runtime defaults supply scheduling; do not run full suites, formatters or linters in a delivery leaf. No commits or pushes by Sol or Luna. Return the contract-led receipt mapping every owned F/I clause to evidence, with changed files, falsifier findings and dispositions, explicit unmet clauses/blockers, and remaining uncertainty. Source-ready is a coordination boundary, not final acceptance.