justhtml comments — markdown rendering

the technical plan plus five design options for rendering comment bodies as markdown in the justhtml.sh review rail.

the problem

today every comment body is dumped as a flat string — markdown source shows up verbatim, so a structured review reads as one undifferentiated wall of serif text in the 320px rail.

before — rendered as plain text

**Review** — verified against `kernel/kernel`. Premise checks out: 30-day reaper on `deleted_at`, real jwt column, `clickhouse-go` reuse all confirmed. Four things before M1:

1. **Cursor can silently drop rows (blocker).** `deleted_at` is stamped at *statement* time, not commit time — a row deleted at `T` that commits *after* a concurrent later-`T` row has advanced the cursor never gets exported, then gets reaped. Fix: don't advance to `now()`; export `deleted_at < now()-δ` and re-scan the trailing δ each cycle.
2. **M2 row-count parity will be flaky.** `ReplacingMergeTree` dedupes on async merge, so a plain `count()` over-counts un-merged duplicates. Compare on the `(deleted_at, id)` key using `FINAL` / `uniqExact`:

```sql
SELECT uniqExact(id) FROM sessions FINAL WHERE deleted_at < now() - 30
```

3. **Watermark key is inconsistent.** Config uses `Name="sessions"` but the table examples store `name="export:sessions"` — they never match.
4. **Integration nits:**
   - `ent/schema/` is generated from `schema.hcl` — there's no hand-authored "Watermark ent model."
   - Keyset index is likely fine — but `EXPLAIN` to confirm a range scan with no filesort.

> Net: M1 is close, but the cursor drop is a correctness blocker — fix before merge.

the source has bold, inline code, a numbered list with nested bullets, a fenced sql block, and a blockquote — none of it survives. asterisks, backticks and pipes render literally; the structure a reviewer wrote is lost.

implementation plan

how the markdown renderer drops into the comment rail, and the one decision that carries real risk.

summary

Render comment bodies as Markdown in the kernel/just-html comment rail by introducing one shared client component, <CommentMarkdown body={...}/>, built on react-markdown v10 + remark-gfm + remark-breaks (the exact stack hypeship uses), and swapping it into the two plain-text render sites in app/d/[slug]/CommentsShell.tsx (root body line 903, reply body line 930). The load-bearing decision is sanitization: comment bodies are untrusted and the rail renders in the justhtml.sh shell origin (cookies/session), NOT the sandboxed doc iframe, so this is a real stored-XSS surface. We rely on react-markdown v10's safe-by-default behavior (no rehype-raw, no allowDangerousHtml) AND add rehype-sanitize as defense-in-depth, because a single future PR that flips raw-HTML on would otherwise become an account-takeover bug. Styling is a scoped .jh-md CSS class injected via a <style> block (the file's existing RAIL_CSS pattern), reading the established --jh-card-* vars so it themes in both light and adaptive-dark and survives the ~320px rail. Defer Shiki: the sample comments contain a fenced SQL block, but Shiki's client bundle (multi-MB of grammars/themes) is unjustifiable for a 320px rail — code blocks render as styled, scrollable, monospaced <pre> instead.

recommended library

react-markdown@10.1.0 + remark-gfm@4.0.1 + remark-breaks@4.0.0 + rehype-sanitize@6.0.0, all as runtime dependencies. These match the kernel/hypeship dashboard stack exactly (react-markdown 10.1.0 / remark-gfm 4.0.1 / remark-breaks 4.0.0) — react-markdown v10 already peer-supports React 19, so it drops into this repo's React 19.1.0 with no override. The one deliberate addition over hypeship is rehype-sanitize@6.0.0: hypeship omits it and leans purely on v10 defaults, which is acceptable for trusted dashboard content but NOT for untrusted comment bodies rendering in a session-bearing origin (see security). DELIBERATE OMISSION: shiki/rehype-pretty-code — hypeship uses Shiki for <code> highlighting, but it ships megabytes of WASM grammars + dual themes to the client; for a 320px comment rail that is the wrong trade, so code blocks get a plain styled <pre> and Shiki is deferred behind a documented follow-up. Bundle cost of the chosen set is roughly react-markdown core + mdast/hast pipeline (~40-60KB gzipped) plus remark-gfm's table/strikethrough/autolink microsyntaxes (~10KB) — added to a route that is already a heavy 'use client' bundle, so the marginal cost is acceptable. Alternative considered: a hand-rolled regex/markdown-to-jsx micro-renderer (zero deps) — rejected because GFM tables + nested lists + fenced code (all present in the sample fixtures /home/agent/jh-design/sample-long.md) are exactly where naive renderers either mis-parse or re-introduce an injection hole; matching hypeship's audited pipeline is safer and is the explicit instruction.

