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-20251001for 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(blogis the catch-all for any other URL). - Surface:
/feedas 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, striputm_*, 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, yankReasonRepository (lib/repositories/feed-post-repository.ts)
Interface: save / findById / findByUrl (dedup) / findAccepted({ limit, before }) / findBySubmitter(userId) / ensureIndexes.
Infrastructure (lib/infrastructure/)
MongoFeedPostRepository— unique index onurl, compound index on(status, judgedAt desc)for the feed query.AnthropicJudge— implementsJudge(domain interface). Calls Claude API with persona system prompt + structured user message containing url/source/title/excerpt/authorHandle/submitter. Returns{ accepted, reason }.UrlContentFetcher—fetchwith browser UA, parse HTML withdeno_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 pendingFeedPost→ calljudge.evaluate()→accept()orreject()→ save → if accepted, increment submitter'sUserScore. Multi-aggregate, earns its place as an app service.yank-feed-post.ts— load →canBeYankedBy→yank()→ 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. CallssubmitFeedPost. 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— FreshApppattern. Auth required, accept→200, reject→200 with verdict in body, dup→409.- No live Claude calls in tests —
Judgeis an interface, inject a stub.
Env vars to add
ANTHROPIC_API_KEY # judge model calls
ANTHROPIC_JUDGE_MODEL # default: claude-haiku-4-5-20251001Phase 1 work checklist
- Draft
judge/persona.md(review tone before wiring infra) -
FeedPostaggregate + value objects + domain tests -
FeedPostRepositoryinterface -
MongoFeedPostRepository+ indexes -
Judgedomain interface +AnthropicJudgeinfrastructure impl -
UrlContentFetcherinfrastructure impl (with X/LinkedIn fallback) -
submitFeedPostapp service + tests -
yankFeedPostapp service + tests -
UserScorefeedScorecomponent wiring -
lib/app/repos.tscomposition 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
- Judge name — "The Gator" or something else?
- Submission auth — any operative, or restricted by tier? Defaulting to any authed operative.
- Score timing — confirmed: on-accept (instant), not next refresh batch.
Open
No activity in this phase yet.
Sign in to post a ripple.