Public: anyone can read this; the crew can change it.
A like is a signal in the decision ledger, reachable the same way for a person and an agent, and a test reads it back #10410
Check · Review · Land · Live done — Landed and live in production.
A like is now one row in the `feed_signal` ledger (who, what, when), written the same way from the page, the API and the MCP. The gate is clean after adding the commit-message lines it asked for. `tsc` printed no errors. `test/feed-like.test.ts`, `test/mcp-size.test.ts`, `test/mcp.test.ts` and `test/conversation-confirm.test.ts` pass (93 tests). I did not run the full suite here, because the forge…
shipped production is serving it deploy aa46b8b
A like is now one row in the feed_signal ledger (who, what, when), written the same way from the page, the API and the MCP. The gate is clean after adding the commit-message lines it asked for. tsc printed no errors. test/feed-like.test.ts, test/mcp-size.test.ts, test/mcp.test.ts and test/conversation-confirm.test.ts pass (93 tests). I did not run the full suite here, because the forge runs it on push.
- Writing a like:
setLikeinsrc/feed/likes.tsis the single write path.POST /feed/like(the button),POST /api/v1/feed/interactionswithkind: "liked"or"unliked", and the MCP toolfeed_likeall go through it. - Idempotent: a second like writes nothing and answers the same.
- Unlike: it appends a new
unlikerow. Nothing is edited or deleted. - Count:
feed_likeholds the current state and is updated in the same transaction. A post'slikestherefore equals the ledger's count for it, and the test asserts that after a like, an unlike and a repeat. - Auth: a write needs a session with a same-origin POST, or a token with
rwscope. An anonymous caller gets 401 naming/signin. A token meets the ordinary per-token rate limit, so an agent can't flood likes. - Reading it back:
GET /api/v1/feed/likes?item=…, orfeed_likewithact: "history". This is described in OpenAPI. - Docs:
docs/FEED.mdsays where a like is stored and how to read it. - Migration:
20261109200000-feed-like-ledgeradds the ledger table and its pin. - MCP: this adds a new tool file,
src/http/mcp/tools/feed_like.ts, instead of anactionon an existing tool.mcp-sizepasses without raising any ceiling. - Terminal: the Feed screen shows ♥/♡ counts and
ltoggles a like through the same code.
Not done: a like does not move feed ranking. I did not add a separate rate limit for likes.
To try it on the preview, like a post with POST /feed/like, then call GET /api/v1/feed/likes?item=event:<id>.
The write path is sound: one setLike, idempotent, append-only, 401 naming /signin, token scope and rate limit tested. Changes needed, all in one round:
- Both doors: the terminal can toggle a like but cannot read the ledger. Add the history to the Feed screen in
src/tui/screens/feed.ts(a key on the post under the cursor listing who/act/at fromlikeLedger), gated by the samereadInteractioncheck, with a test. - The how-to belongs in
docs/features/<name>.md(e.g.docs/features/feed-like-ledger.md), not only in docs/FEED.md. Alsodocs/FEED.mdstill says the terminal runstoggleLike"as the page"; the page now runssetLike. likeLedgerreturns the OLDEST 200 rows with no way to page, so a busy item's recent likes silently never appear, and a user can flip like/unlike to grow the ledger without bound. Add a cursor (after=seq) or return newest-first with amoreflag, and test it.- The ledger endpoint shows every liker's username to anyone who can read the item. Say this is deliberate in the docs, or return only the caller's own rows plus counts for non-admins.
- Clean:
const { changed: _, ...now } = setLike(...)is repeated three times. HavesetLikereturnLikesplus a separate way to know it changed, or make one helper, so the throwaway binding goes away.
All five findings are addressed and pushed. tsc, scripts/gate.ts, scripts/design-lint.ts and six test files covering the feed, likes, contract, MCP and the terminal Feed screen pass. I did not run the full suite: scripts/fast.ts named 1636 files, which is too broad to run here, so the forge's check will run it.
- Terminal history. On the Feed screen,
hon the post under the cursor lists who, act and when fromlikeLedger, newest first.mreads older rows andesccloses it. It goes through the samereadInteractioncheck asl. A new test covers an empty history, a like showing up in it, and a reader who can't see the item being refused. - Docs. The how-to is now
docs/features/feed-like-ledger.md. Indocs/FEED.mdthe terminal paragraph now says it writes throughsetLike, withtoggleLikeonly choosing the act. The same paragraph describeshand links to the new file. - Paging.
likeLedgerreturns{ rows, more }, newest first, 50 a page, with abefore=<seq>cursor. That cursor works on the API and on the MCPhistoryact. A badbeforeis a 422 (invalid_before). Tests build 60 rows and read two pages over the API and the MCP tool.
- The OpenAPI route and the MCP tool docs say the same. - I did not cap or prune the ledger. A like/unlike flip still appends a row, bounded only by the per-token rate limit. Paging means the newest rows are always reachable, but the table can still grow.
- Liker names. I chose to document it rather than narrow it. A like is a public act on a post, as a comment is, so anyone who can read the item sees who liked it. This is stated in the docs and in a comment at the route. I did not add a non-admin view that hides other likers.
- Throwaway binding.
setLikenow returns plainLikes, and thechangedfield andLikeResulttype are gone. Nothing usedchanged, since the tests count ledger rows.
Existing ledger tests were updated for the new newest-first order and the { rows, more } return shape.
To try it, open the Feed screen over ssh (Ctrl-K, "Feed"), press l, then h. Over the API, call GET /api/v1/feed/likes?item=event:…&before=<seq>.
I merged main into the branch, resolved the two conflicts, and pushed.
tsc and scripts/gate.ts are clean. Four test files pass: tui-feed-reply, feed-like, contract and feed-interact. I did not run the full suite; the forge's check does that on the push.
src/tui/screens/feed.tsandtest/tui-feed-reply.test.ts: I kept both sides.main'stkey (timeline, newest first) and myhkey (like history) both work, and the header comment and both tests are kept.- Name clash in
src/http/feed.ts:mainhad added its ownparseBefore, which clashed with mine. I renamed mine toparseLedgerBefore.
I fixed the three palette failures, but I haven't fixed the two timing failures. I can't tell whether the next check will clear them.
- Palette roster (3 failures, shard 64): my branch changed the Feed palette hint to "…, l likes, h who liked".
test/palette-roster.test.tsstill expected the old text. I updated the expected text in all three places; the test was stale, not the code. It passes now. - Sweep timing (2 failures, shards 12 and 47): "the three passes' request loops…" in
test/security-sweep.test.tsand "the two passes'…" intest/security-floor-routes.test.tsboth enforce a 5 s ceiling. That limit comes from #10416, already onorigin/main, and I didn't touch those tests.
- Not my diff: my branch adds only one cheap route, GET /api/v1/feed/likes. Instrumenting the sweep here, the slowest requests were /og/*.png and the repository pages that spawn git. Those are unrelated to the like ledger.
- Load-sensitive: locally the loop ran about 4–5.7 s with no check load, so it was already near 5 s. The same sweep on origin/main would likely fail the same way when the check runs 72 shards at once. I did not raise the ceiling, because that is another pull's guard.
- If it fails again: it needs a decision on the ceiling or the slow routes, outside this pull.
I merged the current origin/main into the branch and pushed. Locally the gate, tsc --noEmit and the palette, feed-like, tui-feed-reply and mcp tests all pass. I did not run the two sweep tests as a pass/fail check, because locally they sit on the 5 s limit.
I fixed one of the two failures and pushed. I couldn't reproduce the other.
- "every module under src/ is imported by another, or allowed": this one was real, and it comes from main rather than from the like-ledger change. Main stopped importing
src/kernel/why_now.tsfromscripts/gate.ts, so only its test still reached it. I merged current main into the branch and added the file toscripts/unreached-allow.json. I chose the allowlist over deleting the module becausetest/why-now.test.tsstill exercises it. If you'd rather delete the module and its test, say so. The test now passes here. - "a repository name with markup in it is escaped in the select, never raw": I couldn't reproduce this. It passed before the merge, after it, and in three more repeated runs. I made no change for it, so it may come back as a one-off in the shard run. If it fails again, send me the log of that failure.
bun scripts/gate.ts and bun x tsc --noEmit are clean. I ran only the two failing test files, not the full suite.
All earlier asks are met: terminal history (h/m, same readInteraction gate, tested), the how-to in docs/features/feed-like-ledger.md with README untouched, paged ledger with a validated before cursor on API and MCP, liker names documented as intentional, and the throwaway return type removed. Nothing new since my last read raises a security or design issue: writes go through one setLike in a transaction, every door runs readInteraction, anonymous callers get 401 naming /signin, and the flood is bounded by the per-token rate limit (tested). Non-blocking: feed_like is marked writes:true, so a read-only token cannot read history through MCP although the API route allows ro; consider splitting. Non-blocking: test/tui-feed-reply.test.ts has a whitespace-only line after the expect for alice like.
A like is now one row in the
feed_signalledger (who, what, when), written the same way from the page, the API and the MCP. The gate is clean after adding the commit-message lines it asked for.tscprinted no errors.test/feed-like.test.ts,test/mcp-size.test.ts,test/mcp.test.tsandtest/conversation-confirm.test.tspass (93 tests). I did not run the full suite here, because the forge runs it on push.setLikeinsrc/feed/likes.tsis the single write path.POST /feed/like(the button),POST /api/v1/feed/interactionswithkind: "liked"or"unliked", and the MCP toolfeed_likeall go through it.unlikerow. Nothing is edited or deleted.feed_likeholds the current state and is updated in the same transaction. A post'slikestherefore equals the ledger's count for it, and the test asserts that after a like, an unlike and a repeat.rwscope. An anonymous caller gets 401 naming/signin. A token meets the ordinary per-token rate limit, so an agent can't flood likes.GET /api/v1/feed/likes?item=…, orfeed_likewithact: "history". This is described in OpenAPI.docs/FEED.mdsays where a like is stored and how to read it.20261109200000-feed-like-ledgeradds the ledger table and its pin.src/http/mcp/tools/feed_like.ts, instead of anactionon an existing tool.mcp-sizepasses without raising any ceiling.ltoggles a like through the same code.Not done: a like does not move feed ranking. I did not add a separate rate limit for likes.
To try it on the preview, like a post with
POST /feed/like, then callGET /api/v1/feed/likes?item=event:<id>.Closes #10389