steps

  1. 1Add the four runtime deps (npm, not bun)
    package.json at /home/agent/just-html/package.json pins exact deps and uses npm (package-lock.json present, packageManager npm@10.9.2 line 41; there is NO bun.lock, so do NOT use bun). Run: npm install react-markdown@10.1.0 remark-gfm@4.0.1 remark-breaks@4.0.0 rehype-sanitize@6.0.0 --save-exact. They belong in dependencies (not devDependencies) — they execute at render time. Confirm package-lock.json updates and commit it. There are currently NO markdown deps (verified: dependencies block lines 18-27 has only next/react/react-dom/pg/resend/zod/@pierre/diffs/@asteasolutions).
  2. 2Create the shared component lib/docs/comments/CommentMarkdown.tsx
    New file, marked "use client" (it ships react-markdown to the browser; CommentsShell is already a client component, line 1). It exports default function CommentMarkdown({ body }: { body: string }). Internals: import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkBreaks from 'remark-breaks'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'. Build a frozen sanitize schema once at module scope (not per-render) extending defaultSchema: keep a.target/a.rel on the allowed-attributes for 'a', and decide images (see security). Render: <div className="jh-md"><ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[[rehypeSanitize, schema]]} components={{ a: ... }}>{body}</ReactMarkdown></div>. The single component override is <a>: forced target="_blank" rel="noopener noreferrer" (mirrors hypeship's link override; the rel is also re-pinned in the sanitize schema so a malicious title can't strip it). Do NOT override <code>/<pre> with Shiki (deferred); the .jh-md CSS styles them. Memoize the component body on `body` (React.memo or useMemo on the element) since the rail re-renders frequently on optimistic reaction updates (CommentsShell mutates state on every chip toggle, lines 385-406) — markdown parsing on every keystroke-adjacent re-render is wasteful.
  3. 3Swap render site 1 — root comment body (CommentsShell.tsx ~line 903)
    Today line 902-905 is: <div style={{ color: "var(--jh-card-fg, #222)", marginTop: 2, fontFamily: "Georgia, serif", fontSize: 13, lineHeight: 1.45 }}>{t.body}{t.edited_at ? <span ...> (edited)</span> : null}</div>. Replace {t.body} with <CommentMarkdown body={t.body} />, and move the typographic intent (Georgia serif base, 13px, line-height 1.45, --jh-card-fg color) OUT of the inline style and INTO the .jh-md class so descendant elements (headings, code, blockquote) inherit and can be individually tuned — inline styles can't target descendants. Keep the (edited) marker as a sibling AFTER the markdown div (it's chrome, not body content), so it is not parsed as markdown. The wrapping flex layout (line 892 onward), avatar, author, badges are untouched.
  4. 4Swap render site 2 — reply body (CommentsShell.tsx ~line 930)
    Today line 930 is: <div style={{ color: "var(--jh-card-fg, #222)", fontFamily: "Georgia, serif", fontSize: 13, lineHeight: 1.45 }}>{r.body}</div>. Replace {r.body} with <CommentMarkdown body={r.body} /> inside the same div, or replace the div's className with jh-md and drop the inline font styling (same rationale as site 1). Replies are 1-level (the reply fixture /home/agent/jh-design/sample-reply.md is short with inline code), but use the identical component — no second code path. Import CommentMarkdown at the top of CommentsShell.tsx alongside the existing imports (line 3-4 area).
  5. 5Add the .jh-md scoped styles to the injected <style> block
    The file already injects component CSS via <style>{RAIL_CSS}</style> at line 481, with RAIL_CSS defined at line 1066. Add a sibling JH_MD_CSS constant (or append to RAIL_CSS) holding all .jh-md descendant rules, and render it once. This is the only way to express descendant selectors (h1-h3, ul/ol/li, code, pre, blockquote, table, a) — inline React styles cannot. See the styling field for the exact rule set. Because .jh-md lives in the same shell subtree that already sets data-theme and the --jh-card-* custom properties (line 480, 644-654), the rules just read those vars and work in both themes with zero JS.
  6. 6Verify no regression to the optimistic reaction / anchor postMessage flow
    CommentMarkdown is a pure presentational leaf inside <Card>. It does not touch threads/reactions state, the jh:* postMessage protocol (lines 173-298), the optimistic reactAnchored mutation (367-424), or the no-overlap card layout that measures offsetHeight/offsetTop after paint (RailCards effect, 775-795). Note one real interaction: markdown bodies are TALLER and variable-height vs single-line plain text, so the post-paint re-clamp loop (line 781) must run after markdown lays out. It already runs in a useEffect keyed on [threads, positions] AFTER render, so taller cards are measured correctly on the next layout pass — but call this out for the implementer to eyeball, since a code block or table changes card height materially.
  7. 7Add the unit + e2e tests
    See testing field. New file lib/docs/comments/CommentMarkdown.test.tsx (vitest). It needs a DOM or static-render path: the repo's vitest runs in the default node environment (vitest.config.ts only aliases @/*, no jsdom), and existing tests (lib/docs/theme.test.ts, schemas.test.ts) are pure-function. To avoid pulling in jsdom + @testing-library, render with react-dom/server renderToStaticMarkup(<CommentMarkdown body={...}/>) and assert on the returned HTML string. If a real DOM is preferred, add environment jsdom via a per-file // @vitest-environment jsdom docblock plus the jsdom devDep — but renderToStaticMarkup keeps the dep footprint at zero and still proves the sanitizer neutralizes script/javascript:.

