Skip to main content
← Back to list
01Issue
FeatureOpenSwamp Club
AssigneesNone

Phase 1: /feed — judge-gated content stream

Opened by stack72 · 4/9/2026· GitHub #359

Goal

Build the bones of a swamp-curated content feed. Operatives submit URLs (their own or other people's) — X posts, LinkedIn posts, blog posts, GitHub repos, HN/Reddit threads. A single LLM-driven Judge decides whether each submission is signal or noise. Accepted posts land on /feed. Rejected posts never appear publicly; the submitter sees a rejection message and that's it.

This issue covers Phase 1 only — get the bones into the system. Connected-account auto-ingestion, RSS, personalization, and decay ranking are explicitly deferred.

Decisions locked in

  • Single judge, not a council. Harsh by design — yank shouldn't be needed.
  • Claude API (claude-haiku-4-5-20251001 for the judge — cheap, fast, sufficient for binary classification with reasoning).
  • Submission: any authed operative; can self-submit or submit on someone else's behalf. Phase 2 will add ingestion from connected accounts.
  • Server-side fetch of URL metadata where possible. X/LinkedIn are scraper-hostile; submitter provides title/excerpt for those.
  • Source allowlist: x | linkedin | github | hn | reddit | blog (blog is the catch-all for any other URL).
  • Surface: /feed as a new top-level route. Accepted posts also appear on /u/{username}.
  • Score integration: yes (accepted posts contribute to UserScore). No new badges.
  • Yank: allowed for author or admin, but the bar is so high it shouldn't fire often.
  • No appeals. No public verdicts. Rejection is private to the submitter.

Domain model (lib/domain/feed/)

FeedPost aggregate root. Justifies its existence per the DDD design gate:

Question Answer
What invariants? One-way pending → accepted | rejected. Only accepted can be yanked. URL is canonicalized at construction. Source must be allowlisted.
What state transitions? accept(reason), reject(reason), yank(userId, reason) — each throws on invalid prior state.
Does the repo work through the entity? Yes — save() takes a FeedPost, findById() returns one.
Commands separated from queries? Yes — submitFeedPost/yankFeedPost are commands; findAccepted/findBySubmitter are queries on the repo.
Domain logic in the domain? Yes — canonicalization, state rules, yank eligibility all on the entity.

Value objects

  • FeedPostUrl — canonicalizes (lowercase host, strip fragments, strip utm_*, drop trailing slash) and exposes the source.
  • FeedSource — typed allowlist.
  • JudgeVerdict{ accepted: boolean; reason: string; judgedAt: Date }.

Stored shape (new Mongo collection: feed_posts)

id, submitterId, url, source, title, excerpt, ogImage,
authorHandle (optional — original poster on X/LinkedIn if not the submitter),
status, verdictReason, judgedAt,
createdAt, updatedAt, yankedAt, yankedByUserId, yankReason

Repository (lib/repositories/feed-post-repository.ts)

Interface: save / findById / findByUrl (dedup) / findAccepted({ limit, before }) / findBySubmitter(userId) / ensureIndexes.

Infrastructure (lib/infrastructure/)

  • MongoFeedPostRepository — unique index on url, compound index on (status, judgedAt desc) for the feed query.
  • AnthropicJudge — implements Judge (domain interface). Calls Claude API with persona system prompt + structured user message containing url/source/title/excerpt/authorHandle/submitter. Returns { accepted, reason }.
  • UrlContentFetcherfetch with browser UA, parse HTML with deno_dom, extract <title>, og tags, twitter card tags, meta description. For X/LinkedIn, short-circuit and require submitter input.

The judge persona

New file: lib/infrastructure/judge/persona.md. Same first-person opinionated voice as swamp-media/design/adversary/{completionist,hoarder,weeb}.md. Working name: The Gator — keeper of the swamp.

Accept: original builds, hard-won technical writeups, weird experiments, real shipping, unflinching post-mortems — anything where the author actually did the thing.

Reject: engagement bait, motivational LinkedIn slop, vague platitudes, naked self-promotion without substance, hot takes without skin in the game, anything optimized for a feed rather than written because it had to exist.

The persona is prompt config (infrastructure), but Judge is a domain interface in lib/domain/feed/judge.ts. Keeps the prompt swappable and tests stub-friendly.

Application services (lib/app/)

  • submit-feed-post.ts — dedup check by URL → fetch metadata → construct pending FeedPost → call judge.evaluate()accept() or reject() → save → if accepted, increment submitter's UserScore. Multi-aggregate, earns its place as an app service.
  • yank-feed-post.ts — load → canBeYankedByyank() → save.

UserScore integration

Add a feedScore component to UserScore, mirroring how extensionScore works. Each accepted post = +10 (tunable). Yank deducts. Update on accept, not on the next refresh batch — feels right for something the user just clicked submit on.

Routes

  • routes/feed/index.tsx — server-rendered list of accepted posts (chronological, latest 50). Plain link cards: source icon + title + excerpt + submitter handle. No hydration unless needed.
  • routes/feed/submit.tsx — submission form. Auth-gated. Fields: URL (required), title (optional, required for X/LinkedIn), excerpt (optional, required for X/LinkedIn).
  • routes/api/v1/feed/submit.ts — POST. Calls submitFeedPost. Returns { status, verdict } — verdict shown only on rejection.
  • routes/api/v1/feed/preview.ts — GET. Runs the metadata fetcher so the submission form can preview the card before judgement.
  • routes/api/v1/feed/[id]/yank.ts — POST. Auth + ownership/admin guard.
  • islands/FeedSubmit.tsx — client-side validation, URL preview, inline rejection display.

Profile surface

Add an "Accepted to feed" section on routes/u/[username].tsx showing the operative's latest 5 accepted posts with a link to the filtered feed view.

Composition root

Add getFeedPostRepo(), getJudge(), getUrlContentFetcher() to lib/app/repos.ts. Wire deps bags for submitFeedPost and yankFeedPost.

Tests

  • tests/domain/feed-post.test.ts — invariants, state transitions, URL canonicalization, source validation, yank rules.
  • tests/app/submit-feed-post.test.ts — in-memory repo + stub judge + stub fetcher. Cover accept / reject / dup / score-increment paths.
  • tests/routes/feed.test.ts — Fresh App pattern. Auth required, accept→200, reject→200 with verdict in body, dup→409.
  • No live Claude calls in tests — Judge is an interface, inject a stub.

Env vars to add

ANTHROPIC_API_KEY      # judge model calls
ANTHROPIC_JUDGE_MODEL  # default: claude-haiku-4-5-20251001

Phase 1 work checklist

  • Draft judge/persona.md (review tone before wiring infra)
  • FeedPost aggregate + value objects + domain tests
  • FeedPostRepository interface
  • MongoFeedPostRepository + indexes
  • Judge domain interface + AnthropicJudge infrastructure impl
  • UrlContentFetcher infrastructure impl (with X/LinkedIn fallback)
  • submitFeedPost app service + tests
  • yankFeedPost app service + tests
  • UserScore feedScore component wiring
  • lib/app/repos.ts composition wiring
  • routes/feed/index.tsx
  • routes/feed/submit.tsx
  • routes/api/v1/feed/submit.ts
  • routes/api/v1/feed/preview.ts
  • routes/api/v1/feed/[id]/yank.ts
  • islands/FeedSubmit.tsx
  • Profile surface on /u/[username]
  • Route tests
  • Env vars documented in CLAUDE.md

Out of Phase 1 (deferred)

  • Connected-account ingestion (X/LinkedIn timelines via OAuth scopes / webhooks)
  • RSS auto-ingestion (bones here will support it — judge is invokable from anywhere)
  • Profile-driven feed personalization, tagging, decay ranking
  • Manual page docs for /feed
  • Source-specific extractors beyond OG tags (Nitter for X, etc.)
  • Moderation queue UI

Open questions

  1. Judge name — "The Gator" or something else?
  2. Submission auth — any operative, or restricted by tier? Defaulting to any authed operative.
  3. Score timing — confirmed: on-accept (instant), not next refresh batch.
02Bog Flow
OPENTRIAGEDIN PROGRESSSHIPPED

Open

4/9/2026, 4:44:41 PM

No activity in this phase yet.

03Sludge Pulse

Sign in to post a ripple.