Drip (queue-and-trickle inbox feeder) (part 2)
The second half of the Drip page: the internals behind a drip — its tables, services and CLI surface — and what the inbox does with a released item, from previews and thumbnails to right-click actions and editing.
What it is
This is part 2 of the Drip (queue-and-trickle inbox feeder) page. That page explains what a drip is, how to create and fill one, and how its cadence and repeat-forever modes behave. This half carries the rest: the internals a drip is built from — its five tables, the services that read and write them, and the CLI surface that drives the whole feature — and what happens on the inbox side once an item has been released, from the previews and thumbnails to the right-click actions and the in-place text editor.
Where to find it
Everything described here is reached the same way as on the main page: the Drips virtual project in the Omniscio sidebar for the queue itself, its management pane for the Pending / Released / Archived / All tabs, and the inbox viewer that opens when you click a released item. Nothing on this half has a settings screen of its own.
How it behaves
Sidebar list — status dots and the collapsible Archived section. Both the compact sidebar (DripSidebar) and the full-page Drips view (DripVirtualProject) group drips into Active / Paused / Archived. Each row's status dot comes from ONE shared helper, dripStatusDotClass (src/renderer/src/features/drip/drip-status-dot.ts): only an active drip gets the live amber dot; paused and archived are inactive and render the muted grey dot. Archived is deliberately NOT green — the green "success" dot read as "active/healthy" on a shelved drip. That mapping had been copy-pasted into three files (sidebar, full-page view, detail header), which is exactly how the bug spread, so all three now call the one helper — keep new call sites pointed there, never re-inline the mapping. The Archived section is a collapsible disclosure: collapsed by default so shelved drips don't crowd the working set, and it auto-opens while a search query is active so a matching archived drip is never hidden behind a closed header (Active and Paused stay always-open). That collapse state is the shared useArchivedDisclosure hook (src/renderer/src/features/drip/use-archived-disclosure.ts) so the two surfaces behave identically; the disclosure toggles are recorded in the raw-<button> shrink-only baseline as section-header affordances (the canonical Button would break the header look). Locked by drip-status-dot.test.ts, use-archived-disclosure.test.ts, and the DripSidebar / DripVirtualProject render tests.
Needs You — the actual waiting inbox cards, in the hub. The Drips hub shows a Needs You section at the very top — above Active — that renders the ACTUAL inbox card for every released drip item still waiting in your inbox (released, un-archived, un-snoozed), so you can see what your drips have delivered without opening the inbox at all. It renders on both hub surfaces, via the shared DripNeedsYouSection (which also owns the Edit pencil's CadenceModal, so the load-or-use-cached drip fetch is written once): the full-page DripVirtualProject on desktop, and — since it is the mobile landing surface — DripSidebar on a phone. That second surface is load-bearing, not a nicety: DashboardMobileLayout routes a hub that has a sidebarComponent and no mobilePrefersPanel to the SIDEBAR, so while the section lived only in the panel the phone's Drip hub badged a waiting item and then showed nothing but the drip SCHEDULES — the item it counted was unreachable from the hub that counted it. In the sidebar the section is a SEPARATE child (MobileNeedsYou) mounted only when isMobile, which is what keeps its store subscription and its loadInboxItems() pull off the desktop path entirely — an isMobile branch inside DripSidebar would still have subscribed the rail to inboxItems it renders nothing from (and did, briefly, crash the existing DripSidebar spec's partial store mock). It renders ABOVE the sidebar's load/error/empty tree, so a drips-list failure can't hide an item the hub already badged; the desktop rail stays card-free because the panel beside it already shows them. The card's Start session also advances the mobile nav to the detail panel, since a phone has no second pane for the inbox viewer to appear in. Each card renders the SAME content body as the inbox detail pane via the shared DripInboxItemBody — extracted verbatim from DripInboxViewer so the hub card and the inbox can never diverge — plus inline Snooze + Archive (the canonical InboxSnoozeButton / InboxArchiveButton; snooze keyed on the same payloadRef.id the inbox uses via buildSnoozeEntityDetail(dripInboxUnifiedItem(item)), archive via archiveInboxItem so it inherits the inbox's undo), an Edit pencil that opens the drip's CadenceModal, and a Start session button that OPENS the item in the inbox in one click (setActiveProject(INBOX_ID) + setActiveInboxDripItem) — the single-pane StartSessionButton + its bare-N hotkey can't be replicated across a card list (the start-session-button-universality guard), so the card routes to the inbox pane where it lives. The list comes from dripInboxLiveItems / useDripInboxLiveItems (drip-inbox-items.ts) — the SAME released-un-archived-un-snoozed filter the unified inbox projects (both now share the isDripItemLiveInInbox predicate) — and DripVirtualProject calls loadInboxItems() on mount so the cards appear even if the inbox was never visited this session; the section hides when nothing is waiting. Card component: DripInboxCard.
Needs You owns the archive key. The card list is one focusable container ([data-drip-needs-you-nav]): the first card is highlighted, the arrow keys move the highlight, and the user's resolved archive binding (E / Ctrl+W by default, so a remap is honored) archives the HIGHLIGHTED card through the very same archiveInboxItem store action its Archive button calls — inheriting the optimistic remove, the inbox-cursor advance and the Ctrl+Z undo from that one place. use-drip-needs-you-nav.ts holds the contract, mirroring the PR Merge Queue's use-pr-card-keyboard-nav. Why the LIST must own the key rather than the global handler: these cards are not inbox rows, so nothing on this surface ever sets the inbox-selection mutex that the global closePanel chain resolves its target from — a bare E therefore had nothing the user was looking at, fell through to the session default and silently did nothing (reported 2026-09-15, "I can't press E to archive this item"), leaving the card's button as the only way to archive it; and on a day when a left-over inbox selection WAS still set, the same key would instead have archived that unrelated item from here. The highlight is the point — a bare-key archive with no visible target is how the cross-context wrong-archive class recurs. The matching surface yield ([data-drip-needs-you-nav] → closePanel, in keyboard-shortcuts/surface-yields.ts) stops the global chain firing behind the list, the way the PR queue's does. The autofocus is ONE-SHOT and never steals: it takes focus when the cards first appear so the key works with no prior click, but never re-grabs on a later drip push and leaves an already-focused field alone — and it is inert on mobile, where the same section renders inside DripSidebar. Locked by use-drip-needs-you-nav.test.tsx and the drip-needs-you-card-nav-yield.test.ts marker↔yield guard.
Cadence label — one humanizer, never raw cron. Each row / header shows its schedule as plain English via ONE shared helper, dripCadenceLabel (src/renderer/src/features/drip/drip-cadence-label.ts): the user's saved cronNaturalLanguage when non-empty, else humanizeCronExpression(cronExpression) (which itself falls back to "Custom schedule", never a bare cron). Exactly like the status dot above, the cronNaturalLanguage?.trim() || cronExpression fallback had been copy-pasted into the same three files (sidebar, full-page row, detail header) — and a drip created from a preset chip or a typed raw cron leaves cronNaturalLanguage null (see drip-types.ts), so those surfaces rendered a bare 0 12 * * *. All three now call the one helper; never re-inline the fallback. Locked by drip-cadence-label.test.ts (the helper, incl. the exact bug repro) + the DripSidebar / DripVirtualProject cadence render regressions, and by cron-approval-display-contract.md (I6). The cadence EDITOR (CadenceModal) + its suggestion list (DripCadenceList) follow the same rule: a cron-only drip opens with the field EMPTY and a plain-English "Current: …" line (never seeding the raw cron into the input), keeps cronExpression byte-for-byte unless you retype the cadence, and translates a pasted cron live via humanizeCronExpression instead of the positional "minute · hour · day-of-month" caption — and the preset rows no longer print the raw cron beside each label. This "never a raw cron/RRULE on screen" rule is now enforced repo-wide by no-raw-cron-in-user-surfaces.test.ts, which fails the build on any raw cronExpression / rrule rendered to a user outside the humanizers (contract I10–I15, extending it to the self-heal panels, cron rows/pickers, and Alarm schedule too).
The cron / NL parser is shared with Snooze — parseSnoozeTime in src/shared/snooze-time-parser.ts is wrapped by parseDripNlCron in src/shared/drip-nl-cron-parser.ts so the user's natural-language input goes through the same deterministic parser everywhere.
Two of the five push channels are critical — DRIP_ITEM_RELEASED and DRIP_ARCHIVED are in CRITICAL_PUSH_CHANNELS in src/shared/push-event-schemas/index.ts, so their payloads are Zod-validated on the emit side via emitPush() and listener payloads are validated under the consumer-side flag. The three management-UI-only pushes (DRIP_RELEASE_SKIPPED, DRIP_ITEM_ADDED, DRIP_CHANGED) are not critical; DRIP_ITEM_ADDED is deliberately omitted from the mobile inbox push allowlist so a folder scanner discovering 200 new files at once doesn't wake every phone. DRIP_CHANGED is fired by the CRUD handlers in src/main/ipc/handlers-drip.ts on every create/update/pause/resume/delete so a second Omniscio window and the phone inbox refresh instantly — its renderer listener in src/renderer/src/hooks/useDripPushEvents.ts triggers the same debounced handlePushUpdate (reload-drips + reload-inbox-items) used by the scanner-side channels.
Start session from an inbox item. Each released item's inbox viewer offers a Start session button that launches a Claude Code session carrying the item as its opening prompt — a thin launch layer over the existing session-spawn machinery, no new spawn path. The opening prompt is composed by buildDripSessionPrompt in src/shared/drip-session-prompt.ts: a non-blank per-drip preset leads, then a blank line, then content-type-aware context (text body, link URL + title + description, file name + on-disk path, book prose, or a drip-name fallback), so the result is never empty. The per-drip preset and default repo persist as two nullable columns on drips (session_prompt, session_project_id; NULL = no saved default), joined onto each inbox row by listInboxItems and edited in the cadence form. The launch target resolves with a fallback: the saved repo is used only if it still exists and is spawnable, otherwise the session opens in the Claude repo (the __claude__ sentinel project). The repo picker is the canonical <Select> icon dropdown (src/renderer/src/components/ui/Select.tsx) — each repo renders its ProjectIcon (letter-glyph fallback for icon-less projects), which a native <select> can't do; its portal listbox (position: fixed + containToViewport) can't be clipped by the scrollable dialog body. The prompt textarea honors the user's global submitKeyMode (plain Enter submits in 'enter' mode via isPlainEnterSubmit; Ctrl/Cmd+Enter always submits). The launch source ('drip-start-session') must be registered in NON_MISSION_SESSION_SOURCES (src/shared/ipc-schemas/session.ts) or the IPC silently rejects the launch before a session is created. The agent-alert inbox carries an identical twin, AlertStartSessionDialog (source 'alert-start-session'), sharing the <Select> repo picker + the submit-key helper. Full invariants and the tests that lock them live in .claude/memory/contracts/drip-start-session-contract.md.
Live HTML preview. A drip file item whose name ends .html/.htm renders LIVE in the inbox viewer — its HTML, CSS, and JavaScript execute — instead of the file chip, gated by the dripHtmlPreviewEnabled setting (default on, under Settings → Features → Drip, beneath Enable Drip). srcdoc/blob/data can't be used: they inherit the renderer's script-src 'self' CSP and block inline JS, so the bytes are instead served under their OWN response CSP by ONE shared core, serveDripPreview (src/main/services/drip/serve-drip-preview.ts), feeding two transports that can never drift: desktop loads it through the drip-preview://item/<id> Electron scheme in a separate-process <webview> (a runaway script can't freeze Omniscio), mobile through the token-gated /drip-preview/<id> web-access route in a sandboxed <iframe> (allow-scripts, NEVER allow-same-origin → an opaque origin that can't touch the app). getDripPreviewUrl (src/renderer/src/lib/platform.ts) picks the transport; the component is src/renderer/src/features/inbox/InboxHtmlPreview.tsx; HTML detection is isHtmlDripFile (src/shared/drip-html.ts). The served CSP is contained — the page's inline JS/CSS run, but connect-src 'none' + no remote scheme-source means it reaches NO network, so an untrusted page can't phone home; files over 5 MB fall back to the chip; the read is path-scoped via readDripFile. Because it's contained, a page that pulls Tailwind/Chart.js/fonts from a CDN renders unstyled — relaxing the CSP to allow specific CDNs is a one-line change in the core. Pop it out. The inline frame is a fixed h-[70vh] box sized against the VIEWPORT rather than the room left under the pane chrome, so on a phone the pane header + the "Updated …" / "Generated by …" meta + the frame fill the screen, the outer scroll container has almost no range, and that meta never scrolls away — reported 2026-08-30 as "the date and stuff is pinned". So the preview carries a pop-out control that routes by platform: on desktop it opens the same contained frame in a full-window portal (focus-trapped + aria-modal, Escape to exit — the preview is data-readonly-preview, so without the trap a stray E would archive the item behind it); in any browser it opens the page as a real tab, which brings pinch-zoom, find-in-page, share, and survives switching apps. The browser branch calls window.open directly (openExternalUrl would relay window:open-external over the web-access WS and open the tab on the DESKTOP machine), builds its URL synchronously so the open stays inside the click gesture, and uses the plain cookie-authed ${origin}/drip-preview/<id> — no single-use ?pt= (the tab would 401 on its first reload) and no ?token= in the address bar. Because a top-level tab inherits no <iframe sandbox> attribute, the web route APPENDS sandbox allow-scripts to the served CSP so the page still gets an opaque origin there — a no-op for the existing iframe, and the reason the digest can never read the app's storage from a real tab. Full invariants + the tests that lock them: .claude/memory/contracts/drip-html-preview-contract.md.
Inline media (video / audio / PDF) & openable files. A released file item renders by KIND (dripFileRenderKind): a video or audio file plays inline (<video>/<audio> with a seek bar), a PDF renders in an inline frame, an image shows inline (as before), an .html file gets the live preview above, and anything else — plus any inline-render failure — becomes an openable card (DripFileChip) offering Open (OS default app) + Reveal in folder on desktop, or a download link on mobile. For safety, executables / scripts (.exe/.bat/.ps1/…) are NEVER opened — the card offers Reveal only, and the open IPC refuses them (isExecutableExtension). The KIND comes from the file's EFFECTIVE MIME (deriveEffectiveMime) — derived from the extension when the stored MIME is generic — so a folder-imported .mp4 stored as application/octet-stream still plays as video with no re-import and no DB migration. Bytes STREAM from disk with HTTP Range (seek without pulling the whole file) through one shared core, serveDripMedia, behind two transports — the desktop drip-media://item/<id> Electron scheme and the token-gated mobile /drip-media/<id> web-access route — exactly mirroring the Live-HTML-preview one-core-two-transports shape. The drip-media: scheme is privileged bypassCSP:false (default session only) and admitted by index.html's media-src / frame-src. Full invariants + the tests that lock them: .claude/memory/contracts/drip-media-contract.md.
Queue-row preview thumbnails. The Drips management pane's queue rows (the Pending / Released / All tabs) show a small fixed-size (40 px) preview thumbnail per item instead of a bare content-type icon: an image item shows the picture, a video its first frame with a play-triangle overlay, a link its og: preview image, and a PDF its first page. This reuses the inbox viewer's transports with nothing new on the backend — images via the DRIP_ITEM_FILE_READ data URL warmed through the drip-image prefetch cache, video first-frames via the drip-media:// stream, link images via linkImageUrl, and PDF page 1 rendered client-side by a lazily dynamic-imported pdfjs (render-pdf-thumbnail.ts, kept out of the main renderer bundle). Thumbnails are lazy — loaded only as a row nears the viewport via the shared useNearViewport hook, since the queue list isn't virtualized — and any load failure falls back to the content-type glyph, never a broken-image box. Heavy previews (video first-frame, PDF render) are desktop-only: on the mobile web embed a video/PDF row keeps its glyph, matching the inbox player's preload="none" OOM guard (images + links still preview on mobile). The component is DripItemThumbnail, wired into DripQueueRow; its fixed-size object-cover <img> is a deliberate plain <img> (the Avatar / LibraryCard fixed-size-thumbnail category, seeded in raw-img-use.test.ts), not the zoomable ImageCanvas viewer. Locked by DripItemThumbnail.test.tsx.
Right-click actions. A released drip's content is right-clickable like the rest of the app (desktop/Electron only — the image actions use an Electron-only IPC, and touch has no right-click). Right-click an image for Copy Image / Copy File Name / Save Image As (the app's shared ViewerImageContextMenu, reusing the already-loaded data-URL bytes — no re-fetch), a link (an inline markdown link or a link-type item) for Open Link / Copy Link Address, or text for Edit Text (opens the inline message-text editor — text items only, since the footer Edit button now opens the whole-drip editor instead), Copy Text (copies the item body without selecting first), and Select All. The gap this closed: the app's global right-click is a text menu that bails when nothing is selected, and image menus are wired per component — the drip image had none, so a drip gave nothing on right-click. The image case is wired in DripInlineImage (it owns the bytes, and stopPropagations); a single region handler on the inbox viewer's content area (DripInboxViewer) routes text/links to DripContentContextMenu on the shared MenuShell, and defers to the app's global text menu whenever you have a live selection so there's never a double menu. The sandboxed HTML preview (a separate document) is unchanged. Full invariants + the tests that lock them: .claude/memory/contracts/drip-content-context-menu-contract.md.
Edit a text item's body. A text item can be edited in place — this edits the item's words, distinct from the header pencil / name-link that edit the drip's schedule and settings. In the inbox viewer the text edit is reached by right-click → Edit Text on the message (the footer Edit button now opens the whole-drip editor, so the per-message text edit moved to the content menu — text items only); it swaps the rendered Markdown for an editable textarea with Save/Cancel (Ctrl+Enter saves, Escape cancels). The Drips management pane's queue rows carry a matching Edit button that opens the same editor in a dialog, so a still-pending item can be fixed before it fires. Both surfaces share one DripItemEditor and one optimistic editItemContent store action (patches the inbox list + detail + any cached panel row by the shared id, with rollback on failure). The write is a dual-write by the shared id: a released item is dual-backed by its drip_items row (the panel) AND its inbox_alert_items row (the unified inbox reads its text from THERE, not from drip_items), so the one chokepoint updateInboxDripItemText updates both atomically inside one transaction — the sibling of the archive chokepoint. A still-pending item has no alert row yet, so only the drip_items row updates and the eventual release copies the edited text into the new alert. Only text items are editable (a link edit would strand its og-metadata; file / book-pages aren't free-form text); editing an archived item works from the Archived tab and never un-archives it. IPC DRIP_ITEM_UPDATE; CLI PATCH /drip/items/:itemId (apply-immediate). Full invariants + the tests that lock them: .claude/memory/contracts/drip-edit-path-contract.md.
For agents
Data lives in five tables: drips (one per named queue — cron, status, counters), drip_items (one per item with the typed content discriminator, position in queue, release/archive timestamps, plus book_source_id / book_unit_start / book_unit_end for book-pages items), drip_folder_sources (watched folders), drip_folder_seen_files (scanner bookkeeping), and drip_book_sources (one row per PDF / EPUB attached to a drip — kind + total*units + on-disk path). Schema migration is v195 in src/main/db/database.ts; v220 adds the three book*_columns todrip*itemsand thedrip_book_sourcestable; v221 widens thedrip_items.content_typeCHECK from five values to six to admitdivider(alegacy_alter_tablerebuild that preserves every row, both partial indexes, and the v220book*_ columns). Soft-delete (is_deleted) on drips, drip_items, and drip_book_sources with cascade in JS, not SQL — the same pattern as projects / sessions.
Shared types live in src/shared/drip-types.ts — Drip, DripItem, DripFolderSource, DripBookSource, BookSourceKind, plus the five push payload types (DripItemReleasedPayload, DripReleaseSkippedPayload, DripArchivedPayload, DripItemAddedPayload, DripChangedPayload) and the input shapes for DRIP_CREATE / DRIP_UPDATE / DRIP_ITEM_ADD / DRIP_BOOK_SOURCE_CREATE.
The book extractor lives in src/main/services/drip/drip-book-extractor-service.ts. The unified create-time entry point is extractBookTotalUnits(filePath, kind), which sniffs magic bytes, dispatches to one of three strategies (countPdfPages for kind=pdf, extractEpubChapters for kind=epub-chapter, extractEpubWordChunks for kind=epub-word), and returns { ok: true, totalUnits } or { ok: false, reason }. sanitizeHtml (file-private) extracts body.innerHTML from the source XHTML before sanitising — DOMPurify 3.4.3 auto-detects full-document mode when input contains <html> and would otherwise leak <head><title> text into chapter body text. Do not change this without re-reading the recovery postmortem.
The integration is registered in src/shared/integration-registry.ts with kind: 'amc-builtin', virtualProjectPath: DRIP_PROJECT_ID (the __drip__ sentinel), featureFlag: 'dripEnabled', settingsSearchId: 'drip-enabled', and the full push / inbox-source / criticalPushChannels claims. The integration-completeness contract test (tests/unit/lint/integration-completeness.test.ts) fails CI if any claim drifts from its backing wiring.
CLI surface
Drip has a full CLI surface on 127.0.0.1:19519 for agent automation and scripted workflows (skills, cron, email handlers). All 24 routes are registered via registerDripRoutes() (from src/main/services/cli/cli-server-drip-routes.ts, route definitions in the src/main/services/cli/drip/ subfolder) wired from src/main/index.ts. The route family parallels the IPC handlers in handlers-drip.ts with one carve-out: DRIP_BULK_PARSE_PREVIEW (pure client-side helper) and DRIP_ITEM_LIST / DRIP_FOLDER_SOURCE_LIST (folded into GET /drip/:id) do not get standalone CLI routes.
Gating follows the same approval-vs-apply-immediate split as the rest of the CLI server. Drip-level CRUD (POST /drip, PATCH /drip/:id, DELETE /drip/:id) is approval-gated because each drip is a recurring side-effect schedule — same trust boundary as cron/automation. Filesystem sources (POST /drip/:id/folder-sources, rescan, folder-import-once, POST /drip/:id/book-sources, and the DELETE counterparts) are approval-gated because they read files at the path the caller specifies — same trust boundary as /project/:id/docs. Item-level queue management (POST /drip/:id/items, bulk, reorder, pause, resume, PATCH /drip/items/:itemId (edit a text item's body), DELETE /drip/items/:itemId, archive, unarchive, snooze) is apply-immediately because adding/removing/reordering/editing pending items is reversible and per-user queue noise rather than a recurring schedule change.
Idempotency: every approval-gated route accepts an optional X-Client-Request-Id header; same header on the same action_kind within the 30-day window returns the existing pending row with idempotent: true instead of inserting a duplicate.
File uploads: POST /drip/:id/items accepts base64 in the JSON body (fileBase64 + fileName together). This route carries a RAISED per-route body cap (DRIP_ITEM_ADD_MAX_BODY_SIZE ≈ 7 MB, derived from the 5 MB live-preview render budget so any file the drip can render is acceptable over the CLI) — every OTHER CLI route keeps the shared 1 MB cap; files beyond the render budget must use the in-app picker. The dispatch path goes through drip-file-storage.storeDripFile — the same service the IPC handler uses, so the userData/drip/<dripId>/ layout stays in one place. The per-route cap mechanism + its invariants: .claude/memory/contracts/drip-add-path-contract.md.
Read-style POST: POST /drip/cron-preview is read-budgeted (no state mutation) and returns data: { ok: true, nextRuns: [...] } for a valid cron or data: { ok: false, reason: '...' } for an invalid one — both at HTTP 200 — so CLI consumers can render live cron-validation inline the same way the in-app form does. This mirrors the IPC handler's shape exactly.
The dispatcher's 9 drip arms (drip.create, drip.update, drip.delete, drip.folder_source_add/remove/rescan, drip.folder_import_once, drip.book_source_add/remove) live in src/main/services/cli/cli-pending-dispatcher.ts. Each one re-validates the payload against its original Zod schema at approve time (so a codebase change between enqueue and approve fails fast as a permanent error), invokes the same query-layer functions the IPC handlers use, and emits the appropriate DRIP_CHANGED / DRIP_ITEM_ADDED push so open UI re-renders. Integration coverage in tests/integration/cli-server-drip-flow.test.ts exercises one canonical example per gating category.
Session provenance (AI-created drips) — recording it is REQUIRED, not best-effort. A drip is a recurring publisher into your inbox and its items are content you actually read, so neither can be created anonymously over the CLI. The three content-creating routes — POST /drip, POST /drip/:id/items, POST /drip/:id/items/bulk — refuse a caller that can name neither a session nor a validated cron, with a 400 naming the X-AMC-Source-Session-Id header, before anything is written. (Every Omniscio-spawned agent proves its own session through its token, so it passes without sending the header. The other drip routes — pause, resume, reorder, archive, snooze, delete, PATCH — create no content and are deliberately unguarded.)
Two columns record it, and they answer different questions. drips.source_session_id records who created the queue; drip_items.source_session_id records who filled it — and they legitimately differ when one session makes a drip that another adds to. The item column is stamped for every item kind (text, link, file, divider) and every row of a bulk add. Both are nullable, and NULL is the correct, common answer: a drip or item you made by hand, a folder-scanner or book-source item, a Tasks trickle, or a cron-driven add has no originating session.
You see it in two places, both detail-only: the drip's Edit cadence modal, and — new — the drip item's inbox detail, where a clickable "Generated by ‹session›" link sits beside the released time and jumps back to the session that put the card there. The inbox reads that link from the item's own column via a join, deliberately not by handing the session to createAlert (that input also drives which project an alert is filed under, and doing so would scatter drip items out of the Drips inbox). Full invariants: session-provenance-contract — I19 (the guard, the item column, the render, and the known idempotency gap), plus I2 (columns) and I6 (render surfaces).
Full route list and gating per family: .claude/memory/cli-server-gating.md.
Related
Drip is the overview and the user-facing half of this feature — what a drip is, how to fill one, and how its cadence behaves. The contract pages linked throughout this half hold the invariants and the tests that lock them.
Last verified 2026-09-28