security — the load-bearing decision

THIS IS THE LOAD-BEARING DECISION. Comment bodies are fully untrusted: anyone with comment access — any human grantee OR any agent via API key (resolveCommentPrincipal, lib/docs/comments.ts:63) — writes the raw markdown source, stored verbatim and untrimmed (createComment INSERT, comments.ts:391-405; the route stores the ORIGINAL body per schemas.ts:612-617). That body is rendered in the PARENT SHELL — the justhtml.sh origin that holds the session cookie — NOT inside the doc iframe. The doc iframe is sandbox="allow-scripts" with referrerPolicy no-referrer and an origin of 'null' (CommentsShell.tsx:508-509), so script there can't touch justhtml.sh cookies; but the comment rail is first-party DOM. Rendering untrusted markdown here is therefore a genuine stored-XSS / session-theft surface. TWO-LAYER DECISION: (1) PRIMARY — rely on react-markdown v10's safe-by-default contract: do NOT pass rehype-raw, do NOT set allowDangerousHtml. With raw HTML disabled, any literal '<script>alert(document.cookie)</script>' or '<img onerror=...>' a commenter types renders as INERT ESCAPED TEXT (the characters, not an element). v10's default urlTransform also strips dangerous URL protocols, so a '[click](javascript:alert(1))' link is neutralized to an empty/safe href. (2) DEFENSE-IN-DEPTH — ALSO add rehype-sanitize@6.0.0 with a frozen schema. hypeship omits the sanitizer and survives purely on (1), which is fine for its trusted dashboard content — but here the blast radius is account takeover, and the safety of (1) holds ONLY as long as nobody ever adds rehype-raw. rehype-sanitize makes that failure mode non-catastrophic: even if a future PR enables raw HTML, the sanitizer strips <script>, event-handler attributes, and disallowed tags at the hast layer. EXPLICIT ALLOW/STRIP MATRIX: links — ALLOWED, but every <a> is forced target="_blank" rel="noopener noreferrer" (component override) AND href is protocol-filtered (http/https/mailto only) by both urlTransform and the sanitize schema; javascript:/data: hrefs — STRIPPED. Raw/embedded HTML — NOT rendered (escaped to text). Event-handler attributes (onerror/onclick/...) — impossible (no raw HTML) and additionally stripped by the schema. Images — RECOMMENDATION: STRIP at launch (drop 'img' from the schema's allowed tags) — image markdown renders as nothing / its alt text. Rationale: remote <img src> in a session origin is a CSRF-pixel + IP/Referer-leak + layout-break vector at 320px and adds no real value to a code-review comment rail; allowing it is a one-line schema change later if a use case appears. GFM autolinks — allowed (same http/https filter). Net: links yes (with rel + protocol filter), images no, raw HTML no, javascript:/data: URLs no.

styling

Rendered markdown is styled by ONE scoped class, .jh-md, defined in a <style> block injected once (the file's established pattern: <style>{RAIL_CSS}</style> at CommentsShell.tsx:481, RAIL_CSS at line 1066). Inline React styles cannot express descendant selectors, so the per-element typography MUST live in CSS — this is the explicit reason for the <style> block over inline styles (or a CSS module, which would also work but the repo has no global CSS pipeline — no Tailwind, no PostCSS, no globals.css; confirmed — so the in-component <style> string is the idiomatic choice already used here). THEME-AWARENESS: .jh-md sits inside the shell subtree that sets data-theme="dark|light" and the --jh-card-* custom properties on the wrapper (CommentsShell.tsx:480 and paletteVars 626-681). Every rule reads those vars with the same var(--jh-x, <light-literal>) fallback pattern the rest of the file uses, so it themes for free in both light and adaptive-dark and needs zero JS: base text color: var(--jh-card-fg, #222); muted/borders: var(--jh-card-muted, #999) and var(--jh-card-line, #f1f1f1); code/pre surface: var(--jh-chip-bg, #f5f5f5) background with var(--jh-card-line) border. RULE SET (all selectors prefixed .jh-md): base — font-family Georgia, serif; font-size 13px; line-height 1.45; color var(--jh-card-fg) (moves the inline base today on lines 902/930 into the class). Tighten flow margins for a dense rail: .jh-md > *:first-child{margin-top:0} .jh-md > *:last-child{margin-bottom:0} .jh-md p{margin:.4em 0}. Headings CLAMPED so a stray # in a 320px card doesn't blow out: h1{font-size:1.25em} h2{font-size:1.12em} h3{font-size:1em}; all bold, margin .5em 0 .25em. Lists: ul/ol{padding-left:1.3em;margin:.35em 0} li{margin:.15em 0} — nested ul (present in sample-long.md item 4) inherits. Inline code: font-family ui-monospace,...,monospace (reuse the file's MONO, line 32); font-size .9em; padding 0 .3em; background var(--jh-chip-bg,#f5f5f5); border-radius 3px; overflow-wrap:anywhere + word-break:break-word so a long `identifier` WRAPS instead of widening the 320px card. Code blocks: pre{overflow-x:auto; max-width:100%; padding:8px 10px; margin:.5em 0; background var(--jh-chip-bg); border:1px solid var(--jh-card-line); border-radius:6px; font-size:12px; line-height:1.4} pre code{white-space:pre; word-break:normal} — horizontal scroll, never wraps a code line, never widens the card (the SQL fence in sample-long.md). Blockquote: border-left:3px solid var(--jh-card-line); margin:.5em 0; padding:.1em .7em; color var(--jh-card-muted) (the '> Net:' line in the fixture). Tables (GFM): display:block; overflow-x:auto; max-width:100%; border-collapse:collapse; font-size:12px; th/td{border:1px solid var(--jh-card-line); padding:3px 6px} — wrapping a block table in overflow-x:auto is what keeps a wide table scrollable inside 320px. Links: color inherits the card accent / underline; the target/rel come from the component override. img (if ever allowed) max-width:100%;height:auto. Add a CHROME_TRANSITION-consistent note: these are static colors via vars, so the existing chrome fade (line 1099) already applies when the theme refines.

edge cases

  • Incomplete / partial markdown: a commenter may post an unterminated ```fence, a lone **bold, or a half-written [link. react-markdown (CommonMark/GFM) is tolerant — it renders the best-effort tree and never throws, so a partial fence just renders the rest as code text. No guard needed, but the unit test should include a ragged-markdown case to lock this behavior.
  • Huge code blocks: the body cap is MAX_COMMENT_BODY_BYTES = 10*1024 (10KB, comments.ts:43), enforced as a UTF-8 byte check in the route (schemas.ts:547-549 notes the 413 stays route-side). So a single block is bounded at ~10KB — small enough to render synchronously. The .jh-md pre{overflow-x:auto} + max-width:100% keeps even a 10KB no-newline blob from widening the 320px rail; it scrolls horizontally instead.
  • Images: STRIP at launch (drop img from the sanitize schema allowed tags). Image markdown renders as nothing/alt text. Avoids remote pixels in the session origin (CSRF/IP-leak) and layout breakage at 320px. Re-allow later via a one-line schema change if a real need appears.
  • Link target/rel: every <a> forced target=_blank rel="noopener noreferrer" via the component override AND the rel pinned in the sanitize schema; href protocol-filtered to http/https/mailto. Matches hypeship's link override and the file's existing rel="noopener" convention (e.g. the Sign-in link, CommentsShell.tsx:586).
  • GFM tables at 320px: wrapped in overflow-x:auto (display:block on table) so a wide table scrolls horizontally inside the narrow rail rather than overflowing the card or forcing the whole rail wider.
  • 10KB cap interaction: unchanged — the cap is validated server-side on the raw markdown source (bytes), independent of rendering. Rendering never alters stored bytes; markdown is parsed from the stored source on every view.
  • Preserving case / exact source: the body is stored ORIGINAL and untrimmed (comments.ts createComment, schemas.ts:612-617). CommentMarkdown is render-only and never mutates or normalizes the stored value — editing still round-trips the exact source. Leading/trailing whitespace in the source is handled by markdown block parsing, not trimmed away from storage.
  • Optimistic reaction / anchor postMessage flow: untouched. CommentMarkdown is a presentational leaf; it does not read or write threads/reactions/positions state, does not participate in the jh:* protocol, and does not change the Card's data-no-pin click semantics (the markdown div is inside the pin-toggle area, same as the plain text it replaces). The only second-order effect is card HEIGHT: markdown is taller/variable, so the post-paint no-overlap re-clamp (RailCards effect, CommentsShell.tsx:775-795, keyed on [threads, positions]) must measure after markdown lays out — it already runs in a post-render effect, so this works, but verify a code-block card visually.
  • Empty-after-render bodies: body is a required non-empty string (refine, schemas.ts:613-617), but a body of only markdown punctuation could render to near-nothing. Acceptable — same as today's plain text; no special-casing.
  • Links that are bare URLs: GFM autolink turns them into anchors; they inherit the forced target/rel + protocol filter. No raw-URL XSS path because raw HTML is off.

testing

UNIT (vitest): new file lib/docs/comments/CommentMarkdown.test.tsx. The repo's vitest runs in the default NODE environment (vitest.config.ts only sets the @/* alias; existing tests theme.test.ts / schemas.test.ts are pure functions), so to test a React component WITHOUT adding jsdom, render to a string via react-dom/server's renderToStaticMarkup(<CommentMarkdown body={src} />) and assert on the HTML. Cases: (1) renders GFM — feed the sample-long.md fixture content (bold, inline code, ordered+nested list, fenced SQL, blockquote) and assert the output contains <strong>, <code>, <ul>/<ol>, <pre>, <blockquote> and the SQL text; (2) SECURITY — feed '<script>alert(document.cookie)</script>' and assert the output contains NO literal <script> element (it is escaped to &lt;script&gt; text) and no executable markup; (3) SECURITY — feed '[x](javascript:alert(1))' and assert the rendered <a> has NO javascript: href (href absent or sanitized); (4) link hardening — feed '[k](https://example.com)' and assert the <a> carries target="_blank" and rel containing noopener and noreferrer; (5) images — feed '![x](https://evil/p.png)' and assert NO <img> in output (stripped). Run with `npm test` (vitest run, package.json:15). If a live DOM is later wanted, add a `// @vitest-environment jsdom` docblock + jsdom devDep and switch to @testing-library/react render — but renderToStaticMarkup keeps deps at zero. E2E: scripts/e2e.ts is a black-box HTTP smoke test against a real BASE_URL using a throwaway AgentMail inbox (no app backdoor); it already exercises the comments API. Extend it minimally: after it creates a comment, POST a body containing markdown + a <script> payload, GET the comment back, and assert the API stores/returns the RAW source verbatim (rendering is client-side, so the wire body must be unchanged — this proves no server-side mangling). Visual rendering itself is best verified in the existing design/ demo harness or a Vercel preview deploy (auto-deploy/preview URLs per AGENTS.md), not the headless e2e. Run with `npm run e2e`.

risks

  • Bundle weight: react-markdown + remark-gfm + the mdast/hast pipeline add ~50-70KB gzipped to an already-heavy 'use client' route (CommentsShell). Acceptable for the value, but worth measuring with `next build` output; if it regresses, the renderer could be lazy-loaded (next/dynamic) since the rail is often collapsed at load (railOpen defaults false, CommentsShell.tsx:455).
  • Deferring Shiki means code blocks are unhighlighted monospace. The sample fixture has a SQL block, so reviewers will see plain code. If syntax highlighting is later wanted, add it as a <code>/<pre> override mirroring hypeship — but keep it lazy/async so it never blocks the rail render.
  • Schema-as-allowlist drift: rehype-sanitize's defaultSchema must be extended carefully (a/target, a/rel allowed; img dropped). A too-aggressive schema could strip GFM table/strikethrough nodes; a too-loose one re-opens risk. Freeze the schema at module scope and cover it with the unit tests above so changes are caught.
  • react-markdown v10 + React 19: v10 supports React 19, and the repo is on 19.1.0, so no peer override is expected — but confirm `npm install` resolves cleanly (no node_modules is checked in; CI/Vercel installs fresh).
  • Card height / layout: markdown bodies are taller and variable, which interacts with the no-overlap re-clamp measuring offsetHeight (CommentsShell.tsx:781-792). The clamp already runs post-paint so it should self-correct, but a tall code-block card could visibly reflow once on first layout — verify on a preview deploy.
  • Open question: should comment EDIT (PATCH body) get a live markdown preview in the composer? Out of scope for this change (composer textareas stay raw, CommentsShell.tsx:962/1000), but worth flagging as a follow-up since users now write markdown they can't preview before submit.
  • Open question: do we want a max rendered height with a 'show more' for very tall comments in the rail? Not blocking; the 10KB cap bounds worst case and pre/table scroll, but a 10KB list could still be a long card.

design options

five rendered treatments of the same sample review, each scoped to its own card. same content, different bets on legibility, density, and brand presence.

VARIANT A — familiar prose

github/linear-style markdown: inter 13px body, clear bold via color+tracking, real numbered/bulleted indentation, beige-muted inline code chips, one neutral grey slab for the fenced block, left-rule blockquote

this is the low-risk default — it just makes markdown render correctly and legibly with the typographic conventions everyone already knows from github and linear, so nothing surprises the reader. the trade-off is minimal brand presence: kernel-green appears only as the avatar dot and the reply's threading rail, and bold is faked with color + tighter tracking since the design system has no weight above 400, which makes emphasis quieter than a true semibold. the fenced code block scrolls horizontally inside the 320px card rather than wrapping, so sql stays readable without blowing out the rail. for dark mode, flip the tokens to charcoal card / grey-dark-12 text, swap the inline-code chip to a charcoal-tinted fill instead of beige-muted (which would glow on dark), and move the slab to charcoal with a grey-dark hairline; the kernel-green accent and the left-border blockquote carry over unchanged.

raf@kernel.sh Jun 22, 4:56 PM

Review — verified against kernel/kernel. Premise checks out: 30-day reaper on deleted_at, real jwt column, clickhouse-go reuse all confirmed. Four things before M1:

  1. Cursor can silently drop rows (blocker). deleted_at is stamped at statement time, not commit time — a row deleted at T that commits after a concurrent later-T row has advanced the cursor never gets exported, then gets reaped. Fix: don't advance to now(); export deleted_at < now()-δ and re-scan the trailing δ each cycle.
  2. M2 row-count parity will be flaky. ReplacingMergeTree dedupes on async merge, so a plain count() over-counts un-merged duplicates. Compare on the (deleted_at, id) key using FINAL / uniqExact:
    SELECT uniqExact(id) FROM sessions FINAL WHERE deleted_at < now() - 30
  3. Watermark key is inconsistent. Config uses Name="sessions" but the table examples store name="export:sessions" — they never match.
  4. Integration nits:
    • ent/schema/ is generated from schema.hcl — there's no hand-authored "Watermark ent model."
    • Keyset index is likely fine — but EXPLAIN to confirm a range scan with no filesort.

Net: M1 is close, but the cursor drop is a correctness blocker — fix before merge.

👍2 1
dana@kernel.sh Jun 22, 5:10 PM

good catch on the cursor — pushing a fix. the now()-δ re-scan is the move.

VARIANT B — plex-mono technical

IBM Plex Mono throughout the body in a terminal/diff aesthetic — numbered points become green index chips, inline code blends in, the fenced sql is a charcoal slab with syntax accents.

Mono body makes inline code and identifiers indistinguishable from prose in the best way: nothing has to shift fonts, so `deleted_at`, `ReplacingMergeTree`, and the (deleted_at, id) key read inline at full fidelity. The decimal-leading-zero green chips turn the four review points into a scannable checklist, and the charcoal sql slab with kernel-green keywords reads like a real terminal. This wins for dense, code-heavy technical reviews where most of the comment IS code or identifiers — it loses for long natural-language prose, where mono's even advance and lack of a real bold weight (we lean on 500 + color for strong) make paragraphs more tiring than serif/sans. Dark mode: flip the card to charcoal with grey-dark-12 text and swap the inline-code fill from beige-muted to a charcoal tint; the code slab already lives on charcoal so it needs only a subtle border to separate from the card, and the green/gold accents carry across unchanged.

r raf@kernel.sh Jun 22, 4:56 PM

Review — verified against kernel/kernel. Premise checks out: 30-day reaper on deleted_at, real jwt column, clickhouse-go reuse all confirmed. Four things before M1:

  1. Cursor can silently drop rows (blocker). deleted_at is stamped at statement time, not commit time — a row deleted at T that commits after a concurrent later-T row has advanced the cursor never gets exported, then gets reaped. Fix: don't advance to now(); export deleted_at < now()-δ and re-scan the trailing δ each cycle.
  2. M2 row-count parity will be flaky. ReplacingMergeTree dedupes on async merge, so a plain count() over-counts un-merged duplicates. Compare on the (deleted_at, id) key using FINAL / uniqExact:
    sql
    SELECT uniqExact(id) FROM sessions FINAL WHERE deleted_at < now() - 30
  3. Watermark key is inconsistent. Config uses Name="sessions" but the table examples store name="export:sessions" — they never match.
  4. Integration nits:
    • ent/schema/ is generated from schema.hcl — there's no hand-authored "Watermark ent model."
    • Keyset index is likely fine — but EXPLAIN to confirm a range scan with no filesort.

Net: M1 is close, but the cursor drop is a correctness blocker — fix before merge.

+1 2 eyes 1
d dana@kernel.sh Jun 22, 5:10 PM

good catch on the cursor — pushing a fix. the now()-δ re-scan is the move.

VARIANT C — compact rail-native

all-Inter at 12px with tight 1.4 leading; ordered points as hanging indents with kernel-green mono numerals in the gutter, condensed en-dash sub-bullets, height-capped internally-scrolling sql block — maximum structured review per vertical pixel.

This is the answer that treats the 320px rail as the design constraint it actually is, not a doc column squeezed narrow. Dropping the serif body for small Inter and pulling ordered numbers into a fixed 16px gutter buys real vertical economy — a four-point review plus a fenced query, blockquote, reactions, and a threaded reply all land above one screen without scrolling the card. It wins whenever density matters: agents posting long structured critiques, reviewers scanning many comments fast. The trade-off is warmth — at 12px the tight leading reads more like a terminal log than a written note, so it's the wrong call for short conversational threads where the serif-roomy variants feel more human. Since the design system has no bold weight, strong text is expressed as a color/weight step (300 body to 400 primary) plus kernel-green accents on gutter numerals and the blockquote rule, never green body text (contrast fails). Dark mode: every color is a CSS var, so the host flips --c-bg to charcoal and --c-fg to grey-dark-12 (both AAA); the sql block already lives on charcoal so it inverts to a beige-light inset, and the green numerals/gold keywords keep enough contrast on charcoal (AA, short glyphs only) without retuning.

r raf@kernel.sh Jun 22, 4:56 PM

Review — verified against kernel/kernel. Premise checks out: 30-day reaper on deleted_at, real jwt column, clickhouse-go reuse all confirmed. Four things before M1:

  1. Cursor can silently drop rows (blocker). deleted_at is stamped at statement time, not commit time — a row deleted at T that commits after a concurrent later-T row has advanced the cursor never gets exported, then gets reaped. Fix: don't advance to now(); export deleted_at < now()-δ and re-scan the trailing δ each cycle.
  2. M2 row-count parity will be flaky. ReplacingMergeTree dedupes on async merge, so a plain count() over-counts un-merged duplicates. Compare on the (deleted_at, id) key using FINAL / uniqExact:
    SELECT uniqExact(id) FROM sessions FINAL
    WHERE deleted_at < now() - 30
  3. Watermark key is inconsistent. Config uses Name="sessions" but the table examples store name="export:sessions" — they never match.
  4. Integration nits:
    • ent/schema/ is generated from schema.hcl — there's no hand-authored "Watermark ent model."
    • Keyset index is likely fine — but EXPLAIN to confirm a range scan with no filesort.
Net: M1 is close, but the cursor drop is a correctness blocker — fix before merge.
3 👀 2
d dana@kernel.sh Jun 22, 5:10 PM

good catch on the cursor — pushing a fix. the now()-δ re-scan is the move.

VARIANT D — structured blocks

each numbered review point becomes a separated block with a number badge in a left gutter and a subtle accent rail; the blocker gets a gold stripe; sub-bullets indent with connector lines and the sql block is a charcoal slab.

treats a technical review as structure, not prose, so a triager can scan the number gutter and spot the gold-striped blocker at a glance instead of reading a wall of text. trade-off: it is the densest, most chrome-heavy variant — the block borders, badges and connectors add visual furniture that can feel heavy for a short two-line comment, so it pays off most on long structured reviews and least on quick replies (note the reply card deliberately drops the block scaffolding). dark mode: the beige-light/beige block fills and grey-light dividers map to charcoal surfaces with grey-dark-12 dividers, the gold blocker stripe and green number badge already read on dark, and the sql slab is already charcoal so it needs only a lighter inner hairline.

r raf@kernel.sh Jun 22, 4:56 PM

Review — verified against kernel/kernel. Premise checks out: 30-day reaper on deleted_at, real jwt column, clickhouse-go reuse all confirmed. Four things before M1:

1

Cursor can silently drop rowsblocker

deleted_at is stamped at statement time, not commit time — a row deleted at T that commits after a concurrent later-T row has advanced the cursor never gets exported, then gets reaped. Fix: don’t advance to now(); export deleted_at < now()-δ and re-scan the trailing δ each cycle.

2

M2 row-count parity will be flaky.

ReplacingMergeTree dedupes on async merge, so a plain count() over-counts un-merged duplicates. Compare on the (deleted_at, id) key using FINAL / uniqExact:

sql
SELECT uniqExact(id) FROM sessions FINAL WHERE deleted_at < now() - 30
3

Watermark key is inconsistent.

Config uses Name="sessions" but the table examples store name="export:sessions" — they never match.

4

Integration nits:

  • ent/schema/ is generated from schema.hcl — there’s no hand-authored “Watermark ent model.”
  • Keyset index is likely fine — but EXPLAIN to confirm a range scan with no filesort.

Net: M1 is close, but the cursor drop is a correctness blocker — fix before merge.

2 👀 1
d dana@kernel.sh Jun 22, 5:10 PM

good catch on the cursor — pushing a fix. the now()-δ re-scan is the move.

👍 1
VARIANT E — kernel-branded

fully on-brand: beige-light card with a kernel-green accent rail, Inter body + IBM Plex Mono code, green numeral chips and bullet dots, gold reserved for the single blocker, charcoal SQL slab.

This is the reference treatment if comments fully adopt the Kernel design system: Inter at 13px/1.5 for the body (no serif), IBM Plex Mono for every identifier and the fenced block. Structure is carried by color and shape, not weight — since the system has no bold token, strong terms read darker (grey-12) and the ordered list uses green numeral chips instead of plain numerals, so the four review points scan instantly in a 320px rail. Gold is the one high-impact accent (the blocker's chip + outline flag), keeping green from over-saturating. Green only ever fills behind short glyphs (chips, dots, the SQL label, the active reaction) — never body text — so contrast stays AAA for prose and beige-on-charcoal in the code slab, with green+charcoal chips at AA for short text. Trade-off: it's the most visually distinct from the existing serif chrome, so it reads as a deliberate rebrand of the rail rather than a light retrofit; the green rail + chips also add brand color density that some may find loud next to a neutral document. Dark mode: flip the card to charcoal with grey-dark-12 text and beige-muted to a derived dark fill, keep the green rail and chips as-is (green+charcoal already inverts cleanly), and swap the code slab to a slightly lighter charcoal so it separates from the card; gold stays the blocker accent and still clears contrast on charcoal.

r raf@kernel.sh ·

Review — verified against kernel/kernel. Premise checks out: 30-day reaper on deleted_at, real jwt column, clickhouse-go reuse all confirmed. Four things before M1:

  1. Cursor can silently drop rows blocker. deleted_at is stamped at statement time, not commit time — a row deleted at T that commits after a concurrent later-T row has advanced the cursor never gets exported, then gets reaped. Fix: don’t advance to now(); export deleted_at < now()-δ and re-scan the trailing δ each cycle.
  2. M2 row-count parity will be flaky. ReplacingMergeTree dedupes on async merge, so a plain count() over-counts un-merged duplicates. Compare on the (deleted_at, id) key using FINAL / uniqExact:
    sql
    SELECT uniqExact(id) FROM sessions FINAL WHERE deleted_at < now() - 30
  3. Watermark key is inconsistent. Config uses Name="sessions" but the table examples store name="export:sessions" — they never match.
  4. Integration nits:
    • ent/schema/ is generated from schema.hcl — there’s no hand-authored “Watermark ent model.”
    • Keyset index is likely fine — but EXPLAIN to confirm a range scan with no filesort.

Net: M1 is close, but the cursor drop is a correctness blocker — fix before merge.

3 👀 2
d dana@kernel.sh ·

good catch on the cursor — pushing a fix. the now()-δ re-scan is the move.