dhaga.docs
Roadmap & ideas

Build checklist

Every feature, big and small, with its verified/unverified build status. Checkboxes drift — verify claims against code.

Exposes build status

This is the internal feature-by-feature build tracker. It reveals what's shipped, what's only code-complete, and where the known gaps are. Items are checked when the code is written, typechecks/lints/builds, passes its unit tests, and is merged — but checkboxes drift, so verify against code before relying on them.

Derived from BRD. Every feature, big and small.

What [x] means, restated 2026-09-06. An item is checked when the code is written, typechecks/lints/builds, passes whatever unit tests it has, and is merged to main. A manual browser or device pass is no longer a gate on the box — it is useful, but withholding the tick until someone clicks through turned dozens of shipped features into permanent [ ]s and made this document read as though half the product did not exist. Where a manual pass is genuinely still owed, the item says so in its own note and stays checked; where the behaviour is genuinely absent, the item stays unchecked. Partial work stays unchecked with a note.

Legend: (M#) = BRD MVP feature · (v1.x) = BRD roadmap phase


0. Foundation

  • Private source repository, LICENSE, README
  • CLAUDE.md project rules (stack, SOLID, security, file organization)
  • Next.js app scaffold (apps/web) — App Router, TS strict, Tailwind v4
  • Design tokens centralised (globals.css @theme: calm blue-white/midnight surfaces, trust blue, calm teal, magic violet, human coral, and amber action)
  • Light-mode palette contrast-audited to WCAG AA (fog/ember/seam darkened, --brand-line added for control borders, ember focus rings; dark unchanged)
  • Marketing-first landing page for relationship-driven professionals. The first viewport states the outcome, offers one Start free action, and links to a three-step capture → context → follow-up story. One static, cited Ask example follows that story. Five concise audience cards keep /use-cases/{sales,founders,investors,recruiters,community-builders} visible. The audience cards sit under the Ask proof in one section (#use-cases kept for the nav), the three-step story shows one static sample screen per step instead of icon tiles, the mock-instruction line is hidden on phones, "verify your email" is said once (Access), and only three sections (hero, how it works, ownership) carry an uppercase kicker; meaningful captions are at least 14px (fog clears 4.8:1 on the darkest light surface). Ownership details, FAQ, and access complete the page. Technical depth stays on /features, /product-tour, /docs, and /trust. The coached sample workspace moved to the desktop section of /product-tour, so / no longer loads the heavy interactive preview or advertises unreleased channels.
  • Compact visual feature explorer — /features is a tablist over the eight capabilities (constants/landing/capabilities.ts), each opening its own fixture-driven pane in FeatureStory/DeviceStage: six hand-built crops (CapturePane, CirclesPane, VoicePane, SearchPane, DraftPane, AlertsPane) plus the production sigma renderer for the graph and warm-path entries. Three ways in, because hover must never be the only one: pointing at a capability shows it (debounced 90ms), focus and arrow keys reach it from the keyboard, and a tap selects where there is no hover. It also auto-cycles every 5s so the page demonstrates itself — suspended while a pointer or focus is inside it, stopped for good on an explicit choice, never started under prefers-reduced-motion, and held while the section is off screen (IntersectionObserver), which also keeps the sigma pane from mounting for a visitor who never scrolls to it. A visible Pause/Play control is the WCAG 2.2.2 mechanism; it is not rendered under reduced motion, where there is nothing to stop. Below sm the tabs are a scrolling chip row that follows the rotation, so the selected chip is never parked off screen. For one day (2026-08-14) this was a single embedded AppWindow with the eight capabilities demoted to copy cards beside it. That made /features a second copy of the homepage and pulled Three.js, MapLibre, FullCalendar, and sigma onto the route. On 2026-08-30, the app mock moved from / to the optional desktop section of /product-tour; the homepage now uses one static Ask proof. /features stays short and links to that long-form tour, which preserves the detailed workflow, cited Ask examples, click-loaded graph sandbox, comparison, and FAQ. Retired landing code was audited by production-route reachability: all unreachable modules in the landing scope plus orphaned constants/types were removed, and ogl and @xyflow/react left the dependency graph.
  • npm workspaces (apps/*, packages/*) so packages/core is shareable
  • apps/web/.env.example documenting every env var
  • CI (typecheck + lint + tests + build on push)
  • CI runs in parallel with a 4-way sharded web suite (2026-09-03, .github/workflows/ci.yml) — ~29 minutes → ~10. apps/web's vitest suite was ~26 of the 29 (598 files, 3,436 tests, import 581s + tests 890s); everything else — four typecheck passes, @dhaga/core / @dhaga/mobile / @dhaga/ee tests, the extension build, lint — came to about three minutes and sat queued behind it, so a lint error took 29 minutes to report. Four jobs now run at once: web-tests (a --shard=i/4 matrix with fail-fast: false, so one red shard does not hide the others), web-build (lint + next build, the pair that needs the 6GB heap #313 gave it after main had been red since 2026-08-22), packages, and verify-without-ee unchanged. A concurrency group cancels a superseded run instead of paying for both. Two traps the split had to avoid, both silent. Branch protection requires a check literally named verify, so verify is kept as a gate job rather than deleted — a required check that no longer reports does not block anything and GitHub does not warn; PRs would simply become mergeable with nothing enforcing them. And that gate runs if: always(), because the obvious form (needs: alone) makes it skip when a dependency fails, and a skipped required check is not a failed one. Shards are split by file and are not balanced by duration, so past a point the slowest file sets the floor — raising 4 is not free
  • Dependabot's 57-update batch triaged: 20 taken, eight breaking majors held (2026-09-02, PR #327 replacing the closed #321) — the all-in-one batch failed all four web shards, web lint + build, packages and verify-without-ee, so it was closed rather than merged. Each held major is held for a reason found by building against it, not by reading a changelog: better-auth 1.7.x extracted the MCP plugin into @better-auth/mcp, so mcp and oAuthDiscoveryMetadata no longer exist on better-auth/plugins — two call sites plus lockstep moves of the root @better-auth/core and better-call overrides, which is an import migration through the auth config and not a version number. @electric-sql/pglite 0.5.8 stops hoisting to the root and nests under apps/web and packages/ee, leaving the hoisted drizzle-orm unable to resolve it, so it is held at 0.5.4. The rest are deliberate one-PR-each migrations — @tanstack/react-table 8→9, @fullcalendar/* 6→7, motion 12→13, react-markdown 9→10, openai 6→7, stripe 19→22 (three majors in one step) — plus all of apps/mobile, where the Expo patch bumps drag the whole React Native stack along with them. These are deferred by name, not forgotten — the next person picking one up should read #327's body before starting. Neither remaining open advisory (image-size, decode-uri-component) is touched by any of it; both are documented as unfixable in the dependency-advisories note, and the one alert that did have a safe fix — qs, reachable from live HTTP traffic through express and stripe — was pinned to ^6.16.0 on 2026-09-03
  • Test suite (vitest, in-memory PGlite): heuristic parser, export formats, receipts cascade
  • Fix Dependabot alert (postcss <8.5.10 via next — npm override to ^8.5.16) (needed a version-keyed postcss override to actually dedupe next's nested pin — PR #55)
  • Deploy to Vercel (landing + app)

1. Shared core — packages/core

  • Zod contact schema (name, title, company, emails[], phones[], links, location)
  • Zod extraction schema (facts, relationships, follow_ups, tags) per BRD §6.3
  • LLMClient interface (Dependency-Inversion contract)
  • AnthropicLLMClient — structured outputs via Zod-derived JSON schema
  • Prompt builders (pure functions): contact parse, note extraction
  • Prompt builders: search answer, follow-up draft
  • Prompt builder: search query understanding (structured filters stage)
  • getLLMClient() factory (env-driven; Ollama/BYO-key = future implementations)
  • Heuristic (no-LLM) contact parser fallback — email/phone/URL regex + name lines
  • Shared API types (request/response contracts used by web now, mobile later) — packages/core/src/api/{capture,contacts,export,card-image,access-requests,jobs}.ts; each route satisfies its contract (2026-07-07); auth/[...all], stripe/webhook, and telegram intentionally left untyped (better-auth's own catch-all, a Stripe-shaped webhook receiver, and an internal bot webhook — none are a shared web/mobile contract surface), not pushed

2. Web app shell (v1.1 surface, built first)

  • Contacts & sync journey clarity — /app/import is the canonical hub for file import and Google/Outlook account management; LinkedIn accepts its emailed ZIP directly with local, filtered extraction and persistent recovery states; Settings keeps one Capture signpost and redirects old #import bookmarks to the hub; focused parser, state, navigation, and journey tests cover the new paths.
  • Table search-and-filter bar (built 2026-09-04, rebuilt 2026-09-05) — the bar above every DataTable is a slim row rather than a tinted panel: a live search box over the table's free-text column, one Filter dropdown for the picked ones, and the applied values as removable chips on the line below (components/app/table/FilterBar/). Searching by name no longer goes through a popup at all — it was Filter → Name → operator → type → click away, four steps and two popups deep for the most ordinary action on a list of people, and it is now a visible box that filters as you type (TABLE_SEARCH_DEBOUNCE_MS, with Enter and Escape flushing immediately; no submit button, no Enter required). The search column is derived, not configured — searchFieldOf promotes the first TYPED column — so the import review, whose fields are all picked and which brings its own search box, correctly derives none. The box also suggests filters as you type: "Acme" comes back as COMPANY Acme and TAGS Acme under the input, alongside a footer row naming the name-search that is already running, so the fast path does not require knowing that "Company" is a field or where the button listing it lives. Accepting one is ONE navigation that moves TWO columns — it sets the picked column and clears the text that was filtering names, because leaving both would AND them and return nobody (suggestionPatch, commitFilters). Suggestions come from /api/table-filter-options, which gained a columns= form answering several columns per request: the names are INTERSECTED with the same whitelist the single-column form uses (an unknown one is dropped, not fatal), and the searches run sequentially because each opens its own getDb() checkout against a pool that caps at three. Typing never waits for the dropdown — the name filter applies on its own, so ignoring the list entirely still leaves a working search. Three reactivity bugs are fixed, each of which read to the user as "the filter didn't work, I have to do it twice". (1) Every committed filter remounted the whole table — each call site keyed <DataTable> on JSON.stringify(filters) — destroying any open popup and emptying the search box mid-word on each round trip; DataTable now re-seeds applied from initialFilters during render and no call site passes a key. (2) A ticked value wrote NOTHING until the picker closed, so "pick a value" and "see different rows" were separate gestures and the click that finally committed it was swallowed by the dismissal instead of landing where it was aimed; ticks now commit through a TABLE_FILTER_COMMIT_COALESCE_MS window (use-coalesced-commit.ts), so one tick applies on its own and a burst still leaves as ONE RSC round trip. (3) The field and value pickers were two Combobox roots, the second opening from inside the first's press handler — a popup mounting while another was still dismissing, so the trailing click could land outside the new one; they are now two steps of ONE root, with an "All filters" back item (a real ComboboxItem, because Base UI keeps focus in the input and roves over items, so a plain button beside the list is unreachable by keyboard). The operator step is gone. is / is one of never reached storage — DataTable hardcoded is-one-of on commit and commitDraft only ever read draft.values — and it said nothing the chips were not already saying. Single-valued columns are declared instead: singleValueFilters on the four admin tables whose packages/ee queries (listUsersPage, listSubscriptionsPage, listAiCreditGrantsPage, listAccessRequestsPage) narrow on one string, which also closes the older gap where an admin could add a second chip the query silently ignored. Four more defects surfaced during browser verification and are fixed: the search box re-applied a word 350ms after it was cleared, when the stale debounced value landed (the commit effect now requires debounced === text as well as a changed value); the Filter count badge was invisible in dark mode, where --brand-trust-ink is defined as --brand-trust itself — it is a tint plus the ink now, the way the table header already pairs them; accepting a suggestion left the chosen company sitting in the search box, because Base UI writes a selected item's LABEL back into a typeable combobox's input (the handler now takes input-change only, which also covers its empty close-sync write); and the clear ✕ was focusable while carrying the aria-hidden Base UI puts on everything outside an open typeahead listbox — an accessibility fault with no library opt-out, so it leaves the tab order for exactly that window and Escape covers the keyboard. The Filter trigger also gained an aria-label: Base UI gives it role="combobox", whose accessible name does not come from its contents, so the visible word "Filter" and its badge were unannounced. Everything the previous pass established is unchanged: values are a readonly string[] end to end (URL, engine, SQL), a column ORs its values and columns AND each other, options are searched in the database rather than drawn from a capped list, and the URL codec percent-encodes each value before the comma join so "Acme, Inc." survives a bookmark. Verified in a browser this time — against the local PGlite database, at 1280px and 375px in both themes: typing suggests filters and taking one drops the name search in a single navigation, one tick applies with the popup still open, the chip and the badge appear together, the search keeps focus and its value across the round trip, removing a chip is immediate, and neither theme overflows at 375px. tsc --noEmit clean across apps/web; lint clean; 44 unit tests green across the table and import surfaces (FilterBar/state.test.ts, suggestions.test.ts, menu-items.test.ts, table-filter-searchparams.test.ts). Not done, deliberately: the suggestions are values, not a query language — there is no company:acme syntax and no is not, because nothing in the repo or the client engine can negate; admin tables still take one value per column until EE widens to IN; and the suggestion list is capped at TABLE_FILTER_SUGGESTION_LIMIT per column, so it narrows what you have typed rather than browsing a column (the Filter button remains the way to browse one).
  • Auth: real accounts (better-auth email/password), signed httpOnly session cookie — typecheck/lint/build/test all pass; manual browser click-through + push still pending
  • Sign in with a phone number + SMS code — genuinely unbuilt (recorded 2026-08-07). Better-auth's phoneNumber plugin is registered (lib/auth/config/plugins.ts) and its sendOTP is wired to Twilio (lib/sms/send.ts), so a code could be delivered — but nothing in the app ever calls signIn.phoneNumber: LoginForm offers email + password and a magic link, with passkey and social alongside. Verifying a number in Settings → Security therefore unlocks nothing, so that section is greyed out with a "Coming soon" reason gated at runtime on smsEnabled(), and the copy names whichever half is missing — PHONE_SIGN_IN_COMING_SOON when SMS is also unconfigured (the deploy's state: TWILIO_ACCOUNT_SID is unset), PHONE_SIGN_IN_UNBUILT_COMING_SOON when Twilio is set and only the sign-in path is absent. Ticking this needs the sign-in entry point, not a Twilio key
  • Every /app page + server action validates the session (guard helpers)
  • Responsive app navigation — rebuilt 2026-08-13 around one complete route model. Desktop (lg) gets a permanent 256px rail with the existing ThreadMark + dhaga wordmark, search, Capture, Workspace, Explore, import/settings and account utilities. Phone gets a safe-area-aware bottom bar (Today / People / Capture / Map / You) with 44px+ targets; You opens a bottom sheet containing every secondary route, resource, preference and account action. Tablet keeps the compact top bar and opens the same directory from a right sheet. One SearchPalette and one NavQuickAddDialog instance are repositioned rather than duplicated, so shortcuts/listeners cannot fire twice; main content and the toaster reserve the phone bar/safe-area footprint.
  • In-app feedback box (2026-08-03) — an icon button in the app nav beside the notification bell, so it is on every /app route (the responsive capture action already owns the phone bar's centre slot) and adds no extra fixed layer over the bottom navigation or bottom-right toaster at 375px. One free-text field and Send: no categories, tags, screenshots or triage states — reading a handful of reports is cheaper than making every user classify their own. POST /api/feedback (requireUserIdFromRequest → new feedback rate-limit bucket, 5 per 5 min → zod → withUserDb insert → email) writes the core feedback table (lib/db/ddl/core/feedback.ts, mirrored in db/schema/feedback.ts, added to EE's TENANT_TABLES), then notifies DHAGA_OWNER_EMAIL through the existing Resend layer best-effort: the tenant scope is closed before the send and a throw is swallowed and logged shape-only, so a Resend outage (or an unconfigured self-host) can never cost the user the words they wrote. The captured context is an allow-list, enforced three times over — a named column per field with no jsonb blob, a zod schema that strips unknown keys, and per-field patterns narrow enough that prose cannot ride in a "harmless" field: route, viewport, user agent, locale, timezone, build id, plus the RLS user_id. The route is the pattern, not the path (routePattern() substitutes Next's resolved params by value, so /app/people/<id> is stored as /app/people/[id] and a new dynamic segment is redacted the day it ships), and the server strips any query string or fragment because ?q= is exactly the forbidden payload. Nothing else is collected — no contact names or ids, note text, search terms, DOM snapshot, clipboard, referrer, IP or analytics id — and the whole attachment is spelled out under the textarea before the user sends, since silent collection is the thing the privacy stance forbids. Admin view at /app/admin/feedback: server-paged (listFeedbackPage, limit/offset + a separate count(*), sequential awaits on the one bypass-RLS admin connection) through the shared TanStack-backed DataTable in server mode — no new useReactTable, per docs/LIBRARIES.md §2, and FeedbackTable is an export of the already-listed AdminTables.tsx so the verify-without-ee removal list needs no additions. Tests: lib/__tests__/feedback-capture.test.ts (allow-list, route redaction, disclosure), feedback-submit.test.ts (row survives an email throw, forbidden fields never reach the row, 401, 429), packages/ee/src/admin/__tests__/feedback-page.test.ts (offset arithmetic, ordering, unfiltered total, row mapping, connection release). tsc/lint/vitest green; DDL replay verified twice against a throwaway PGlite. Pending: manual browser click-through
  • Empty states + error states; submit buttons have loading spinners
  • Loading skeletons on data-heavy screens (route loading.tsx files)
  • Branded public/app 404 and route loading states — cloth-shop illustration, recovery actions, theme-safe tokens, and reduced-motion-safe animation (2026-08-06; browser/screenshots pending)
  • Signed-out surfaces rebuilt on one shell (2026-08-19) — /login, /signup, /pending, /forgot-password and /reset-password now render through components/app/auth/AuthShell: an ambient amber thread constellation behind an editorial left column (mono eyebrow, display headline, three lines of Capture/Connect/Recall) and the form plate on the right, collapsing to one column at 375px with the proof rows dropped so the form stays first under the headline. The motif is inline SVG with no JS — every thread carries pathLength="1", so one stroke-dashoffset keyframe draws curves of different real lengths at the same rate, and three carry a travelling signal pulse; the landing page's real sigma.js graph (FeatureStory/GraphStage) was deliberately NOT reused, since a dynamically imported WebGL canvas is the wrong cost on the first paint of a sign-in screen. A scrim of --brand-ink floats back over the weave so the headline never sits on a wire — one declaration, correct in both themes. Colour is tokens only (text-ember/text-trust-ink/text-calm-ink for accent text, amber strictly as fill), measured at the glow's peak in both modes: every text token clears 4.5:1 and --brand-line clears 3:1 on the card. prefers-reduced-motion drops the draw, the pulses and the card entrance. /pending is now an invitation: a three-node thread timeline (PENDING_STEPS) states what happens next above the unchanged Skip-the-queue picker. Behaviour is untouched — auth wiring, form actions, validation, redirects and the approval-queue flow are byte-identical; the forms' existing role="alert" errors gain only a CSS reveal, reached from auth-motion.css via the shell's data-auth-card hook. Verified in a real browser at 375px and 1280px, light and dark, plus reduced motion.
  • General Tasks at /app/tasks (superseded 2026-08-13 — now the List view of /app/plan) — active/completed views, optional due date, optional person/company associations, and daily/weekly/monthly/yearly calendar recurrence without placeholder contacts (2026-08-06; browser/screenshots pending). Searchable + paginated 2026-08-06: the board is driven by @tanstack/react-table row models over a single pseudo-column — the card rows host an inline edit form a <table> cell cannot, so this follows the ActivityCard precedent rather than converting to DataTable. Search matches action text and the attached person/company names (debounced via the shared useDebouncedValue); the All/General/People/Companies and Active/Completed chips compose with it, and any change to search or filter resets to page 1 so a narrowed list cannot strand the user on an empty page. The footer was extracted from DataTable into a shared TablePagination (components/app/table/DataTable/Pagination.tsx) so the five existing DataTable call sites and the tasks board render identical controls from one copy. Predicates are pure and unit-tested (components/app/tasks/filters.test.ts). Still unbounded server-side — listTasks() loads every open and done row per page view and done tasks are never pruned; client pagination fixes render cost, not payload. Keyset pagination in the repo layer is the follow-up Sort, a person filter, and a visible added-date (2026-08-12): every row now carries Added <date> (formatDate, never formatDueDate — createdAt is a real timestamp in the reader's zone, while a due date is a semantic day pinned to UTC), a sort select offers Due date / Date added (newest) / Date added (oldest), and a person select narrows the board to one contact. Both run client-side over the already-loaded set through the SAME path as the existing chips — filter, then sort, then paginate — and reset to page 1 like every other control; listTasks()'s orderBy is untouched, and the due default reproduces it exactly (dated ascending, unscheduled last, oldest-created breaking a tie) so the board cannot silently re-order itself between the server render and hydration. Every sort tie-breaks on the id, because equal timestamps permuting between renders reads as rows shuffling under the cursor. The person options derive from the LOADED tasks via distinctOptions, hoisted out of the calendar's filter-follow-ups.ts into the shared lib/filters/follow-up-scope.ts so both boards build that dropdown one way; the control is hidden entirely when no task names a person, and it deliberately offers no "general" entry — the General scope chip already means that. Covered by components/app/tasks/filters.test.ts. No browser pass — the filter row at 375px is reasoned about, not screenshotted
  • Calendar-aware follow-ups — recurring rows advance on completion; note phrases resolve deterministically into calendar dates; ambiguous “next weekend” schedules Saturday and offers Sunday in Confirmations (2026-08-06; browser/live calendar verification pending)
  • Keep-in-touch scheduling — cadence-specific weekday/day/month selectors, persisted capacity-aware Auto assignment, user-timezone due dates, and a non-blocking People/day overload warning (2026-08-06; browser/screenshots pending)
  • Soothing midnight theme reused from landing tokens, with semantic colour cues shared by the real dashboard and its landing preview
  • Application-wide light-mode hierarchy, provider identity, and affordance pass (2026-09-01) — refreshed signed-in surfaces and accent inks, strong table states, 44px phone controls, icon-led empty states, and semantic confirmation/goal/sync choices on main; the updated public landing page is preserved. Official marks and structured provenance now cover auth, imports, calendars and contacts, meeting joins, Today moments, notes, messaging, connector enrichment/notifications, and sync conflicts with labelled Lucide fallbacks for unknown providers. Typecheck and lint pass; the full suite reached 3,419/3,420 with its unchanged timing-bound test passing alone, and the hosted Vercel production build passes. Post-change localhost screenshots remain environment-blocked. Full matrix: docs/UI_AUDIT_2026-09-01.md.
  • Home typographic hierarchy pass (2026-10-06, critique rec 8) — captions that carry meaning on Home (row details, tile subtitles, hints, chips, links) moved from 10-13px grey to 14px (text-sm); 10-11px mono kickers are now 12px; primary names gained font-medium so rank comes from size/weight, not more text-fog. The page h1 is 32px at sm (24px on phones) and is now the largest text on Home. Visible product change: the first-run headline ("Capture your first person") and the empty-state headline got SMALLER, from 30-36px to 20px on phones and 26px at sm, so no h2 outranks the h1; home-type-hierarchy.test.ts pins this at both phone and sm sizes. The time column of moment rows widened (5rem phone, 4.75rem sm) so a 14px 10:30 AM or All day no longer wraps. Home sits inside .home-lifecycle-theme, so fog there is #5c646d light / #a7aab2 dark: light 5.91:1 on panel #fffdfa, 5.16:1 on ink #f3ede4, 4.52:1 on well #e8ded2; dark 7.77:1 on panel, 6.94:1 on panel-2, 7.98:1 on ink. All clear 4.5:1, and 14px does not change that threshold. Two globe messages stay at 12px by design (inside a 132px circle on phones). Not done: landing kickers (Hero, Journey, CaptureEverywhere) and rail captions; nothing was viewed in a browser.
  • Home lifecycle + daily briefing — rebuilt 2026-08-13 as three factual stages: first when peopleCount === 0, started after the first person but before every offered setup fact, and integrated only when person, WhatsApp/Telegram capture, calendar, preferred calling hours, reminder review, and map exploration are all complete. The messaging step is the one conditional row: it is dropped from the list (and from the counts, so the card reads "of 5" rather than a permanent "5 of 6") when the instance has no WhatsApp/Telegram provider configured, or the plan cannot link a chat and none is linked — a step nobody can ever tick would pin an otherwise finished account below integrated forever. First-day Home leads with add/import actions and a Dhaga-owned thread illustration; later stages lead with Moments for today, a contact-initials detail sheet, Reached out, and calendar-backed Find a time. The setup card persists calling/reminder/network choices in the existing settings table (no DDL); person/calendar completion remains derived from real data. User-facing setup/calling preferences are exported through an explicit allowlist; internal settings stay excluded. Settings caught up 2026-08-14: the same fact now hides the Messaging tab (components/app/settings/tab-visibility.ts, sharing lib/messaging/availability.ts with the checklist rather than re-deriving it). Both surfaces disagreeing is what a user hit — WhatsApp visible in Settings, absent from the checklist, with nothing saying why. A user who linked a chat while a channel was configured keeps the tab regardless, so a config change can never strand someone with a linked chat they cannot disconnect; and #messaging deep links are filtered by the same gate, since a hash selecting an unrendered tab hides every panel and reads as "nothing is selected". Where a channel is configured but one provider has no credentials, that row still reads Not configured — now a link to the self-hosting page's Set up a channel section, because naming a state without naming the missing piece or who owns it is a dead end for the self-hoster, who is usually two env vars away. The messaging tour steps are filtered at source, not left to driver.js's skipMissingElement: TOUR_PRESENCE_WAIT_FRAMES is paid up front waiting for every anchor to exist, so hiding the tab alone would have stalled the tour ~7s on every install with no bot.
  • Interactive Home network globe — production Three.js renderer over cached city summaries only: drag/pinch/wheel and keyboard rotation, click/Enter to /app/map, an Earth-fixed solar day/night terminator computed from the real current UTC — noon over India lights India, and because daylight is resolved against an object-space normal the terminator cannot drift under drag, keyboard or spin — city markers, and a Calling windows mode based on each city's DST-aware IANA zone, which now deepens the night side rather than switching the shadow on (Places used to render a flat, fully-lit Earth). Rendering Home never schedules geocoding and never sends contact data to a globe service. Two local 2048×1024 NASA-derived Earth textures (day and night) plus a 1024×512 combined topography/bathymetry elevation map total 285,707 bytes — the elevation map replaced a 2×1 neutral placeholder that contributed no terrain, and is built at asset-build time by averaging two separately-published NASA/GEBCO sources because neither carries both land and sea. The Earth spins west→east at one turn per two minutes, with a ±80° pitch clamp and a reset control that appears only after deliberate input; DPR/geometry/antialiasing reduce on narrow/≤4-core/≤4 GB devices, rendering pauses off-screen/hidden, reduced-motion stops the spin, and WebGL failure remains a keyboard/click route to the real map. The real map now exposes point, quick-chip, and all-city controls that deep-link a city at zoom 11. three is MIT, tz-lookup is CC0, and exact NASA/GEBCO/illustration provenance lives beside the assets; browser QA is tracked in the Testing guide.
    • Globe labels now show the busiest six cities, add local time beside Good window / Check first, and deep-link a clicked city into the selected city-level map view. The signed-in Home and synthetic landing preview share this implementation.
    • Map resilience (client): every failure state has a Retry (first-load error, map-start failure, stale refresh, crash boundary + app/map/error.tsx); poll failures keep the stale map with a non-blocking "Couldn't refresh" notice and a 429 backs off quietly on Retry-After; post-load tile/glyph failures raise a "Map tiles are having trouble" banner; a dead OpenFreeMap style falls back to OSM raster tiles; polling backs off after 3 attempts, stops when only pendingCount moved, and pauses while the tab is hidden; copy-maplibre-worker.mjs exits non-zero if the worker files are missing. Unit-tested (map-poller, poll-policy, tile-health). Pending: the Playwright spec e2e/map-resilience.spec.ts is written but has not been run green (local dev server ran out of memory), and next build was not run.
  • Suspense streaming on Home/profile/settings — Home now streams the lightweight OnboardingGate separately from one coherent DashboardSection; profile and settings retain their section-level boundaries. This lets the gate arrive first without presenting a partially assembled lifecycle screen.
  • Home inline summary: people plus available company/note/event/open-follow-up counts come from the existing one-query RLS-scoped getGraphStats() aggregate and render below the local-time greeting; the retired stat-card strip issues no second stats read
  • Searchable EntityCombobox + trigram GIN indexes replace bare entity text boxes
  • Saved page (/app/saved, "Saved" nav) — Starred + Watching tabs, each a server-mode paginated TanStack DataTable; a manual starred favourite flag (contacts column + partial index) with an optimistic StarButton on the contact header and in the People/Saved rows. The former Home Starred preview and CSS-columns bento were retired by the lifecycle rebuild; /app/saved remains the canonical surface. Pagination audit: People/Events/Admin/Import already paginate via the shared DataTable (server + client modes); interactive detail-page card lists left as-is by design. tsc/vitest/build green; visual pass pending
  • Last-touch ranking — lib/repo/last-touch.ts names the touch signals once (capture, a note, an event scan, an explicit "I reached out") and remains shared by recency, cadence, quiet-network, goal, and contact-detail reads. The former Home "Recent people" tile was retired by the lifecycle rebuild; current Home uses the same touch facts inside Moments for today instead of presenting a separate recency card. Built 2026-07-29 (web); mobile parity pending
  • Per-user /app appearance (theme + font) — built 2026-07-31. Seven contrast-tested colour themes (Dhaga, Monochrome, High contrast, Rose, Ocean, Forest, Violet) and eight font choices (Geist Pixel, System, Inter, Roboto, Open Sans, Lato, Montserrat, Poppins), persisted per user under the EXISTING settings key/value table (key ui_theme) — no migration, identical self-hosted and hosted. Every preset ships both a light and a dark palette, so the existing next-themes toggle is untouched: a preset picks the hues, the toggle still picks the mode (presets/types.ts documents why one-mode presets were rejected). Delivered as a <style> block emitted by the /app layout (components/app/AppThemeStyle.tsx + lib/theme/css.ts), so there is zero added client JS, no extra round trip and no flash of the default palette — a server-rendered choice is the only thing next-themes' pre-hydration script cannot know. The default preset emits nothing at all and the Google faces are declared preload: false, so a user who never opens the picker downloads no extra bytes. Scoped to /app/** on purpose — the marketing site, /blog and /docs are statically rendered SEO surfaces and per-reader colours would force dynamic rendering; they keep the brand. utils/constants/theme/__tests__/presets.test.ts holds every preset, in both modes, to WCAG AA for body/secondary/accent text and the 3:1 non-text minimum for control borders — which is why the picker is a curated list rather than a colour wheel. parseUiTheme degrades per field, so an unknown id from a rollback loses one choice, not both. Core: no packages/ee file and nothing to add to the core-only removal list in ci.yml. Browser-verified against the seeded Supabase account on a production build: preset + font apply, persist across a reload and revert on Reset, and all three doc screenshots (settings-appearance.png, app-theme-monochrome.png, app-theme-rose.png) are captured and committed — the capture entries live in scripts/capture-docs-screenshots.mjs behind the ONLY= allow-list so they can be regenerated after any UI change, and the run restores the account to the default preset in a finally. Verifying it surfaced one real defect, now fixed: components/ui/button.tsx hardcoded the primary button's amber cast glow, so a Monochrome or Rose button kept an amber halo — it now reads --shadow-accent/--shadow-accent-strong, derived from --brand-amber in globals.css. Verifying also turned up, and this branch fixes, a pre-existing crash unrelated to theming: /app Home and the confirmations inbox threw their error boundary on the seeded account because confirmationPayloadSchema reused the strict model-facing relationshipSchema to parse ALREADY-STORED rows, and a row written before the affiliation fields existed has them absent rather than null — which .nullable() rejects. The model contract stays strict (it must, for structured-output strict mode); the read path now goes through storedNoteExtractionSchema, which defaults those six fields to null. listPendingConfirmations also moved to safeParse-and-skip so one unreadable row can never take out the page again — zero rows actually needed skipping once the schema was fixed. Pending: mobile has no parity for the theme feature (the Expo app has its own token layer) and is deliberately out of scope. User guide: the Appearance section of apps/web/content/docs/guide/settings.mdx
  • Theme Studio — the Appearance panel rebuilt from the approved design (docs/design/theme-studio.html), 2026-08-14. The picker is no longer a grid of eight fixed themes: it is six source palettes (Dhaga, Racing, Gold & Blue, Rosewood, Terracotta, Earth — utils/constants/theme/palettes.ts) plus per-role override of the six things a colour can actually be — accent, background, cards & rail, text, muted text, borders (roles.ts) — mixable across palettes or taken from an OS colour picker. Stored under the SAME settings key (ui_theme) as a new colors object, so no migration; parseUiTheme keeps only well-formed hexes and degrades per field. Picking a palette writes six ROLE VALUES rather than a saved palette id, which is what makes "start from Rosewood, keep its ground, take Racing's accent" one click each. Everything the user sees is a NAME. nameFor() resolves a hex back to "Racing Red"/"Warm Greige", or renders Custom; the hex appears in exactly one place, the editable field inside the colour-picker popover — people do not think in hex and the row should not make them. Each role row carries a live WCAG ratio (lib/theme/contrast.ts, the one luminance implementation in the app) and a (?) popover listing what that colour paints, which also dims a miniature live /app preview down to the surfaces that role touches. The preview pins its own mode with .home-lifecycle-light/.home-lifecycle-dark — the class pair the landing mock already uses — and re-declares --brand-* on its own wrapper, so it needs no bespoke CSS and no hardcoded colour, and someone working in dark mode can still see the five light-only surface colours they are choosing. The eight THEME_PRESETS lost their grid (the palettes are the presets now) but are NOT removed: a stored preset still layers UNDER the colour overrides in buildUserThemeCss, so an existing user on Ocean keeps Ocean as the base layer, and Reset to default is what clears it. Browser verification found a real regression, now fixed. Surface overrides are light-only, and that had been implemented by leaving the property OUT of the dark rule — but the generated sheet declares light at :root:root:root (specificity 0,3,0) while palette-dark.css declares the stock dark values at .dark (0,1,0), so a user's LIGHT ground won in dark mode and the whole app stopped responding to the light/dark toggle. restateInDark (lib/theme/overrides/dark.ts) now writes the value dark should KEEP for every property light moves — the preset's own if it has one, else Dhaga's (STOCK_DARK_VALUES, pinned to the shipped stylesheet by a test). Same trap as the semantic.css alias layer and the .home-lifecycle-* scopes: a mode or a scope that restates only SOME of a group is half-themed, and half-themed reads as broken. Two existing tests had encoded the bug (expect(darkRule).not.toContain("--brand-panel:")) and were rewritten to assert the VALUE dark carries; lib/theme/__tests__/dark-restatement.test.ts is the new guard and was confirmed failing before the fix. Verified in a real browser at 375px and 1440px in both modes (e2e/theme-studio-visual.spec.ts, against local PGlite — never Supabase), which caught two more defects that measurement alone had missed: the palette cards' meta line overflowed its card (the Button primitive's whitespace-nowrap), and the preview clipped "View all" and both pills off its right edge at 375px (a grid item's min-width:auto). Known limits, inherited on purpose: surface roles apply to light mode only and dark follows only the accent; --brand-panel-2, --brand-line and --brand-well are driven by no role, so a hand-picked dark panel leaves the muted/secondary grounds on the preset's values; Gold & Blue and Earth supply a neutral light ground that is not in their own colour lists, so those rows legitimately read "Custom". Pending: no mobile parity (the Expo app has its own token layer); text-on-panel is not contrast-checked (only text, muted text and accent text against the background), so a dark card carrying dark text is possible and unwarned; the user-guide theme screenshots were regenerated 2026-08-19 against the Theme Studio — scripts/capture-docs-screenshots.mjs now drives the six palette BUTTONS rather than the dead radio grid. app-theme-monochrome.png and app-theme-rose.png keep their filenames but now show Racing and Rosewood, since neither Monochrome nor Rose exists any more; renaming them is a pending tidy-up. Hotfix, preview 500 (2026-08-14): adding colors INSIDE UiTheme took out the whole /app shell with Cannot read properties of undefined (reading 'accent'). getCachedAppConfig is a per-user cache with no TTL, so a warm entry written before this deploy came back as {preset, font} — present, so the shell's uiTheme ?? DEFAULT_UI_THEME guard never fired — and isDefaultUiTheme short-circuits on a non-default preset before it reads colors, so undefined reached resolveOverrides, which indexes it by the first role id. The guard's own comment had anticipated this failure one level up but a nullish fallback can only catch the object going missing WHOLESALE, never a warm entry of the previous SHAPE. New coerceUiTheme(value: unknown) (utils/constants/theme/ui-theme.ts) rebuilds every field with the same per-field tolerance parseUiTheme already had — so a shape lagging one deploy keeps the choices it did carry instead of resetting to the brand — and the shell now coerces rather than falling back. Covered by two cases in lib/theme/__tests__/css.test.ts, the first of which asserts the raw stale object still throws, so it cannot pass vacuously.
  • Fix render-blocking font/animation on first load (BRD §7.6) — Geist Pixel is self-hosted via next/font/local; the landing WebGL cursor and particle field were retired. Rich graph UI remains click- or state-driven, and the production Sigma renderer is dynamically loaded only when its preview is selected. Corrected 2026-09-08 — "the GSAP scroll thread was removed" had stopped being true. GSAP returned with the landing scroll animations (SectionHeading, CountUp, DecryptedText, TiltCard) and then spread to /, which renders none of them: FocusedHome imports Footer from Closing.tsx, and Closing.tsx imports SectionHeading for a FinalCta that page never renders — so every visitor to the site's highest-traffic route downloaded a 130,101-byte gsap chunk for an animation they could not see. Fixed by components/landing/useLazyGsap.ts: it loads @/lib/gsap through a dynamic import() from an effect, runs the callback inside gsap.context(fn, scope) so scoping and cleanup are unchanged, and does not issue the import at all under prefers-reduced-motion (re-checked on a media-query subscription, so flipping the OS setting mid-visit still animates). The rule that keeps it fixed: no component under src/components/landing may import @/lib/gsap or @gsap/react statically. @gsap/react's useGSAP pulls gsap in as well — which is how SpotlightCard was paying for the whole library while running no tween at all; it is a plain useEffect now. One static import silently re-attaches 130 KB to every route that transitively touches that component, with no build error to catch it. The animated copy is server-rendered and readable before and without GSAP, which is why dynamic(..., { ssr: false }) was rejected. Measured by summing the emitted assets that /'s prerendered HTML references, on two clean production builds (Next 16 no longer prints per-route First Load JS): 1,241,157 → 1,120,256 bytes of JS, 26 → 25 files, and the 130,101-byte gsap-bearing chunk is gone from the route entirely. The same pass moved the Fumadocs docs-site CSS out of globals.css into app/styles/fumadocs.css, imported by the /docs and /blog layouts only — 66,289 of the 320,243 bytes globals.css compiles to, render-blocking on / and /app, which never render a docs shell. / CSS after minification: 269,847 → 214,923 bytes. / end to end: 1,511,004 → 1,335,179 bytes, −11.6%; /product-tour 1,475,493 → 1,299,840 and /features 1,350,472 → 1,174,647 shed both halves, /trust the CSS half. /docs and /blog pay +28,193 bytes, split across two cacheable files of which globals.css is the one every other route already warmed — that is the trade, taken deliberately. Moving a stylesheet moves its source order, and that broke docs dark mode once before it was caught. Fumadocs declares its stock colours twice — light in a layered @theme, dark in a plain unlayered .dark {} — so once the import loaded after globals.css its dark block started beating our brand aliases and /docs rendered on neutral grey (--color-fd-background measured at #121212 instead of --brand-ink #0c161c) while light stayed correct, because a layered rule loses to an unlayered one whatever the order. The --color-fd-* aliases therefore moved out of styles/palette-light.css / palette-dark.css and into app/styles/fumadocs.css, below the import, which is also where they belong: nothing outside those two routes reads them. Verified in Chromium against a production server — /, /docs and /blog at 375px in both themes with no horizontal overflow, plus a real MDX page at 1280px with the sidebar, the table of contents, prose typography and brand-coloured code chips all present. The pre-v16 @source ".../fumadocs-ui/dist/**/*.js" glob was deleted rather than moved, after compiling globals.css with and without it produced byte-identical output — fumadocs-ui 16 ships those class lists itself as @source inline(...) in its css/generated/*.css. If a future Fumadocs stops doing that the docs go unstyled with no build error, so app/styles/fumadocs.css records how to put the glob back.
  • First load and layout stability on the signed-in routes (2026-09-08) — three things the signed-in shell paid for on every visit and used on almost none, each invisible to tsc, eslint, the build and the UI. driver.js left /app's first load: OnboardingGate mounts OnboardingTour on every Home render purely so /app?tour=1 still forwards to the settings leg — Home is not a driver.js leg at all any more, it draws the annotation overlay — but the leg machine was reached by a static import, so ~24 KB of driver.js plus 8 KB of render-blocking stylesheet (its own and our popover theme) shipped to 100% of visits to serve ~0%. Both now sit behind one dynamic import("./tour-runtime") taken only once a leg is actually about to start; the CSS moved into that module for the same reason, since CSS imported from a component module is hoisted into the route's stylesheet. graphology + ForceAtlas2 left /app/graph's first load: worker-runner is eager on that route (page → GraphView → use-graph-data → run-layout), and it statically imported fa2-core — ~110 KB of graphology and graphology-layout-forceatlas2 — to back a main-thread fallback that the COEP fix of 2026-08-25 made near-unreachable. The fallback is a dynamic import() now; the worker keeps its own copy in the worker chunk, so the layout still runs off the main thread and the fallback still renders a graph if the worker ever cannot start. Home's Suspense fallback stopped generating layout shift: HomeBentoSkeleton had drifted to gap-8 with tracks [minmax(0,1fr)_minmax(0,0.92fr)_20rem] against DashboardSection's gap-6 and [minmax(0,0.9fr)_minmax(0,1.1fr)_18.5rem], and it put the rail in a grid row of its own where the real layout stacks it under the network inside a flex column — so every column re-wrapped the moment the dashboard streamed in. It is now copied from that component class for class, and the fallback in page.tsx gained the space-y-6 the real root has (as two bare siblings it had no gap at all, so the whole bento sat 24px high and dropped on arrival); loading.tsx matches, so a hard navigation's three stages share one rhythm. Measured as the route's client chunk set out of a real production build: /app 1992.4 → 1961.7 KB raw, 570.2 → 561.0 KB gzip; /app/graph 1921.7 → 1846.0 KB raw, 537.2 → 521.2 KB gzip, with driver.js, graphology and FA2 absent from both. All three are pinned by app/app/__tests__/first-load-geometry.test.ts, which reads the source on disk and fails on a hoisted import or a drifted grid track — the only thing that can, since none of these regressions breaks anything visible. Field scores are NOT re-measured yet: the /app = 70 and /app/graph = 64 baselines are Vercel Speed Insights RES and need post-deploy traffic to move.
  • Cache authenticated /app/* navigation so switching pages doesn't re-run the full Postgres query set on every click (BRD §7.6) — per-user scoped, invalidated on mutation, not a raw TTL. Built (perf/app-nav-cache): a cachePerUser/invalidatePerUser helper (lib/cache/per-user.ts) over unstable_cache — cache key + tag both include userId and the read runs inside withUserDb(userId), so an entry can only ever hold that user's data (missed invalidation = same-user staleness, never cross-tenant leak). The force-dynamic shell (getCachedAppConfig: isAdmin/searchWeights/sttEngine/storeCardPhotos) now costs zero Postgres round-trips per nav, and the node-type ontology (getCachedNodeTypes, home/entities) is cached too; both bust via revalidateTag(..,{expire:0}) in their settings/node-type mutations. Volatile feeds (due reach-outs, signals, suggestions) intentionally left live — though the Today suggestion list stopped being volatile on 2026-08-02: its score is now a pure function of (data, the user's local day), so it is newly a cachePerUserVersioned candidate keyed on that day plus a cheap data version — not done, recorded here as a future option rather than a claim. The heaviest hot read, /api/graph/full, is also cached now via cachePerUserVersioned/getCachedFullGraph — version-keyed on the cheap fetchGraphVersion() aggregate, so a graph change changes the key (no explicit invalidation, never stale) and the multi-table assembly runs once per version instead of per request. Store backend is Next's default incremental cache; Redis = a cacheHandler in next.config.ts, no app-code change (Vercel already has a durable shared Data Cache). Typecheck/lint/vitest pass; still to do: manual browser pass + push; extend to schedulePrefs/calendarConnected (home) with their own tags
  • Read scale at ~10k users — cache the remaining hot reads per user (contact/event lists version-keyed + JSON-safe; data-only parts of the home feed) with the cachePerUserVersioned pattern, behind a Redis cacheHandler for shared multi-instance self-hosting — graph payload already done; see SCALING.md §1 + roadmap
  • Read replicas — route graph/search/list reads to a Supabase replica, writes to primary (SCALING.md §3)
  • General rate-limiting on data/AI routes (per-user + per-IP) beyond AI-action metering (SCALING.md §5) — a shared per-user floor now runs inside the auth guards, plus per-IP buckets on the one public mutating route and on the sensitive better-auth endpoints (mail-sending 10/h, sign-in 30/5min, allow-listed so session reads are untouched); still in-process, not a distributed store
  • Library-first data gateways (docs/LIBRARIES.md): TanStack Query behind @/lib/data, TanStack Table + nuqs inside DataTable (adds client-mode sorting) — PRs #24/#25 (2026-07-20); lint/typecheck/vitest green, manual browser pass + merge pending
  • Virtualize with TanStack Virtual when any client list/table renders ~1k+ rows (docs/LIBRARIES.md §6) — no current surface qualifies; do not add speculatively

3. Data layer (BRD §7.4 — boring storage)

  • PGlite (embedded Postgres) + Drizzle; hosted Postgres = driver swap
  • Hosted Postgres support: DATABASE_URL → node-postgres (unlocks Vercel)
  • contacts table
  • companies table
  • events + event_contacts tables (M2)
  • notes table (kind: voice|text|photo|capture_source, body)
  • facts table (type, text, confidence, source_note_id, deleted_at)
  • edges table (src/dst typed, predicate, source_note_id)
  • embeddings table (pgvector, 384-dim, receipts via owner_type/owner_id)
  • follow_ups table
  • ai_actions metering table (day one requirement)
  • calendar_event_contacts — which of the user's contacts a connected-calendar event is with, matched server-side on an EXACT email address and nothing weaker (a display-name or domain match is refused: a wrong link silently attributes a meeting to the wrong person, and nothing downstream can tell it from a real one). Only matches are stored; an attendee who is nobody in the graph is compared and discarded, and no contact is ever created from a guest list. Both FKs are ON DELETE CASCADE — a plain REFERENCES here is RESTRICT and would make forgetContact throw on its final delete, rolling the whole erasure back. A derived cache rebuilt from the window /app/plan reads, so it RECONCILES rather than upserts (a cancelled meeting has to stop showing) and refuses to reconcile on an empty read, which is indistinguishable from a rate-limited one. The address only survives that far because CalendarEvent.attendees stopped being displayName ?? email and became { email, displayName } (packages/core calendar/types) — until then the provider mapper threw the join key away
  • events.calendar_event_id (BRD §7.4) — the column the spec always named and the shipped table never had. Schema-only so far: it gives a CRM encounter event somewhere to name the calendar event it happened in, and nothing writes it yet
  • Deletion cascade: contact → notes → facts → edges → embeddings ("forget this person") — forgetContact cascaded the chain, but deleting a contact that had produced relationship suggestions was blocked by edge_suggestions RESTRICT FKs until PR #57 extended the cascade to them (repo/contacts/mutations/forget.ts)
  • Note deletion tombstones derived facts/edges (receipts invariant) — embeddings cleanup moved into deleteNote itself (2026-07-07, was only in the action layer, so any other caller skipped it); graph-receipts.test.ts now asserts embeddings are gone, not pushed
  • Tags carry receipts too (contact_tag_sources) — extraction used to union tags into the contacts.tags array with no provenance, so deleteNote/clearNoteDerivations could never revoke them. One row per (contact, tag, source note); source_note_id NULL = pinned by the user. A note's deletion drops only the tags nobody else vouches for; a second live note's tag and a hand-pinned tag both survive. Tags that predate the table are pinned by a re-runnable DDL backfill (TAG_SOURCES_BACKFILL_DDL, db/ddl/core/tag-sources.ts) rather than left with zero receipts — without it the first note to mention one wrote its only receipt, so deleting that note revoked a tag the user had all along (__tests__/tag-receipts/). Included in the full JSON export, repointed on contact merge, cleaned up by forgetContact; EE adds contact_tag_sources to TENANT_TABLES
  • groups + group_members + note_contacts, and a group_id on notes / facts / follow_ups (2026-08-23, lib/db/ddl/groups.ts, docs/MEETING_GROUPS.md §3) — a meeting group is a private set of the user's OWN contacts who meet together, the same shape as a tag: there is no invite table, no permissions column and no member who is not already this user's contact, because Dhaga has no inter-account collaboration and this adds none. group_members carries joined_at and left_at instead of deleting the row: a membership is a claim about a period of time, and deleting it would rewrite the group's history into "they were never here" while the shared notes written while they were here still exist. note_contacts is the snapshot of who was actually in the room for ONE note — seeded from a calendar event's matched attendees, editable by hand, and never derived live from group_members, which is what makes "adding somebody to a group never backfills old notes" a property of the schema rather than a rule a reader has to remember. Every contact_id is ON DELETE CASCADE for the same load-bearing reason calendar_event_contacts is: a plain REFERENCES is RESTRICT in this repo and forgetContact ends in a bare DELETE FROM contacts, so a RESTRICT reference would abort and roll the whole erasure back. notes.group_id and facts.group_id are RESTRICT and there is no hard delete — a group is archived (archived_at), because deleting one could only abort on its first shared note, orphan an owner-less note assertSingleOwner forbids, or cascade away what the user wrote about several people at once; the argument is restated in lib/repo/groups/index.ts so the next reader does not "fix" it. follow_ups.group_id is ON DELETE SET NULL and is not an owner — a follow-up already permits several nullable associations on top of a bare user_id. All three tables are in EE's TENANT_TABLES with RLS specs, and all three are in the full JSON export
  • Six hot-read indexes, in the boot DDL rather than the SQL editor (2026-09-03, lib/db/ddl/hot-path-indexes.ts) — Supabase's index advisor, reading pg_stat_statements, put getGraphStats (lib/repo/stats.ts, the nine-count aggregate behind Home's stat strip) at 44% of total database time: 1,655 calls at a 167ms mean. Four of its nine scalar subqueries had no index for their filter and fell back to sequential counts, so events (created_at), notes (kind), edges (deleted_at) and follow_ups (status) ship; contacts (source) serves the "promote a mentioned stub" lookup on every contact save (22,300 calls); and a partial contacts (reach_out_every_days) WHERE … IS NOT NULL serves the two catch-up reads. They live in the DDL, not a one-off psql run, so a fresh deployment, a self-host and every PGlite test database get the same plans production has. ⚠ They were already live in production before the PR merged: this branch's Vercel preview boots against the shared DATABASE_URL and the DDL self-applies on cold start, so all six existed on the production database (verified read-only, 6 of 6) before review. Purely additive, so nothing was at risk — but merging made the change durable rather than making it real, and that is a property of every DDL chunk in this repo worth knowing before opening a preview. The measured finding is bigger than the indexes — production EXPLAIN (ANALYZE, BUFFERS) shows they are picked, and the remaining cost is the RLS OR predicate, which is not indexable at all. Full numbers, the deferred fix and why the advisor missed it: the scaling notes

4. Capture — web quick-add (v1.1, M1-equivalent for web)

  • Paste email signature / free text → extracted contact (LLM, heuristic fallback)
  • Card photo scan (M1 web path): phone camera/upload → vision parse → review → receipt
  • Multi-image card scan: capture/upload several photos of the same card (front + back) or a multi-page leaflet together → merged server-side into one contact, every image kept as its own visual receipt; web (mobile-browser multi-shot camera, desktop live webcam, multi-file upload) + mobile Expo parity, backward compatible with the single-image path (mobile shares §12's on-device-verification caveat)
  • Multi-contact extraction from one capture (a leaflet/roster listing several people → several contacts) — not in scope yet; a multi-image capture always merges into a single contact
  • Card photos stored as visual receipts (user's own DB — local or hosted), shown on the contact page
  • File attachments on a contact or a company (2026-08-22) — a plain document shelf: attach a PDF, Word/PowerPoint/Excel document, text/CSV file or image (4 MB cap, MAX_ATTACHMENT_BYTES / ATTACHMENT_TYPES), list it, download it, remove it. No AI anywhere in it — nothing is read, extracted, enriched or embedded, so it costs zero credits and works on an instance with no LLM configured. Bytes live in the user's own Postgres (attachments, base64, same storage shape as card photos); listAttachments reads metadata ONLY, so a list of ten files is a few hundred bytes rather than the payloads. The 4 MB cap is set by the HOST, not by taste — Vercel Functions refuse a request body over 4.5 MB with 413 FUNCTION_PAYLOAD_TOO_LARGE before the handler ever runs, an infrastructure limit no config raises, so a larger cap would only manufacture files that pass the picker and the repo check and are then rejected by the platform; 4 MB leaves headroom for the multipart envelope on top of the file. base64 inflates ~33%, so a 4 MB file is ~5.3 MB stored. A self-hosted Docker/Node deployment shares none of that ceiling and may raise the constant (docs/SELF_HOSTING.md); every caller derives its copy from MAX_ATTACHMENT_BYTES rather than restating a number. POST /api/attachment (multipart — a server action body is capped at 1 MB) and GET/DELETE /api/attachment/[id], both behind an owner gate that re-reads the owning contact/company through the request-scoped connection, and the download is always application/octet-stream + Content-Disposition: attachment + nosniff so a stored file can never execute on Dhaga's origin. UI is ONE component for both pages (components/app/attachments/, owner props union like EnrichButton/FactItem), rendered in the aside of /app/people/[id] and /app/companies/[id]: size/format are pre-validated in the browser so a whole-file round-trip is never wasted (the route and the repo enforce them again — the client check is UX, never the boundary), delete is a confirm dialog, filenames truncate inside the 22rem aside. The full JSON export carries attachment METADATA, not bytes — by design, and RESOLVED by the archive export below (2026-08-23). lib/export/data/everything.ts selects each file's id, owner, fileName, mediaType, byteSize and createdAt and deliberately NOT data_base64, because the dump is assembled as ONE in-memory JSON string and inlining payloads would make the export fail outright rather than merely be large. For one day that made the JSON alone not a byte-for-byte copy of the account, against the standing "export must always work — you can leave with all your data" commitment; GET /api/export/archive closes it by streaming that same dump plus every file's bytes in one zip. Tests: components/app/attachments/__tests__/ (oversized file never reaches the network, server refusals surfaced as written, the same shelf for a company owner, long-filename clipping) + lib/__tests__/attachments.test.ts. tsc/lint/vitest green; no e2e spec exists for the attachment flow and it is not browser-verified
  • Streamed archive export — the complete copy (2026-08-23) — GET /api/export/archive (lib/export/archive/) answers the gap the entry above opened: one zip holding dhaga-export.json (byte-identical to /api/export/json) plus every stored file at attachments/<id>-<file name>, keyed by the attachment id the JSON rows already carry so each file reconciles back to its row. Nothing is buffered — payloads are read ONE row at a time through getAttachment(id), each inside its own short-lived withUserDb tenant scope, and the writer is driven from the stream's pull rather than start, so the next database read only happens when the client has consumed the last chunk; peak memory is one file, not the account, and no connection is pinned for the length of a multi-minute download. The same metadata-only query (lib/export/data/attachments.ts) feeds both the JSON manifest and the archive's file list, so the two can never disagree. fflate is the writer (docs/LIBRARIES.md §19) — already in the tree as a transitive dependency, zero deps of its own, so adopting it added no package; files are stored uncompressed (PDF/DOCX/XLSX/images are already-compressed containers) and only the JSON entry is deflated. Zip-slip is the security property: attachment file names are user-controlled, so every entry path goes through archiveEntryPath — basename only, path separators/control characters/Windows-illegal characters removed, leading and trailing dots stripped, non-empty fallback, and the server-generated id prefixed, which also makes two files sharing a name two distinct paths by construction. The ceiling is stated, not implied: a streamed response has no size limit on Vercel (the 4.5 MB cap is on requests) but maxDuration = 300 does bound it, and the plain non-ZIP64 zip bounds it at 65,534 files / 4 GB — both checked from the cheap manifest BEFORE a byte is written and refused with a 413 naming the fallback, never a truncated archive that looks complete. Its own export_archive rate-limit bucket (3 per 5 min) so it can never eat the budget for the cheap csv/vcard/json exports, which are unchanged. Surfaced at People → Export → archive with one line of copy explaining the difference. Tests: lib/__tests__/export-archive.test.ts (bytes round-trip identically, same-named files both survive, hostile names cannot escape attachments/, exactly one payload read per file with never two alive at once, an unreadable file omitted rather than written empty). tsc/lint/vitest green; not browser-verified — no e2e spec downloads and unzips the archive
  • Settings page: per-user "store card photos" toggle + purge-all button
  • Photo deletion cascades: gone with its receipt note, gone with "forget this person"
  • Edit-before-save review form (M1 acceptance: user confirms fields)
  • Assign capture to an event (create/pick "Web Summit 2026")
  • Attach source text as first note (receipt for extracted fields)
  • Write the first note while saving the person, not after (2026-09-04) — the note used to be reachable only after createContactAction redirected to /app/people/[id], so "met at the dinner, wants an intro to the platform team" cost a save, a redirect and a hunt for the notes section. ContactForm now takes withNote (default false) and renders one optional Textarea posting a top-level note field — a sibling of the existing sourceText/eventId fields, deliberately not folded into the hidden JSON payload, because the server re-validates that with the contact schema, which owns no note. Blank or whitespace-only means no note. On by explicit prop on the three create surfaces only — QuickAddManual (Add a person), QuickAddResult (scanned/pasted card review) and /app/people/new — and deliberately off on the edit form, which sits on a saved contact that already has AddNoteForm on its own page; a second composer there would be a duplicate writing to a different place. AddGuestDialog (calendar guest → new contact) also leaves it off for now. On the scan review screen this produces two notes on purpose and they are not merged: the machine's verbatim sourceText capture record, and the human's line about the meeting. Plain text only — no photo tray, no mic; those stay on the contact page's fuller composer. Covered by components/app/ContactForm/note-on-create.test.ts (field name, present on all three create surfaces, absent on edit). (web only — mobile parity pending; typechecked and unit-tested, not browser-verified in either theme or at 375px)
  • Manual add-contact form (no extraction path)
  • Quick-add reachable from the app nav on every screen — Capture in the desktop rail and phone bottom bar opens one shared capture→review→save dialog (NavQuickAddDialog + QuickAddForm) from any /app screen; the fuller /app/quick-add page remains under More and the phone You sheet. Responsive navigation shipped 2026-08-13. Event attach fixed on the nav dialog (2026-08-14) — it passed events={[]} with no defaultEventId, so the capture surface most people actually press offered nothing to attach to and never preselected the active event, failing BRD M2's "contacts scanned same day/venue auto-attach to the active event" on the main surface while /app/quick-add (a page, with a server component above it) met it. The list is now fetched lazily from GET /api/events when the dialog opens (AppNav/useCaptureEvents.ts + lib/capture-events.ts), not resolved in /app/layout.tsx: the layout's Promise.all already sits at the three-connection tenant-pool ceiling, so threading it through there would have put a listEvents round-trip on every /app navigation to serve a dialog most page views never open. Same lazy shape, and the same reasoning, as /api/ai/gate, which exists for these same two client-shell surfaces. defaultEventId is resolved server-side so all three capture mounts judge "today" against one clock rather than the visitor's timezone. The form is held behind a skeleton until the list lands, because EventPicker's <select> is uncontrolled — a defaultEventId arriving a render late would never apply, which would have reintroduced the bug in a subtler form. Not cached for the session (30s) the way the AI gate is: making an event and then capturing into it is the conference flow the list exists for. Covered by lib/__tests__/capture-events.test.ts. (the homeEvents() request memo the fix was expected to reuse is dead — HomeDock no longer renders anywhere) Phone + now ASKS the method (2026-08-14) — the centre + opened that dialog on its default Manual tab, so on the surface most phone capture starts from, the card scan, the paste box and voice looked like they were not there. It now opens a bottom Sheet (AppNav/CaptureMethodSheet.tsx) listing Manual · Paste text · Scan visiting card · Upload file · Record voice, and the chosen entry is handed to QuickAddForm as initialMode/initialSource keyed on entry.key — the same remount contract /app/quick-add uses, so a second choice actually applies instead of silently reusing the first surface. The last three rows are spread from HOME_CAPTURE_ACTIONS (utils/constants/capture-methods.ts), never retyped, so the phone sheet and the desktop Capture menu cannot drift into different names or different destinations; there is still no bare "Camera" row, because the app's only camera is the card camera. Record voice ships greyed out with "Coming soon" wherever the WebGPU probe returns false (most mobile browsers) — a live-looking row that lands you on a mic that cannot run is the same disappointment one tap later. The desktop rail's Capture and the phone You sheet's "Capture someone" still open the panel's own default. Covered by AppNav/capture-methods.test.ts. Superseded by ONE flat method chooser (2026-08-25) — the phone Sheet above and the desktop mode pills were two vocabularies for the same seven destinations, and five of them still sat behind a "← Back to capture" link that reads like an undo, so nobody found them. AppNav/CaptureMethodSheet.tsx is deleted: the phone's centre +, the desktop rail's Capture, the Home Capture button and /app/quick-add now open the SAME dialog on the SAME front door — QuickAddForm/CaptureMethodChooser.tsx, headed "How do you want to add them?", a responsive grid of all seven methods (Paste text · Scan a card · Record a voice note · Upload a photo · Add a person · Log a relationship · Note a fact or follow-up), each an icon + label + one-line blurb, driven off the single flat CAPTURE_METHODS constant (utils/constants/capture-methods.ts). MOBILE_CAPTURE_METHODS, CAPTURE_MODES/CaptureModeOption (the pill-strip vocabulary) and the whole of utils/constants/home-capture.ts — HOME_CAPTURE_ACTIONS, named earlier in this entry — are deleted with it: one list, one place it is maintained, so the phone and the desktop cannot drift into different names again. Picking a method lands on that surface under a CaptureSurfaceHeader naming it and carrying one ← All methods link back; there are no mode pills and no manual sub-tabs left anywhere. QuickAddForm/capture-mode.ts became the directory capture-mode/ (index.ts + types.ts + modes.ts + methods.ts) under the 150-line rule, and every CAPTURE_METHODS href must normalize back to captureEntryFromMethod(id) — the same mode, source and tab — so a menu tap and that URL pasted into the address bar land in the same place; capture-mode.test.ts pins the round trip for all seven. A bare /app/quick-add resolves to the chooser (the URL asked for nothing in particular); ?mode= / ?source= / ?tab= deep links still resolve straight into a method, so no existing link broke. The chooser footer carries a deliberately quiet "Adding lots of people? Import from Google, LinkedIn or a file →" to /app/import — bulk import was previously unreachable from any capture surface. An AI-gated method stays clickable and is marked "Needs credits" (the surface behind it explains the gate in full, and typing into the paste box still works); a method nobody can have at any price — voice with no WebGPU — is genuinely disabled with "Coming soon", per the beta rule. There is still no bare "Camera" row, and the reasoning now lives in the CAPTURE_METHODS doc block: the app's only camera is the card camera, so "Scan a card" IS the camera, and two names for one behaviour was the bug the old camera source shipped with. The in-dialog Voice/Camera/Upload dock row is gone, the chooser owning those destinations; QuickAddDock survives only inside the Home FAB (HomeDockCapture). Home's three-item Capture dropdown became a single Capture button linking to /app/quick-add. Docs updated in the same change: the capturing-people guide and the driving path in apps/web/scripts/capture-docs-screenshots.mjs. Web only — apps/mobile's Expo capture flow is untouched and still carries its own taxonomy; and the docs screenshots have NOT been regenerated, so quick-add-manual.png and nav-quick-add.png on the guide still show the retired hub until that script is re-run.
  • Manual entry reachable from every capture screen — Built 2026-07-27 (web) as a "Prefer to type it in? Add manually" affordance inside CaptureForm; by the responsive-nav rebuild that had already become the Manual pill in the mode strip, and this line went stale describing a link that was no longer there. Since 2026-08-25 it is the shared CaptureSurfaceHeader's "← All methods" link — one click from any AI capture surface back to the chooser, where Add a person / Log a relationship / Note a fact or follow-up each save through the same blank ContactForm and server actions with no extraction and no credit. Mobile parity pending.
  • Manual (no-AI) capture — add a person, record a relationship between two existing people, or log a fact / follow-up against a contact, none of which needs an API key or spends a credit (QuickAddManual + manual/ form builders, capture-mode/ routing). Built 2026-07-28 (web); mobile parity pending. The "Manual hub" it shipped as is gone (2026-08-25) — the capture dialog no longer opens on a Manual tab, and these three are no longer sub-tabs nested inside one: they are three of the seven rows in the flat chooser, siblings of the four AI methods, picked and deep-linked exactly like them (?mode=manual&tab=person|relationship|fact). QuickAddManual now renders whichever single form the chosen method names and owns no tab strip at all. Nesting them one level down behind a mode pill is why Relationship and Fact went unfound
  • Note capture — a pasted free-text note about an existing person (e.g. "Met Priya, discussed the round") is recognised by the same single metered contact_parse call that parses contact fields (no extra AI round-trip; captureExtractionSchema folds an isNoteAboutPerson/subjectName/noteBody classification onto the extraction), its subject is matched against your contacts, then the note is attached silently on a confident single match or routed to a confirmation — asking which contact it belongs to when several match, or offering to create one when none do — never guessing the subject (packages/core/src/capture/route.ts, a pure unit-tested router). Built 2026-07-28 (web); mobile parity pending
  • People list with filter + contact detail page
  • People bulk actions — multi-select on /app/people (per-row checkbox + select-all-on-page, selection persists across pagination) with a bulk bar: Merge, Add to company, Change relationship, Tag (add or remove), Group by (tag / company / location, the manual counterpart to the auto-suggested Groups tile — company/location force-overwrite, unlike the auto-suggested clusters' fill-empty-only rule), Set location, Add to event, Star/Unstar, and Delete (forget, full cascade). Built 2026-07-27 (web), Group by added 2026-08-02; mobile parity pending. Set location / Add to event added 2026-08-17 — the two answer different questions and are deliberately two buttons: Set location writes the free-text contacts.location (where they ARE), Add to event writes event_contacts (where you MET them, creating the event from the typed name if it is new). Set location defaults to only fill people with no location and offers overwrite as the explicit, warned second choice — a header-checkbox selection routinely includes rows the user never opened and there is no undo; the Group by tab keeps its force-overwrite semantics and now says so, passing mode: "overwrite" through the same action. Both writes are ONE statement over the whole selection (setContactsLocation UPDATE, addContactsToEvent INSERT … SELECT in a data-modifying CTE), never a loop of getDb() calls — the tenant-pool fan-out of PRs #60/#96 — and both select through contacts (and events), so an id the caller cannot see drops out instead of raising on a NOT NULL FK or, under EE RLS, writing across tenants; a contact already at the event is a counted no-op, not a duplicate row. No geocoding runs on write: contacts.location is free text and the map geocodes lazily from geocode_cache in GET /api/map's after() pass, so a 500-row selection costs zero Nominatim requests. Toasts report what actually changed (rows filled vs already set, newly attached vs already there). Companies gets neither action — the companies table has no location column and event_contacts.contact_id FKs contacts, so there is nothing to write; its bulk bar stays Merge + Delete. Covered by lib/__tests__/bulk-location-event.test.ts; typechecked, lint-clean, not browser-verified, mobile parity pending
  • Bulk relationship editor — a Change relationship action on the People bulk bar relabels how the selected people affiliate with a company (positions.relation): pick their current company or a specific company, then a new label (studied at / worked at / interned at / board member of / … or a custom type). Only the affiliation label changes — no role's company moves — so a mis-imported "works at" cohort (e.g. a whole university) can be split into "studied at" (students) vs "works at" (professors) while everyone stays clustered on the same company node. Built 2026-07-28 (web); mobile parity pending
  • Education capture — the contact create/edit form splits positions into Experience and Education (institution / degree-programme / field-of-study / years / "currently studying"), and every single role carries a selectable relationship type; education rows store a studied_at (or attended) affiliation. Built 2026-07-28 (web); mobile parity pending
  • Company auto-link: extracted company name → find-or-create companies row
  • Jobs editor — create a new company inline when there's no match (reuses EntityCombobox create + findOrCreateCompany)
  • Companies management page — /app/companies lists companies with contact counts; inline create, rename, delete, and multi-select (reached via Explore in the desktop rail or the phone You sheet, APP_EXPLORE_LINKS). Deleting a company detaches its contacts, positions and company tasks (job history kept, only the employer link cleared) and never deletes contacts — but it DOES erase the company's own notes and facts, which exist only to describe it (see the company detail page item below). Built 2026-07-27 (web); responsive nav access added 2026-08-13
  • Company merge + duplicate detection — per-field resolver (name/domain/sector) re-points all contacts, positions, and relationship edges onto the survivor in one atomic transaction, then hard-deletes the merged-away companies; /app/companies/duplicates clusters by normalized name (stripping legal suffixes like Inc/Ltd/LLC). Built 2026-07-27 (web); mobile parity pending
  • Company aliases — merging companies auto-records the losing names (and their own aliases) as aliases of the survivor (company_aliases table, recorded in the merge transaction); manage them per-company in the edit dialog ("Also known as") and globally at /app/companies/aliases (add / edit / delete / view, reached via the "Aliases" link in the Companies header). A later capture/import of an aliased name resolves to the surviving company instead of re-creating a duplicate (findOrCreateCompany alias lookup). Built 2026-07-28 (web); mobile parity pending
  • Company detail page — /app/companies/[id], at parity with the person page (2026-08-12) — a company used to be a label with no page of its own: CompaniesTable rendered its name as a plain <span>, and RelationshipRow sent company rows to /app/graph?focus= under the comment "Companies have no detail page yet". Now a company is a subject you can open. Same shape as /app/people/[id]: full-width header, grid lg:grid-cols-[minmax(0,1fr)_22rem], one <Suspense> per section, a _sections/ dir, and a cache() loader for the ONE query two boundaries share (notes feed both Notes and Timeline; facts/follow-ups have a single consumer each and are read directly — caching them would buy nothing). Sections: header (name, domain, sector, Also known as aliases, edit/merge/delete) → People at this company (current vs past off positions.is_current, with relation separating employment from studied/interned/board) → Relationships → Follow-ups → Facts → Notes → Timeline. Schema is expand-only: notes.company_id and facts.company_id (nullable, FK → companies ON DELETE CASCADE, partial indexes mirroring the contact-side ones) plus ALTER COLUMN facts.contact_id DROP NOT NULL — the same widening notes.contact_id took for entities and follow_ups.contact_id took for company tasks. Nothing dropped, no rewrite. A note/fact belongs to exactly ONE owner, app-enforced by assertSingleOwner (lib/repo/content-owner.ts) rather than a CHECK constraint, because a two-owner row is listed by BOTH detail pages and erased by BOTH cascades. Deletion cascades fully (lib/repo/companies/cascade-content.ts): the company's own notes/facts go with it — including rows OTHER records derived from those notes, which the DB-level FK cannot reach and without which the whole delete rolls back on facts_source_note_id_fkey — while people, positions and tasks keep their existing DETACH. Merge re-points company notes/facts onto the survivor. Receipt highlighting (?note=<id>) works here because the selected-note/ primitives were reused, not forked. Extraction on a company note writes company-scoped facts and follow-ups, each carrying source_note_id, and deliberately writes no positions/edges/edge-suggestions/tag receipts — those resolve against a person subject a company note does not have. Metering: it rides the existing note_extraction entry point (a nested withAiAction joins the open action; the user-visible action is still "I saved one note"), so no new wrapper and no new credit price. Company notes and facts ARE now found by SEMANTIC search (closed 2026-08-12, having shipped as a known gap the same day): embeddings gained a nullable company_id and contact_id dropped its NOT NULL (expand-only ALTERs in db/ddl/vector.ts; PK stays (owner_type, owner_id)), so a vector is owned by exactly ONE subject — a contact or a company — app-enforced by the same assertSingleOwner notes and facts use, not a CHECK constraint. Company notes/facts are indexed on write, backfilled for pre-existing rows, and removed when the company is deleted; hybridSearch/SearchHit carry a kind (contact | company) and Ask-Dhaga renders a company receipt card linking to /app/companies/[id], attributed to the COMPANY and never borrowed from a contact — a borrowed receipt would be a fabricated one (architecture principle 3). packages/ee adds an explicit WITH CHECK on embeddings so a vector can only name a contact or company inside its own tenant. Two limits remain, deliberately: the KEYWORD/full-text stage (lib/repo/search/keyword) still indexes contact-owned rows only, so with DHAGA_EMBEDDINGS=off — and on hosted serverless, where the local ONNX embedder does not run — company notes and facts are still not keyword-searchable; and ENTITY-owned notes stay unindexed entirely, because embeddings has no entity owner. Two of the cuts listed here have since closed — manual "Add fact" and "Enrich from public web" both landed on the company page on 2026-08-22 (their own items below). Still NOT at parity, all deliberate cuts: no live extraction streaming (FactsPanel/ExtractionStatus and listRecentExtractionJobs are keyed by contact id, so company facts appear on the next revalidation, not as they land — and /api/companies/[id]/facts was therefore not built, since with no onFacts trigger it would be dead code); photo/voice notes are person-only (addCompanyNote takes a kind defaulting to "text" since company enrichment needed to save an enrichment note, but no capture path passes a photo or voice kind); and merge stays on the list/duplicates pages, since CompanyMergeDialog resolves fields between ≥2 companies and a detail page has only one id. (web only — mobile parity pending; typechecked, lint-clean and unit-tested via lib/__tests__/company-content.test.ts, with the cascade and mutual-exclusion tests mutation-proved to go red, but not yet browser-verified)
  • User-triggered enrichment: web search → cited enrichment note → receipted facts. Companies too (2026-08-22) — /app/companies/[id] gained the same "Enrich from public web" control, and it is the SAME feature rather than a look-alike: the same enrichment entitlement (Pro/Power/self-hosted, requireFeature), the same 20-credit action, the same budget check. What differs is what it asks for and where the result lands — a company-shaped search prompt (industry, size, HQ, funding, products, notable news, with an explicit rule against writing an executive's biography onto the company) whose findings save as an enrichment note on the COMPANY, then a company extraction pass that writes company facts unverified, each with its source_note_id, for the same one-tap confirm the person page uses. One enrichment is ONE metered action on both arms, not a search plus an extraction (the nested extraction joins the open withAiAction scope). The company arm fires its own worker POST from the button (EnrichButton is now an owner union) because the company page has no FactsPanel streaming jobs — without that the job would sit pending until the daily reaper errored it. Still strictly user-triggered on both arms: nothing enriches in the background. lib/__tests__/company-enrich.test.ts + company-enrich-gate.test.ts; typechecked and lint-clean, but no e2e spec exists for this flow and it is not browser-verified. Jev gate on the extraction (2026-09-23, lib/ai/note-extraction/research-gate.ts, utils/constants/jev/enrichment.ts): on web enrichment, person and company, Jev reads the saved findings note and asks whether it states anything at all; only at p(found) ≤ 0.02 (ENRICHMENT_JEV_SKIP_MAX_P_FOUND, compared directly) is the Claude extraction skipped, and an empty extraction runs through the same apply phase — the same rows, the same outcome, still one 20-credit enrichment action. The web search always runs. Connector enrichment is never gated (its findings note is built by code from provider fields). Eval (30 synthetic findings): 0 false skips, 10 of 13 found-nothing notes skipped
  • User-added connectors — "Enrich from Apollo" beside "Enrich from public web" (2026-08-26) — merged; this entry claimed "not merged" until the 2026-09-06 audit found the connectors commit to be an ancestor of main. Still never once run against a live Apollo account, so every cost and latency figure below is an estimate. Design and the full unverified list: docs/PLAN_CONNECTORS.md. A connector is a credential the USER owns, stored encrypted (connectors table, AES-256-GCM via the existing lib/crypto/tokens.ts), that Dhaga spends on their behalf. The whole design is that it replaces exactly one stage: enrichment today is trigger → enqueue → fetch → derive, and a connector swaps the fetch while reusing the note-and-extraction tail verbatim — so findings arrive as an ordinary enrichment note and cascade delete, export, unverified-mode extraction and receipted facts all hold with no new code. Two providers, both code-defined (no user-pasted URLs, so §7.2's SSRF surface does not exist in this branch): apollo over REST with a pasted x-api-key, restricted to a three-path allowlist covering people/organization enrichment ONLY — an Apollo key can carry sequence/email scopes, and calling those would let Dhaga send mail as the user — and apollo_mcp over Streamable HTTP at https://mcp.apollo.io/mcp, which is OAuth 2.0 with no API key at all (PKCE + RFC 8414 discovery + RFC 7591 dynamic registration, in packages/core/src/connectors/mcp/oauth/), with the callable tool set PINNED at configure time plus a name heuristic that refuses email/send/create/sequence even when pinned, because Apollo's MCP exposes 50+ tools. MCP tool output is prose written for a model — which is precisely what the extraction pass wants, so one generic client serves any remote MCP server with no per-provider mapping. Priced at 2 credits, not enrichment's 20, and the gap is the point: the provider lookup is billed to the user's own Apollo seat, so Dhaga pays only for the Haiku extraction pass (~1 credit of work). ESTIMATED, never measured. Two gates, deliberately not one (lib/ai/gate.ts): connectorsGateReason guards STORING a credential and checks the connectors plan feature alone, because saving a key costs nothing and greying it on a credit-exhausted account would refuse a free action; connectorEnrichGateReason guards SPENDING one and adds the budget check, in the order the action checks it, so the greyed reason is always the one the server would have given. New connectors PlanFeature (Pro/Power/self-hosted, not free) and job kind connector_enrichment; extraction_jobs.connector_id makes the in-flight-job guard key on the connector, or an Apollo run would be handed back the web-enrichment job for the same person. Failure copy is USER-facing (connector_auth → reconnect in Settings, connector_quota → your provider account is out of credits) and deliberately does not reuse provider_auth, which points at the operator. lib/connectors/providers.ts is the ONLY file naming the provider modules — registration is by import side effect, and an import that never runs presents as "this build ships no connectors" rather than as an error. Tests: lib/__tests__/connector-enrich.test.ts, connectors-save-and-ownership.test.ts, connectors-credential-boundary.test.ts, lib/ai/connector-enrich/errors.test.ts, plus 51 new tests in packages/core/src/connectors/__tests__/. tsc + lint clean
  • LinkedIn Connections import — the user's own emailed ZIP is filtered locally to Connections.csv, with a direct CSV fallback; bulk import remains ToS-safe (BRD §6.7) (v1.1), wired into /app/import and covered by archive, parser, and repository tests
  • Google Contacts CSV import — user's own Google export (both header generations, ::: multi-values) → bulk contacts (v1.1); lib/import/google.ts, wired into /app/import, covered by csv-import.test.ts/import-repo.test.ts
  • Networking-chapter directory CSV import (2026-09-29) — a third detected CSV shape (scraped member directory, detected on user_id+profile_url+chapter+chapter_path), same review/destination-picker/dedup/200-batch flow. lib/import/directory/: rows sharing a user_id merge into one person (union of distinct values); email cells split on , ; and whitespace (unparseable text is kept in a custom field, not dropped); phone becomes "Work" and mobile "Mobile"; profile_url becomes the link "Directory profile"; address, city, chapter, classification, membership duration, products/services, training history, company description and scrape date land on the contact (address / custom fields), plus a searchable receipt note (directory_import, cut on a word at 2,000 chars). industry and chapter become tags, written through the tag tables (recordTagSourcesForContacts, pinned like a hand-added tag) — no import had ever persisted tags, and contacts.tags is a cache so it cannot be written directly. Company description is a custom field + receipt, not companies.blurb (that column is documented as web-lookup-only). Sector (2026-09-30): industry also fills the sector fields — the file's 27 industries fold onto the 14 default sectors (case/space-insensitive; an unlisted industry gives no sector but is still a tag). The contact's sector is stored as your own ruling, and a company the import creates gets the same sector; an existing company is never overwritten. Five mappings are judgement calls: Food & Beverage, Personal Services, Sports & Leisure, Car & Motorcycle, Advertising & Marketing. Rows imported earlier are filled by a one-off backfill script that only fills empty sectors. Known limit, dedup ladder unchanged: two different people who share a phone number (an office line) or a name with no company are skipped as duplicates of the first; on the one real file tried, that skipped 5 of 5,147. Tests: directory-import.test.ts, import-directory-repo.test.ts. Not browser-verified, and the real file has not been imported into a database The loader now also warms the file's places into geocode_cache at the end of a write run (best-effort, offline gazetteer, insert-only; see feat/geocode-on-write), and apps/web/scripts/backfill-map-places/ does the same for rows imported earlier (dry run by default; not yet run against any real database).
  • Universal file import (2026-09-29) — any tabular contact file, not only the four recognised shapes. Formats read in the browser: .xlsx/.xlsm (every sheet; first real row as header, so a title line or blank rows above it are skipped; dates → ISO; merged cells tolerated; read-excel-file, lazy-loaded — LIBRARIES §23), .csv/.tsv/.txt (delimiter and encoding sniffed: BOM, UTF-16, Windows-1252), .json (array, or data/contacts/items/results/… wrapper; nested keys flattened to address.city), .jsonl/.ndjson, plus .vcf as before; detected by extension AND content. Legacy .xls is NOT supported — it says "save as .xlsx"; no maintained CVE-clean browser parser exists. Google / LinkedIn / directory / vCard win exactly as before with no extra step; anything else opens "How we read your file": per sheet, each column → a field (name, first/last, title, company, email, phone, mobile, website, LinkedIn, social, address parts, location, birthday, other date, tags, notes, source ID, or "keep as custom field") with a confidence marker and a Select to override, and a counts-only live summary, then the existing review / destination picker / batches of 200. Cheapest-first (Rule 5): header aliases + value profiling (lib/import/universal/classify.ts) settle most columns in code; only AMBIGUOUS columns go to TypeSafe's Jev as one choice question each (lib/ai/import-columns, import_columns, 0 credits, dollar-ceiling + burst gated, one metered row per file). What Jev receives (owner decision 2026-09-29): for an unrecognised file only, for ambiguous columns only — the header, rates and masked shapes, and up to 5 distinct sample values (≤80 chars) of THAT column, drawn per column and not in row order; identifier/secret-like columns send shape only; never a row, never two columns' values together; recognised exports send nothing; "Read columns on my device only" (switch on the import screen, remembered per browser) or no JEV_API_KEY sends nothing and gives the identical deterministic import; the mapping step says which of those happened. Jev proposes, values dispose: a pick is refused when the column's values do not fit it (an "email" over non-emails). Nothing is dropped: every unmapped column with data becomes a labelled custom field (and a line in the receipt, cut on a word at 2,000; the field keeps the whole text), split cells become several emails / phones / tags, unparseable emails / phones / links / over-cap tags are kept in "(unrecognised)" fields, rows sharing a source ID (within a sheet) or an email (anywhere) merge into one person, and the receipt names the sheet and row. Phones are only tidied (float/exponent digits restored, no country code invented). Sector (2026-09-30, stack integration): a tags column headed industry or sector also fills the contact's sector, but ONLY when a value is one of the directory's 27 industries (DIRECTORY_INDUSTRY_SECTORS, same case/space-insensitive fold, same sectorForIndustries); anything else, and a generic tags/category column, leaves the sector empty. Tests: universal-import-sector.test.ts. Unverified: no live Jev call has been made for this (the choice question, its 0.8 bar and its prices are untuned estimates — run a live eval before trusting the confident picks); the mapping screen and the xlsx path have been exercised in tests and a build, not in a browser at 375px in both themes; a huge sheet's live summary recomputes on every override.
  • vCard (.vcf) import — user's own device export → bulk contacts, covers Apple/iCloud, Android, and Google Contacts device exports (vCard 2.1/3.0/4.0) (v1.1); lib/import/vcard/ (tokenize/map/labels, dependency-free, parsed 100% client-side), wired into /app/import via parseContactsVcard, phone-dedup makes re-import safe for email-less device cards; covered by vcard-import.test.ts (incl. the QUOTED-PRINTABLE accented-name regression guard) + import-repo.test.ts
  • OAuth contact connectors — converged on contact sync (2026-08-15). The v1.1 one-click importer (better-auth linkSocial grant + getAccessToken, provider gateway lib/import/providers/) persisted nothing: no external id, no cursor, no record that you had ever connected. So it re-enumerated the entire address book on every visit and dumped you back on the review table each time, and getAccessToken({providerId}) takes no account discriminator, so a second Google account was unreachable. Deleted rather than kept alongside (Rule 7 — two connect paths for one job was the bug). Connect Google Contacts / Connect Outlook / Hotmail now go through /api/contact-sync/connect/[provider] → contact_connections, which already carried delta cursors, contact_links.external_id, tombstones, last_synced_at and one row per (provider, account_email). Still env-gated on <PROVIDER>_CLIENT_ID/SECRET, so a self-host with neither shows file import only. Tokens are AES-256-GCM at rest (lib/crypto/tokens.ts), not better-auth's encryptOAuthTokens. Operator setup: docs/CONTACT_IMPORT_SETUP.md. Pending: Google sensitive-scope + Microsoft publisher verification for public (frictionless) use — works now for the owner + test users.
  • Regression to re-close: the deleted importer had a named-cause error table (lib/import/providers/errors.ts — SERVICE_DISABLED → the instance's Google Cloud project needs the People API enabled; ACCESS_TOKEN_SCOPE_INSUFFICIENT → re-consent; RESOURCE_EXHAUSTED → rate-limited, retry shortly). Contact sync is coarser: a failing connection is caught per-connection and flipped to needs_reconnect, which cannot distinguish "the People API is off on this project" from "the token expired". The mapping is worth porting onto the sync runner — deliberately not done in the convergence change, so it is a real, named gap rather than a silent one. Partly closed 2026-08-15: a 401 is now named — isAuthFailure (packages/core/src/sync/http/classify.ts) flips the connection to needs_reconnect, which is exactly the revoked / ACCESS_TOKEN_SCOPE_INSUFFICIENT case. 403 deliberately still is not, and cannot be from where the error is thrown: isRateLimited403 consumes the body that separates SERVICE_DISABLED and RESOURCE_EXHAUSTED from a permission failure in order to decide retriability, so by the time a SyncHttpError exists the reason is gone. Restoring the rest of the table means carrying that reason code (the code only — never the body, which can quote the user's contacts) onto the error
  • Import scale hardening: >40 MB / >20k-row files warn (not crash), upload batch 50→200, DataTable review is paginated; very large lists degrade gracefully
  • Suggested groups — confirm-only clustering over saved contact names (docs/ideas.md #4): a word shared by ≥2 names (surname, company, or place, split on whitespace/punctuation, never a substring match) becomes a suggestion the user confirms as a Tag, It's a company, or It's a location (company/location fill-in only contacts missing that field). The former five-item Home preview was retired with the lifecycle redesign; it lists every suggestion. lib/suggestions/name-clusters.ts (pure token-frequency, no LLM per Rule 5), covered by name-clusters.test.ts. Moved from /app/groups to /app/name-clusters (2026-08-23) — it clusters strings and produces tags and company links, never a group of people, and /app/groups is now the meeting-group surface. Nav row renamed "Suggested groups" → "Name clusters"; the page itself is unchanged
  • Onboarding walkthrough starts on Settings and covers the capture channels — the first-run walkthrough (components/app/onboarding/) is now ONE driver.js leg of seven settings steps, and Home is not a leg at all: it is an all-at-once crayon annotation overlay (components/app/onboarding/annotations/, list in utils/constants/onboarding/home-steps.ts) that draws every pointer together and clears on one "Got it" or ESC. That split is forced by layout, not taste. Settings' seven anchors live across its tab panels and only the selected one has a layout box, so they genuinely cannot be shown together — stepping is the only way to reach them. Home's are all on screen simultaneously, which is the case stepping serves worst: the stepped Home leg spent its budget waiting for anchors that were already visible, and on desktop for one that never would be. It used to run the other way round with five Home steps and five settings steps, then eight settings steps plus a four-step Home leg. The overlay resolves anchors by VISIBILITY, so a pointer whose control does not exist at this width is dropped and the rest renumber rather than leaving a gap — which is also why Capture now carries its own data-tour="capture" anchor on BOTH the desktop rail and the phone bottom bar (previously only .tour-quick-add, declared on mobile-only chrome and on the tablet Menu trigger, so above 1024px the app's most important action went unannotated and at tablet widths "Capture anyone" pointed at a hamburger). Settings is leg one because it introduces the preferences and destinations a new user needs, and the leg's order walks the tab strip left to right (Appearance → Credits → Contacts & sync signpost → Calendars → Messaging ×2 → notification preferences) rather than by narrative: SettingsTabs scrolls the active trigger into view on every change, so a non-monotonic order drags the strip backwards mid-tour. WhatsApp/Telegram was the headline gap — the messaging tree had no data-tour anchor at all — and gets two steps (what the channels are; how to link a chat), anchored on the provider list and the "Connect this chat" block, both of which render on an empty account; the QR/token card and the linked-chats list are described in that copy instead of anchored, because neither exists before a token is generated. Calendars gained an anchor too. Deliberately NOT covered, on step budget: profile, security, API keys, the MCP token card, card-photo retention, voice teaching, and every data-driven list page — "here are your contacts" over an empty table is the failure this change is fixing, so the Home leg's nav step names Companies, Network, events, saved items and confirmations without pretending there is anything in them. Four bugs went with it. (1) skipMissingElement was never set, and driver.js 1.7 defaults it to false: a missing anchor neither skips nor throws, it gets a 0×0 #driver-dummy-element at the viewport centre and the popover floats over blank page with the counter still counting it. Steps are now filtered against the live DOM before setSteps, which keeps the progress count honest and reads the gate matrix (no billing tab on core, no credits ledger without hasLLM(), plan-gated messaging link) off what actually rendered rather than duplicating it as constants; skipMissingElement: true is set as well, for an anchor that vanishes mid-leg. (2) A missed anchor killed the seen-flag forever — the old wait returned after a console.warn, so for the first step no driver was ever constructed, onDestroyed never fired, markOnboardingTourSeenAction was never called and the tour re-forced itself on every visit to /app, permanently; every terminal outcome (finish, ESC, abandonment, missing anchor, no anchors) now routes through one guard that fires exactly once. (3) Below 1024px two Home steps were orphans. The graph step is gone (its content folds into the nav step), and [data-tour="more"] now exists on the phone You trigger, tablet Menu, and desktop More control. All three can exist in the DOM at once, so anchors resolve via firstVisible, not querySelector. (4) Home waited for nothing, though OnboardingGate is deliberately cheaper than the streamed DashboardSection, so the tour could start before Moments or its navigation anchors existed; both legs now share one machine that waits. Visibility is tested with getClientRects().length, not offsetParent !== null, which is null for position: fixed and would hang on the fixed desktop rail or phone bar. Entry is resolveTourEntry (pure): /app forwards an unseen user to /app/settings?tour=1 and continues the Home leg on a sessionStorage hand-off; it refuses to start on the ?calendar= OAuth return, where SettingsTabs ignores hashes for the whole page load. The leg start is deferred one frame so React StrictMode's double-invoke cannot consume the hand-off key on the throwaway pass. Nothing in the leg enables anything; every switch it points at is described, not touched. First real test coverage (there was none): onboarding/__tests__/{tour-state,tour-leg}.test.ts drive the machine headlessly through a fake TourEnv — the three seen-flag tests were confirmed failing against the old behaviour first — and utils/constants/onboarding/steps.test.ts resolves every step selector against the real data-tour attributes on disk and every hash against SETTINGS_HASH_TO_TAB. e2e/auth.setup.ts follows the tour to its new route (its one ESC is what keeps the whole Playwright suite clickable). Unverified: no browser was available in this environment, so nothing below is confirmed by a real render — popover placement, the 375px You step, and the tab-switch timing on a cold connection
  • First capture on an empty Home (2026-10-06, components/app/home/HomeFirstRun.tsx, FirstRunExamples.tsx, QuickAddForm/CaptureMethodChooser.tsx, utils/constants/{capture-methods,home-examples,home-lifecycle}.ts, docs/ONBOARDING.md §3.7) — the first-run primary actions are captures, not a form: Scan a card / Say it out loud / Paste a note (voice disabled with its reason where the device can run neither on-device nor server speech), with Import contacts and Add a person as quiet links. The ghost globe is replaced by two clearly labelled Example contacts — synthetic client-side constants, never written to any database or API, never counted in a stat, search or export, and gone the moment the account has one real contact (pinned by FirstRunExamples.test.ts). The capture chooser opens on three methods (Scan, Voice, Paste) with the other four behind More ways, from one primary flag on the single flat CAPTURE_METHODS list; every deep link still resolves. One name for each intent (ADD_PERSON_LABEL, IMPORT_CONTACTS_LABEL, CAPTURE_NOTE_LABEL). The day-one setup card shows two steps (people, one note) instead of six, titled "Get set up"; the other four appear once a person exists. First-run guidance is one layer: the annotation overlay — the driver.js forwarding gate no longer mounts on the empty Home. Not browser-verified at 375px light/dark; the e2e specs ai-gate.spec.ts and capture-chooser.ts still click the four folded methods without opening More ways and need a follow-up.
  • Guided first-run wizard (core) (2026-10-06, app/app/welcome/, components/app/welcome/, lib/onboarding/, lib/repo/settings/onboarding-wizard*.ts, lib/actions/settings/onboarding-wizard.ts, utils/constants/welcome-steps/, lib/auth/oauth-return.ts, docs/ONBOARDING.md §3.8) — after the first verified login a brand-new account is redirected ONCE (from app/app/page.tsx, never the layout) to /app/welcome?step=<id>: one screen per step with Back, an optional Skip, a progress indicator and Skip all setup; seven steps (chat, calendar, contact sync, import, first capture, name + time zone, reminders). Completion is derived where the app can see it (a linked chat, a calendar, contacts, a note), so steps done elsewhere are never re-asked; a skip only suppresses and never counts as done. State is one JSON value under settings key onboarding_wizard (no DDL). Resume setup on Home's setup card and in Settings. Calendar and contact-sync OAuth can return to the wizard through a validated, signed next (relative /app/ paths only). Preference walk-through (2026-10-06, trimmed 2026-10-09): two further screens after the seven, each reusing the Settings control and saving only when the user changes it (appearance, and connectors [needs the plan]); Continue and Skip write only the wizard record. Card photos + taught words, daily suggestions, birthdays, meeting-guest privacy, security and connect-assistant are no longer wizard steps (they stay in Settings, defaults unchanged). The progress header counts EVERY step that applies to the account, done or not ("Step 8 of 9"), and the bar shows done steps filled. Left out: billing, credits, tokens, access log, deletion, sector auto-suggest (disabled) and the sector list. Connector consent returns to Settings, not the wizard. Not verified in a browser (375px, light/dark) or end to end against a real OAuth provider.
  • LinkedIn export reminder emails — a "Get contacts from LinkedIn" button opens LinkedIn's data-export page and starts a day-1/3/6/7 nudge sequence (since 2026-08-09 the "Your LinkedIn export" section of the daily brief, lib/jobs/daily-brief/sections/linkedin.ts, rather than its own email; was lib/jobs/linkedin-export-reminders.ts) that stops the moment they upload LinkedIn contacts (detected via the receipt note) or after a week; per-tenant fan-out in hosted mode (lib/hosted/tenants.ts), degrades to a clean no-op without Resend; cadence logic covered by a unit test
  • Mobile: on-device contact import via expo-contacts (SDK 57 legacy API) — permission-gated select → map to ContactProfile → POST /api/import with the per-user x-api-key (v1.1, honors the mobile/web parity rule); apps/mobile/src/{app/import.tsx,components/contact-import/,lib/contacts/map.ts} + web route apps/web/src/app/api/import/route.ts; mapper covered by device-contact-map.test.ts. Pending on-device build/test (native module added — needs a dev build, not OTA; user will test later).
  • Waitlist signups get a confirmation email (Resend)
  • Signup welcome email — ONE branded onboarding email per new account (lib/auth/config/welcome.ts + emails.ts), always carrying the product-guide link, and adding the not-yet-approved explanation only when the account is unapproved at send time. The branch is approval, not plan: every pricing CTA routes to /signup, the account is created free and unapproved, and the only checkout a new account can reach is on /pending after it exists — so currentPlan() answers free for 100% of accounts here and a plan check would never fire. Since 2026-09-10 that branch almost always takes the approved side: verifying the email address approves the account, and approval is written before the send on both paths (which is why the ordering in signup-hooks/on-user-created.ts is load-bearing — approving after the send would mail the queued variant to somebody already inside). The unapproved variant is now the recovery case — a revoked account, or an approval write that failed — not what a new user reads, and it no longer offers a way past a queue, because paying no longer skips one. Admin-invited, DHAGA_ADMIN_EMAILS, already-paid and core-only self-host accounts get docs-only for the same reason they always did. Read at send time (the email/password path sends at verification, long after signup), idempotent via a per-user welcome_email_sent settings key, never throws (logs PII-safe fields instead). The signup hook no longer sends the requester a second "request received" mail — notifyOwnerOfAccessRequest still tells the admin queue. Covered by welcome-email.test.ts
  • Adopt react-hook-form + Zod resolvers the next time a form grows real validation/field-array complexity (docs/LIBRARIES.md §4) — don't rewrite working server-action forms for it
  • Swap the hand-rolled PhotoCropper to react-easy-crop the next time the cropper needs a feature (rotation, aspect presets — docs/LIBRARIES.md §7); works fine today, no urgency

5. Events / auto-grouping (M2)

  • Events list + event page (contacts met there)
  • Create events (from Events page and inline in quick-add)
  • Rename/merge events
  • Web: active-event default (an event started today is preselected in quick-add)
  • Attach-to-event is a typeahead, not a native <select> (components/app/EventPicker/, 2026-09-03). Reported from a phone: the old field pair — a native <select> of every event plus a separate "…or start a new one" text box — opened Android's full-screen chooser sheet over the form ("scroll menu is overlapped by select message"), could not be typed into, and asked the user to make one decision in two controls. Now ONE Base UI combobox (components/ui/combobox.tsx, the same primitive as EntityCombobox/TagCombobox): typing filters locally over name and city (EventPicker/filter.ts — people look an event up by where it was), each row shows the emoji, name and a <city> · 12 Mar sub-line via the shared formatDayMonth, a name matching nothing offers Create event "…", and None stays a tap away. Filtering is purely client-side because the list already arrives in full as props — listEvents() is uncapped on /app/quick-add and behind GET /api/events, so there is no search route. Home's capture dock is the exception: it reuses the dashboard's homeEvents() memo, which is listEvents(HOME_PREVIEW_LIMIT) — 5 — so typing there searches only those five and anything else falls to "Create event …", which createOrReuseEvent then resolves to the existing row server-side. Pre-existing (the old <select> offered the same five); widening it means a second, uncapped read on Home's critical path. The choice is mirrored into hidden eventId / newEventName inputs (one or the other, never both; neither for None) so createContactAction's FormData contract is unchanged, and that action now calls createOrReuseEvent so typing the name of an existing event attaches to it instead of minting a duplicate. Mobile parity pending: apps/mobile still has no attach-to-event control at all
  • Mobile: time + geohash clustering per BRD §6.2 — packages/core/src/geo/geohash.ts + apps/web/src/lib/repo/event-clustering.ts (geohash-6, 4h window, "Name this event?" prompt on new cluster); typecheck/lint/build/tests all pass (a346516), not pushed; needs on-device verification (no EAS/device access here)
  • Meeting groups — surfaces (docs/MEETING_GROUPS.md §8, 2026-08-23). /app/groups lists confirmed groups (member count, colour/emoji, archived section) plus a Suggested section over the calendar window the page already loaded — recurring above one-off, capped at MEETING_GROUP_SUGGESTIONS_VISIBLE with a "show more", each card carrying an editable pre-filled name, untick-able occasionalMembers, and the unmatched-guest count linked to that meeting on /app/plan. Nothing is written until Create; Dismiss stores the canonical member-set key. /app/groups/[id] holds members with joined_at, add/remove (remove stamps left_at — the copy says past notes are unchanged), a shared-note composer whose "who was there" tick list becomes note_contacts, the group's notes spined with the ONE note-accents module, group-level facts, group tasks and recent meetings. Rename + archive/unarchive only — there is no delete, deliberately (lib/repo/groups/index.ts carries the argument). Groups is a first-class rail entry (APP_NAV_LINKS); the phone bar is a fixed five-slot grid and was left alone, so mobile reaches it through the You sheet. Actions in lib/actions/groups/, all through mutation(). Saving a shared note queues its extraction and the page DRIVES it: loadGroupPage reads the group’s own jobs and GroupExtractionStatus streams each active one to the worker, showing the stage, the counts or an honest failure. Re-running a shared note’s extraction is offered here and nowhere else (reprocessSharedNoteAction), for the same reason — this is the only page that can drive a group-owned job
  • "Note from this meeting" on a connected-calendar event in /app/plan (components/app/calendar/ExternalEventDialog/MeetingNoteDialog/) — pre-links that event's matched attendees (source: 'attendee'), offers a new group (name pre-filled from the event title) or an existing one, and the decline path saves an ordinary note on one person's page through the same addNoteAction their own page uses. It has to be a per-person note rather than a group-less shared one because notes.group_id is a shared note's owner and assertSingleOwner forbids a note with none. Unmatched guests are counted on screen and nothing about them is persisted
  • Group suggestions are deterministic — there is no LLM anywhere in this feature (lib/suggestions/meeting-groups/, 2026-08-23). Each meeting in the calendar window /app/plan already loaded reduces to a signature — its sorted matched-contact ids; a signature seen MEETING_GROUP_MIN_OCCURRENCES (2) times is recurring, once is one-off, and sizes outside MEETING_GROUP_MIN_MEMBERS…MEETING_GROUP_MAX_MEMBERS (2–12 matched, plus a 25-attendee raw ceiling) are refused: a 1:1 is not a group and an all-hands is an audience. Counting repeats is arithmetic, so Rule 5 says code answers it. Real recurring meetings gain and lose the odd attendee, which would shatter one weekly sync into three one-offs, so two signatures merge at Jaccard |A∩B| / |A∪B| ≥ 0.6 (MEETING_GROUP_MERGE_SIMILARITY) — the only cut that takes "one extra person came" (0.75) and "one person missed it" (0.67) while refusing a substitution (0.5), because a swap is a different meeting. Merging is greedy against an anchor (the most-repeated set, ties to the smaller) rather than a growing union, which would let sets chain until a group nobody ever attended together was proposed; anyone appearing only in a folded variant comes back as occasionalMembers, offered un-ticked rather than pre-selected. Naming is mechanical too — recurring takes the meeting title, a one-off takes title + date, a titleless meeting takes member names — and is editable before confirming. Nothing is persisted until the user confirms, matching name-clusters.ts verbatim ("an unconfirmed guess has no receipt"): until then only the meeting and the names are held in memory for the render. Dismissal writes the canonical sorted member-set key to settings (an atomic append, not read-modify-write — two concurrent dismissals raced there on the name-cluster side and one was lost), and the same key function is what de-duplicates against groups that already exist, so "already decided" cannot drift from "already dismissed"
  • No durable attendance history — and nothing may be built that implies one. calendar_event_contacts is a disposable cache, written from an after() on a /app/plan render and reconciled per window, and the window is { back: 1, forward: 2 } months. With no backfill (a deliberate decision, not an omission), group suggestions and a group's meeting history see only that window — which is why the meeting-history section is captioned with the window rather than reading as a lifetime record. Long-run cadence intelligence ("you haven't met this group in six weeks") is therefore not answerable and must not be shipped: the data does not exist. The fix, when it is worth its cost, is a durable meeting_attendance table, not a wider window
  • Calendars connected for availability only say so (components/app/calendar/LimitedCalendarNotice.tsx, 2026-08-23) — the premise this started from ("Microsoft doesn't return attendees") was checked and is false: microsoft-provider/read.ts selects attendees and maps them exactly as Google does, so no provider-specific notice was shipped and none should be — it would be a false statement about a competitor's product. The real gate is the capability tier, derived from the granted OAuth scope (packages/core/src/calendar/capability.ts): a connection with listBusy and no readEvents sees no titles and no attendees, on Google, Microsoft and the demo provider alike. So the notice fires on the one condition !capabilities.readEvents, says the calendar is connected for availability only and that meetings therefore cannot be read to suggest groups, and carries both the fix (reconnect with full access — it renders the existing CalendarFullAccessUpgrade inside itself rather than duplicating it, so there is still exactly one re-consent control with one wording) and the fallback (build the group by hand). It names no vendor. Mounted in Settings → Calendars, where the capability is already in hand so it costs no read, and on /app/groups, where it replaces the "Nothing to suggest right now" empty state rather than stacking on it: on a free/busy tier the suggester correctly returns nothing, and "nothing to suggest" then reads as a claim about the user's diary that we have no basis for. The shared condition is lib/calendar/limited-connection.ts, which also took ownership of canListCalendars (moved, not copied)

6. Notes & entity extraction (M3, M4)

  • Text notes on a contact (add, list, delete)
  • Extraction: note → facts/relationships/follow-ups/tags (Sonnet 5, structured output — it ran on Haiku 4.5 until 2026-09-04)
  • Facts render on contact page with receipt ("from note, {date}")
  • Receipts are navigable, not just labelled (2026-08-12) — the receipt told you a fact came from a note; now it shows you which. Selecting a note on /app/people/[id] highlights every fact and follow-up it produced, and selecting a fact or a follow-up highlights the note behind it plus that note's other output — one shared selection, so the relationship reads from either end. Note rows also carry a permalink (the chain icon), and the selection is the ?note=<noteId> search param (nuqs, history: "push", scroll: false), so a note is linkable, Back/Forward walk the selection, and an inbound link scrolls its note into view once (selected-note/use-note-anchor.ts, retrying while the notes list streams in, silent on a miss). Items with a NULL source_note_id — hand-added facts, manual follow-ups, optimistic rows — never highlight and are not clickable at all; that rule is the pure isFromNote (components/app/contact/selected-note/selection.ts, covered by selection.test.ts). Highlight is a ring and a tint from the amber token plus aria-current, never colour alone. Same pass surfaced a follow-up's added date on the person page, which previously showed only a due date. (web only — mobile parity pending; tags deliberately left out, their provenance belongs with the planned tags page. Typechecked, lint-clean and unit-tested, but not yet browser-verified)
  • Edges carry a visible receipt too (2026-08-19) — edges.source_note_id had been stored since M4 and governed the deletion cascade, but nothing ever showed it: FullGraphEdge did not even carry the field, so an edge could not answer "why do you think these two know each other?" — the one thing that separates a derived graph from Dex/Orvo's drag-one-contact-onto-another. Every place an edge is inspectable now says where it came from, via one shared EdgeReceipt (components/app/relationships/EdgeReceipt.tsx) that mirrors FactItem's affordance: the relationship list on /app/people/[id], /app/companies/[id] and /app/entities/[id], the graph canvas's edge popover, and the node panel's in/out edge lists. Three honest states, never a fourth that bluffs. A note in THIS page's timeline → a button that highlights it in place, the same ?note= selection a fact uses. A note on the OTHER endpoint's page — an edge extracted from Rohan's note also reads on Priya's — → a link to /app/notes/{id}, a new resolver route that looks the owner up server-side (a note belongs to exactly one of contact/company/entity) and redirects to that page with ?note= already set, so no payload has to carry a path per edge. No note at all → the quiet label "Not from a note", no control: hand-drawn and import-derived edges legitimately have no receipt, and saying so is what makes the receipt on every other edge worth believing. Which of the first two applies is decided server-side, by left-joining notes in listNodeRelationships and comparing the note's owner with the viewed node — the join also proves the note is still live, so a tombstoned receipt degrades to "not from a note" rather than to a control that opens nothing. Synthesized edges inherit the right receipt: a works_at/affiliation edge carries its backing positions.source_note_id, attendance and tag spokes carry NULL. Payload cost is one nullable id per edge, and it is not free: +20 B for "sourceNoteId":null and +54 B for a real uuid, against a ~182 B edge object — 11% and 30%. On a realistic 5k-edge graph that is +0.10–0.27 MB; at the GRAPH_TIER3_EDGE_TRIPWIRE of 150k edges it is +3–8 MB, which makes the Tier-3 follow-up (payload slimming / viewport streaming) more urgent rather than less, and is the first thing to reconsider if that tripwire is ever approached. And GRAPH_PAYLOAD_IDB_KEY went to full.v2 because the ETag is a CONTENT version, not a shape version: a cached v1 payload would 304 forever and paint receipted edges as "not from a note". (web only — mobile parity pending; typechecked, lint-clean, existing graph/receipt tests green, but not yet browser-verified. The entity page has no SelectedNoteProvider, so an entity-owned receipt shows as a label there rather than a highlight.)
  • User can edit/delete a fact inline (M4 acceptance)
  • Manual add-fact / add-follow-up forms (no extraction path, NULL receipt — a row typed from scratch still carries no source_note_id, so no note deletion can tombstone it; the note-selection path below is the one exception, and it is deliberate)
  • Manual add-fact on a COMPANY too (2026-08-22) — /app/companies/[id] can now be given a fact by hand, closing the cut the company detail page shipped with. The store stopped being contact-shaped rather than growing a second path: repo/manual-entries.addFact takes a FactOwner union ({contactId} | {companyId}) guarded at runtime by the same assertSingleOwner notes and embeddings use, addFactAction reads either owner off the form and hands BOTH keys on uncoalesced so a two-owner form is rejected by that gate rather than silently picking one, and upsertEmbedding files the vector under whichever subject it got — so a hand-typed company fact is semantically searchable exactly like a person's. Which owner it is changes three things and nothing else: whose existence is checked, which subject the vector is filed under, which detail route is revalidated. UI is the same FactListClient for both owners, not a fork. Behaviour matches the person path exactly: no source note, no extraction job, no LLM, ZERO credits — which is what makes the company page usable on an instance with no AI configured at all, previously impossible since extraction from a company note was the only way a company fact could exist. Covered by lib/__tests__/manual-entries.test.ts; an e2e spec was ADDED (e2e/facts.spec.ts, "add a fact to a company") but has never been executed, and the flow is not browser-verified
  • Select text in a note → add it as a fact, follow-up or relationship (2026-08-17) — the manual counterpart to extraction, for the claim the extractor missed or got wrong. Highlight a phrase inside any note body and a floating bar offers the three actions; the selected text is carried into the form verbatim — no model, no AI credit — and nothing is written until the user confirms. Fact → the text field, follow-up → the action field, relationship → seeds the target typeahead (a phrase is a lead, not an answer). These items DO carry source_note_id, unlike a hand-typed one: the note is genuinely where the words came from, so the item highlights with its note (?note=) and is tombstoned with it by the existing cascade (repo/notes/derivations) exactly like an extracted one. Threaded as an OPTIONAL argument through addFact, TaskInput/createTask and createRelationshipEdge, so every existing manual caller still writes NULL. Built on a reusable useTextSelection hook (lib/hooks/) — container-scoped (both ends of the selection must be inside the watched notes list), touch as a first-class path, and viewport-clamped so the bar flips below the selection rather than off the top of a phone; the note id is read off a data-note-body attribute so selecting the row's meta line offers nothing. The note body already sat inside the ?note= selection button, so that button now sits out the click that ends a drag (isTextDragSelect) — otherwise dragging inside the selected note deselected it mid-gesture. Wrapped once in NoteList, so it works on all four surfaces that render notes, including the two with no ?note= selection at all (Home's contact sheet, the entity page). Follow-ups go through createTaskAction, the only follow-up write that stamps user_id, which an entity note's contact-less follow-up needs for RLS. Known gap: "Add as fact" from a note selection is still CONTACT-only — but no longer for want of a write path, and the old reason recorded here was wrong from 2026-08-22 on. repo/manual-entries.addFact now takes a {contactId} | {companyId} owner and upsertEmbedding indexes under either, so a company fact writes and is searchable; FactListClient shows "Add fact" on the company page. What is still contact-shaped is THIS surface: FactFromSelection takes a contactId: string and posts it, so there is nothing to hand it for a company note, and a company note therefore offers the two actions that work. Widening it is a UI change, not a schema one. Entities remain a genuine gap — they have no facts table at all. Covered by lib/hooks/text-selection.test.ts, selected-note/selection.test.ts and lib/__tests__/note-selection-receipts.test.ts (which proves the deletion cascade takes the receipt-carrying items and leaves the hand-typed siblings alone). (web only — mobile parity pending; typechecked, lint-clean and unit-tested, but not yet browser-verified)
  • Deleting a note tombstones its derived facts/edges
  • Re-process a note: manual "re-run extraction" on an existing note (text/voice/photo) — after an edit or a missed fact — re-enqueues a note_extraction job for the same note; idempotent (worker's clearNoteDerivations replaces the note's prior facts/edges/follow-ups, never duplicates), AI budget still metered in the worker. Fixed 2026-09-04: it was also deleting what the USER wrote. source_note_id is a receipt, not an author, and the note-selection dialogs stamp the same one the extraction worker does — so re-processing a note hard-deleted (no tombstone, no undo) every fact and follow-up the user had lifted out of it by hand. facts.origin / follow_ups.origin (ddl/row-origin.ts, utils/constants/row-origin.ts) now record who wrote a row: 'manual' from addFact/addFollowUp and createTaskAction, NULL everywhere else, and clearNoteDerivations spares the manual ones (IS DISTINCT FROM, so NULL still clears — a plain <> would have spared every pre-existing row and ended the idempotent re-run everywhere). Not backfilled and not backfillable: a hand-made row written before the column reads NULL and is still cleared, because the only way to guess would be a heuristic over the text. deleteNote is deliberately unchanged — deleting a note still retires what was lifted out of it, which is what both dialogs promise. Covered by lib/__tests__/reprocess-preserves-manual.test.ts
  • Move a note to another person (2026-08-09) — inbound WhatsApp/Telegram/quick-add captures pick their subject with no confirm step (lib/messaging/process-session/apply/person.ts), so a note can land on the wrong contact. moveNoteToContact (lib/repo/notes/move.ts) re-points the note and everything keyed to its source_note_id — facts, follow-ups, positions, confirmations, edge suggestions and tag receipts (moveTagSourcesToContact) — plus extraction_jobs/card_images by note_id, the contact-typed edges endpoints only (a company or third-person endpoint is never touched, then self-edges and duplicates of this note's edges are dropped), and the denormalised embeddings.contact_id for the note's own vector and its facts'. One transaction, set-based SQL, no re-extraction — costs no AI credits and a fact the user edited or verified survives the move. contacts.title/company_id are recomputed on BOTH people (computePrimaryDenorm); capture_source receipts, entity notes, a missing target and a no-op onto the current owner are refused with a user-safe PreconditionError. Surfaced as a third control on the note row (MoveNoteButton, a dialog over the shared EntityCombobox) via moveNoteAction. Covered by lib/__tests__/move-note.test.ts. (web only — mobile parity pending; the dialog is typechecked and lint-clean but not yet browser-verified)
  • Import/scan/QR/WhatsApp receipt notes are permanent and excluded from the Notes count (2026-08-01) — capture_source notes (IMMUTABLE_NOTE_KINDS, utils/constants/extraction-jobs.ts) can no longer be deleted or reprocessed: deleteNoteAction/deleteEntityNoteAction (lib/actions/notes.ts) look the note up and no-op instead of deleting when its kind is immutable, and NoteList hides the delete button for them (the reprocess button was already gated by REPROCESSABLE_NOTE_KINDS, which dropped capture_source in the same change). They're also excluded from the dashboard's headline Notes count and activity sparkline (USER_AUTHORED_NOTE_KINDS, read in getGraphStats/getGraphActivity, lib/repo/stats.ts) — importing 751 contacts used to inflate the Notes tile to 760 (751 receipts + real notes); now the tile reflects notes a user actually wrote, and the receipts stand as a permanent, unlisted audit trail of how each contact was captured. Narrowed to actual machine receipts (2026-08-12) — the kind was also being written by capture paths whose text the user wrote, so a note saved with the browser extension, a message sent to the Telegram bot and a quick-add paste all came back with no Delete and no Reprocess (reported by a user against extension text capture). Those now write kind text with the channel in notes.source (api/capture/handlers/text.ts and lib/actions/contacts/create/create-contact.ts, which still writes a receipt when the save carries card photos), leaving capture_source to what no human typed: import receipts (lib/repo/import.ts — hasLinkedinImportSince still keys on the kind) and card-scan transcripts (api/capture/handlers/card.ts). They now count toward the Notes tile and are movable, which is correct — they are notes the user captured. HEAL_EXTENSION_CAPTURE_KIND in EXTEND_DDL frees rows the extension paths already wrote; Telegram and quick-add rows written before this change carry no marker that separates them from a genuine receipt and stay immutable. Covered by api/capture/__tests__/capture-note-kind.test.ts and lib/__tests__/heal-extension-capture-kind.test.ts
  • Voice notes on web (Dhaga Voice — Moonshine tiny, on-device WebGPU — live dictation → transcript → extraction)
  • Photo notes on web — a third capture mode in the same note composer, alongside typing and voice: attach photos of a whiteboard, poster, handwritten page, receipt or badge (transcribePhotoNote, lib/ai/photo-note.ts — vision call, structured output, metered + budget-checked like every other AI action) and the transcription becomes the note body, so it is searchable and runs the normal note pipeline (facts / follow-ups / embeddings) exactly as a typed note does. Photos are kept as the note's visual receipts under the same Store captured photos setting and are deleted with the note. Reuses the card-scan tray, cropper and downscaling. (web only — mobile parity pending, see M3)
  • A note about several people attributes what it extracts (2026-08-23, lib/repo/graph/attribution/, packages/core/src/schemas/extraction/) — applyExtraction(contactId, …) writes every fact to one contact, an assumption a shared note breaks. The rule the whole path enforces is that nothing lands on a person's profile unless the note was talking about that person. Facts, follow-ups and relationships gained an about (a name as written in the note), resolved against only that note's note_contacts set — a closed list of three to six people, never open-set resolution over the graph. Unambiguous → written to that contact with the ordinary source_note_id, and it renders on their page carrying a "from group note" link back to the shared note (GroupNoteLabel). Ambiguous → a confirmation, reusing the existing subject_resolution type with a second apply kind (attribute_extraction) that carries the single item as a one-item extraction, so the existing card, queue, resolver and dismissal all work unchanged and answering runs the plain applyExtraction — identical receipts and embeddings to a note captured on that person's own page. No about at all → a group-level fact (facts.group_id) or task (follow_ups.group_id), shown only on the group page. Three decisions worth keeping: about is .nullable().default(null) and never .optional(), because .nullable() keeps it in the JSON schema's required list (the strict-mode contract) while .default(null) makes it absent-tolerant on parse, so a confirmations.payload written before the field existed still reads — the failure that once took out the whole /app inbox; enforcement lives in code after parsing, with no superRefine and no required field, because #245 came from enforcing a rule in the prompt and schema instead of in code, and the prompt says outright that about never changes what is extracted (PR #212's verbatim-and-whole rule is untouched); and matching takes a whole name first then a single-token given name ("Sam" ⇒ "Sam Patel"), absorbing case, spacing, possessives and punctuation but never spelling and never a surname, with a tie or a miss counted as ambiguous. about is omitted from the company-note and unclear-note schemas, which have no room to choose from. The single-contact path is untouched — applyExtraction never reads about; this is a superset, not a rewrite. Tests: lib/__tests__/shared-note-attribution/ (partition.test.ts asserts every item lands in exactly one of the three piles, never zero) and packages/core/src/schemas/extraction/about.test.ts
  • A shared note's extraction is queued AND driven (2026-08-23, docs/MEETING_GROUPS.md §4). Two gaps of the same shape, closed one after the other. First, nothing was queued: createExtractionJob took only a contact or company owner, so applyGroupNoteExtraction had no production caller and the whole attribution path was unreachable code. Giving extraction_jobs a group owner (guarded by assertSingleOwner), a group-note branch in the worker's dispatch and a createExtractionJob call on the shared-note create path fixed the queuing — and left the job sitting there, because nothing in this app runs an extraction job except a mounted ExtractionStatus, whose stream POSTs /api/jobs/extraction/run per active job. That chain was contact-scoped end to end, so a group-owned job was enqueued by a page that could neither show it nor fire it and was reaped as "Timed out — retry." fifteen minutes later. Closed by mirroring the person page's chain rather than inventing a second one: listRecentGroupExtractionJobs (keyed on group_id, never on members — one pill on several pages, each able to fire the worker, is not a smaller version of this bug), read by loadGroupPage into extractionJobs, rendered by GroupExtractionStatus on /app/groups/[id] — mounting it is what runs the jobs — with GET /api/groups/[id]/extraction-status as the fallback a detached second tab reconciles against. ExtractionStatus moved to components/app/extraction/ and takes an ExtractionScope ({kind: "contact" | "company" | "group", id} — company joined it when the company page gained its own status route and surface, PR #274) instead of a contactId, which is what makes the absence of a driver visible: an owner with no status route cannot be named in extractionStatusUrl and does not compile. The status route runs auth first and wraps its read in withUserDb(userId, …) — the group id comes from the client and may only narrow within the session's own data, and a bare getDb() on an x-api-key request has no cookie to resolve and falls through to the global unscoped connection. Reprocess for a shared note lives here too (reprocessSharedNoteAction, offered per note in GroupNoteList), and nowhere else: a member's page withholds it because it cannot drive a group job, and the action re-checks the client's groupId against the note's own before filing anything. Failure stays honest — a failed or blocked run never emits the done event that produces "extraction finished — N facts added", and an undriven job still reads "Timed out — retry." rather than as a quiet success. Idempotent because clearNoteDerivations clears by source_note_id and is owner-agnostic, so group-level rows are retired with the rest. Tests: lib/__tests__/group-extraction-driver/, which never call the worker by hand and never query extraction_jobs themselves — they save a note and drive the REAL loadGroupPage, since a helper reading the jobs table directly would pass straight through the bug it exists to catch
  • A relationship with no about at all is not written — the one relationship outcome still dropped. edges has no group endpoint type, minting one was out of scope, and fanning the link out to every attendee is precisely the rule the attribution path exists to enforce. The same holds for a shared note's tags: there is no group tag table, and tagging everyone in the room with a topic they never raised is the same violation. Both are counted and returned (unwritten), never dropped silently. Do not "fix" either by applying to all members. Blast radius, measured 2026-09-06: the write is the cheap half — the endpoint columns are plain text with no constraint, the table needs no new tenant-isolation spec, account erasure already clears it, and the duplicate check is endpoint-type-agnostic. The readers are the work, and there are at least six: one would mistake a group endpoint for a contact, two would discard it as an unknown kind, the graph payload builder would skip it, and the extraction write path can't emit one at all. A group endpoint would also outlive its group, since nothing deletes a group today. Recorded here so the next attempt starts from the real cost rather than the apparent one
  • A shared note's tags are attributed and written — this used to sit on the line above as a second known limit and no longer does. tags carries about, so a tag the note names a member for lands on that member through their own applyExtraction (findOrCreateTagIds → contact_tags + contact_tag_sources + the contacts.tags cache), one it names nobody for lands on the meeting in the new group_tags / group_tag_sources (lib/repo/groups/tags), and an unplaceable one raises the SAME subject_resolution confirmation the facts do — never a second mechanism, and never fanned out to the room. Receipts mirror contact_tag_sources down to the coalesce'd unique index, so a re-run cannot double-record and clearNoteDerivations / deleteNote revoke exactly what one note vouched for. unwritten lost its tags key entirely rather than reporting zero, so a stale reader fails to compile. Driven end-to-end (not hand-called) in lib/__tests__/group-extraction-driver/tags.test.ts
  • Somebody in the room who is not in the group (2026-08-25, docs/MEETING_GROUPS.md §4, components/app/groups/SharedNoteForm, lib/actions/groups/notes/create.ts) — "Who was there" was a tick-list over the group's CURRENT members, so a meeting with one visitor had no truthful answer: leave them off and every fact the note states about them is un-attributable (note_contacts is the closed list about resolves against), or add them to the standing group, which is a claim about the future the user did not make. The tick-list now carries the shared PersonPicker — the same control the group's "Add someone" uses, so quick-create reuses quickCreateContactAction, which PROMOTES a matching "mentioned" stub rather than minting a duplicate — and picking somebody links them to that note only, as note_contacts with source: 'manual', the same row a ticked member gets. Membership is a separate opt-in, default off ("Also add them to this group" / "They are on this note either way. Tick this only if they are part of the group from now on, not just this meeting."), and turning it on still cannot backfill the group's older notes, because no-backfill is a property of note_contacts rather than of this screen. Guest ids ride the same hidden contactIds JSON field as the ticked members plus a guestIds field naming only the non-members, which the action intersects so somebody unticked before saving cannot be enrolled; the opt-in is a native checkbox, so OFF is the field's absence and no value can be misread as on; membership is written before the note because addGroupMembers is ON CONFLICT while a note save is not. A guest is attributable exactly like a member — that is the point — so narrowing processGroupNote's room read to group_members would silently reattribute their fact to the meeting. Driven end-to-end in lib/__tests__/group-extraction-driver/guests.test.ts and guest-attribution.test.ts
  • A forwarded message that is about NOBODY, or about a COMPANY, is finally kept (2026-08-27, lib/messaging/process-session/apply/unclear/, packages/core/src/schemas/batch-plan/unclear.ts) — a user forwarded the voice note "remind me to send <name> the revised quote on Friday" and got back "I couldn't place message 1 — nothing was saved from it". Transcription worked and the apply step worked: the PLANNER dropped the seq, because the plan has exactly three claimants (a person, a meeting, an ambiguity) and a reminder to yourself is none of them; every mocked case states the plan as a fixture, so all of them passed while the real model was dropping messages. A fourth claimant was not available — budget.test.ts measures the plan at 32 of the 32-property compiled-grammar ceiling, and a 33rd property is the 400 compiled grammar is too large that has already taken inbound messaging down for every user. So the fix adds zero schema properties: unclear was widened in PROSE (descriptions are prose to the model and cost the grammar nothing) into the plan's catch-all, and subjectName became a discriminator resolved in CODE — null ⇒ about nobody, a name matching one of the user's own companies ⇒ that company, anything else ⇒ ask. applyUnclear runs that ladder in order: a matched company gets saveCompanyNote + its reminders (verdict company); a subject-less note that asked for something writes general tasks (contact_id/company_id NULL, so it lands under /app/plan's General scope) and raises no card (verdict general), because a confirmation there would be a question with no right answer; everything else parks. Company resolution is match-only, never create (findCompanyByName — name then alias, both case-insensitive, mirroring findOrCreateCompany minus the insert): a misheard word in a transcript and a firm nobody has added look identical, so a miss costs one confirmation rather than a junk row the user must hunt down. NEVER DISCARD is enforced in code, independently of the model: markUnaccounted now parks a dropped seq's own text as a note_subject card before stamping it, so a planner slip is a question rather than a loss, and the chat line says where it went instead of "nothing was saved from it". Two adjacent lies fixed en route: answering "it's about a company" in the Inbox re-pointed the reminders and threw the note text away — the card said so, "the note text isn't saved — only a person can hold a note" — though notes.company_id has always been one of the four owner columns and listCompanyNotes has always rendered them; and the capture log printed seq + 1 over an already 1-based seq, so the chat reply's "message 1" was row "2" in the log, which is what made one message look like two. Voice transcripts are now kept on the item (saveItemTranscript, one jsonb_set): the transcript used to live only as a local in the derive step, so the capture log said "No text came with this one" about every voice note ever forwarded (the preview read the caption), and a dropped message took its only copy with it — keeping it also stops the stall sweeper re-paying for the same audio minute, which dollar-cap cannot see at all because gpt-transcribe bills per minute and reports zero tokens. Tests: messaging-cases/{no-person,company-subject,voice-transcripts,accounting}.test.ts, confirmations-note-subject-tasks.test.ts, capture-log/payload-*.test.ts, and — the only kind that could have caught the original bug — packages/core/src/llm/prompts/batch-plan.live.test.ts, which calls the REAL model and asserts every seq comes back accounted for, a self-reminder gets subjectName: null, an organisation is not turned into a contact, and an ordinary person still resolves to one. Run against the old prompt it fails two of four: "<company> wants the revised quote by Tuesday" came back as a contact named after the company. It costs money per run and skips without ANTHROPIC_API_KEY, so CI and a fresh clone stay green.
  • A forwarded message that is a MEETING becomes a group note (2026-08-25, docs/MEETING_GROUPS.md §4a, lib/messaging/process-session/apply/meeting/) — the batch plan had only two top-level claimants and the prompt forbade the multi-person case outright, so "I met A and B … B is in city in September … get back to A by Saturday 2:00am" landed one of three ways and the worst was the likely one: both people claimed the message, duplicatePlanSeqs flagged it, contestedNoteSeqs stripped it from both, and the whole message was stored on nobody with the Saturday 02:00 reminder silently never created. batchPlanSchema gained a third claimant, meetings, whose followUps carry about — the only place in the plan that does, because about is what puts B's fact on B — and plannedItemSeqs / duplicatePlanSeqs / withoutContestedSeqs all learned it. Missing any one either stamps a group-owned seq unaccounted (a bug reported to the sender) or writes the message twice, and the withoutContestedSeqs hole is silent: the object spread carries meetings through untouched, still typechecks, still succeeds, and writes the disputed message onto the group by exactly the route the guard exists to block. Every attendee goes through the SAME routeIdentity a planned person does, so the planner PROPOSES the room and code DECIDES it; the group is then matched, never created, by findGroupByMemberSet — ONE query, GROUP BY with two HAVING clauses, over members with left_at IS NULL in an unarchived group, on exact set equality, because a subset rule would file a two-person chat into a nine-person standing group and put the note on seven people who were not there (a tie takes the oldest group). An attendee the route will not settle makes the resolved set a subset, so the match is not attempted at all. A match writes ONE shared note (contactSource: 'extraction', the channel in notes.source, the messages' own words) with sourceItemId threaded through createSharedNote so the partial unique notes_source_item_idx makes a re-driven batch write one note rather than a second copy, stamps every message grouped ("Added to a group", distinct from attached on purpose) and runs extractAndApplySharedNote INLINE — never createExtractionJob, whose group-owned rows are only ever drained by a mounted GroupExtractionStatus and would sit pending fifteen minutes inside an after() before reading "Timed out — retry." No match ⇒ the meeting's follow-ups are written IMMEDIATELY as contact-less general tasks (dated, due_has_time set, mirrored onto the connected calendar) and only then is ONE note_subject card parked carrying the message text, the resolved attendees and those task ids, so a deadline never waits on an answer. Tests: messaging-cases/group-capture.test.ts, contested-meetings.test.ts (both cases fail without the meetings strip branch), meeting-groups/member-set-match.test.ts (the match SQL against real Postgres — subset, superset, same-size-different-people, left_at, archived, tie), shared-note-attribution/messaging-deadline.test.ts (the 02:00 landing on the right person and visible from the group). The card carries the MEETING answers (noteSubjectGroupSchema): the groups to attach to, from listGroupsContainingAny — ONE query, groups holding at least ONE of the room, most-overlapping first — plus the resolved room as removable chips and the plan’s groupName prefilled. Overlap here and exact equality for the automatic match are not an inconsistency: a group holding exactly this room would already have been written to, so the only groups worth OFFERING are the ones the exact rule had to refuse, and a human is choosing. Known gap: an attendee the identity route would not settle is not carried — people is ConfirmationOption[] and they have no id, so their name is dropped and the user retypes it in the composer (a pendingNames: string[] beside people would close it)
  • The bot understands what a message is FOR: a question, a correction, or something to keep (2026-09-01, lib/messaging/inbound/route.ts, lib/ai/message-intent/, lib/messaging/correction/, packages/core/src/schemas/message-intent.ts) — until now every inbound message that was not /start or DONE became a batch item, so "who do I know at Northwind?" was filed as a note about nobody and "no, I already have a contact, you spell it Anchit" was filed as a note about Anchit. One Haiku call now routes the message first. Four verdicts, because four things happen — deliberately not one per capture kind, since a note, a new contact and a follow-up all take the same path and the batch planner tells them apart better downstream. A question goes to the existing non-streaming answerSearchQuery (whose own docstring already named a webhook as its intended caller) and is never captured; a correction is applied to what this chat just saved; capture and unclear fall through to the batch exactly as before. It could not be a property on the batch plan: schemas/batch-plan/budget.test.ts pins that schema at 32 of the measured 32-property compiled-grammar ceiling, where a 33rd is the 400 compiled grammar is too large that has already taken inbound messaging down for every user — so the intent and the correction's two names ride on one structured output of their own, and on one round-trip rather than two. Corrections resolve in CODE, never on the model's word (the rule apply/identity/route.ts already states): the classifier returns two bare strings copied verbatim from the user, and resolveExistingContact demands pool.length === 1 && isExactNameMatch over the same findRelationshipCandidates query quick-add and the planner already route on. same_person where the name resolves to an existing contact ⇒ merge, the pre-existing row surviving (it holds the history, the share links and any bookmarked /app/people/<id>); where it resolves to nobody ⇒ rename in place, creating nothing; different_person ⇒ move every note this batch wrote, leaving the source contact standing. Anything short of one confident answer writes nothing and says why — "I found 2 people who could be Anchit" — because refusing costs the sender one more message, while guessing files a note on the wrong person, which looks exactly like a note the user put there themselves. THE BLAST RADIUS IS TWO GATES, NOT ONE. isTouchedByBatch bounds a correction to the contacts one recent batch named (MESSAGING_CORRECTION_WINDOW_MINUTES = 30, measured off processed_at — the sender is correcting the summary they were sent); and merge additionally requires created, because recent.entries also holds attached verdicts and merging hard-deletes the source row — without that second gate a parsed chat line could destroy a contact somebody has had for years, and the reply now refuses that case by name and points at the app's merge flow. Neither gate needed new storage: messaging_session_items.outcome has always stored {contactId, contactName, noteId} beside a created/attached verdict, written by the walk in the same scope as the write it describes, so "what did I just do" was already on disk and simply unreachable. No conversational state was added by the router — a follow-up correction is just another correction against the same batch. (The dormant messaging_pending_questions table was revived separately on 2026-09-04, for the in-chat subject question only; see the entry below.) Priced at 0 credits (message_intent, BRD §8.3): the user forwarded a note, they did not ask us to read it twice; it runs on every message, so any price would make forwarding ten cards cost ten credits more than it does today — the exact regression batch_plan exists to have undone; and what it routes to is already priced, so charging for the routing would double-bill one action. It is also the one AI entry point that does not call assertAiBudget: that helper's first act is to spend a token from the shared per-user "ai" burst bucket, so ten forwarded cards would have spent ten of them on routing and could have been refused the capture by the guard meant to protect it. It checks the dollar ceiling instead, which is the gate that actually bounds a 0-credit action. Bounded by a 3s timeout (the webhook walks a batch's messages sequentially inside a 60s maxDuration, so a slow provider morning is the difference between the forwards landing and the whole invocation being killed with them unstored) and short-circuited entirely when hasLLM() is false, so a core-only self-host pays neither the model call nor the recent-save query. Every failure path returns capture — no LLM, a throw, a timeout, a budget read that will not read, a Zod miss against the schema — because an unanswered question can be asked again and a dropped note cannot. Tests: messaging-cases/{corrections,message-routing}.test.ts and packages/core/src/schemas/__tests__/message-intent.test.ts; the load-bearing case is the one asserting that a correction against an attached contact does not merge. Not verified against a real WhatsApp or Telegram message — every case is mocked, as the rest of this surface is. And a batch that saved NOBODY is reachable too (2026-09-03): the headline transcript was a voice note kept as a plain reminder — bot: "Kept 1 reminder for you. It’s under General on your plan." / user: "No it was for <name>" / bot: "1 note needs your input." The correction did nothing: it was filed as a new note, which then needed its own subject question, and the reminder stayed under General. That batch had taken rung 2 of the unclear ladder — nobody named, something asked for — so it wrote contact-less follow_ups and stopped: no contact, no note, no card. The general verdict recorded that reminders were kept and not which, so a correction had nothing to re-point. The unblock was one field: generalTaskIds on the stored outcome detail — ids, never actions, per that detail's own contract (ids and names, never note bodies). With it toEntry maps a general row to a third entry kind, and the correction re-points exactly those rows onto the resolved contact through assignTasksToContact — contact_id only, a sibling of attachTasksToContact rather than a call to it, because a general task has no note and stamping a source_note_id that names nothing would be a receipt pointing at nothing. A re-point, never a re-extraction: the action text, the due date and the user's own timing words survive untouched. The calendar is mirrored afterwards — scheduleCalendarWriteOut(userId, id), the explicit-userId variant, because a provider webhook has no session and scheduleTaskWriteOut's requireUserId() would throw — so the event stops reading as a general task and becomes Follow up: <contact> with the contact's deep link. The name resolves the same way as every other correction: one exact match over findRelationshipCandidates, on nickname as well as name (the candidate row is passed, never .name), and anything short of one confident answer writes nothing and says why. The classifier had to be fed too: a general batch has no name to put in "Just saved", so its entry contributes a LABEL (a reminder I kept for you) and the system prompt learned that a non-person entry is still correctable — "no it was for Y" leaves wrongName null. No subject is invented, because that rung is reached only when the planner named nobody. Blast radius unchanged: only the ids that verdict recorded, inside the same 30-minute window; a batch holding two general notes is two nameless targets and asks rather than moving both. A general row written before the field existed carries no ids, maps to nothing, and falls through to capture exactly as it did. Tests: messaging-cases/general-corrections.test.ts, which runs the real batch first so the ids under test are the ids the apply path actually stored. Jev gate (2026-09-23, lib/ai/message-intent/jev.ts, utils/constants/jev/message-intent.ts): with JEV_API_KEY set, TypeSafe's Jev answers a confident capture or unclear (confidence ≥ 0.9) with no Claude call; a list_add when the message names exactly one existing, non-archived list whose name does not end in "list" (code fills listName, lib/ai/message-intent/list-name.ts); and a snooze that states exactly one duration code can read ("in 10", "10 mins", "an hour" — lib/ai/message-intent/snooze-minutes.ts does the arithmetic, still one Jev call) or, failing that, a confident next-morning — one Jev call carries both questions, so askJev runs at threshold 0 with a per-answer bar. For the directive and spoken-question callers, which act on one verdict only, a confident "not a correction" / "not a question" (p(yes) ≤ 0.1). Every other verdict, and anything less confident, still runs on Claude. Timing: Jev gets a 1s share (MESSAGE_INTENT_JEV_SHARE_MS), then Claude gets exactly its pre-Jev 4s — worst case 1s slower, and with no key the timing is unchanged. A bare link skips the router (2026-09-23, lib/messaging/inbound/bare-link.ts): a message that is exactly one http(s) URL goes to capture with no model call and no read, with or without JEV_API_KEY — it cannot be any verdict the router diverts.
  • “Who is this about?” can be answered in the chat, in one word (2026-09-04, lib/messaging/pending-question/, lib/repo/messaging/pending-questions/, utils/constants/messaging/config/pending-question.ts, utils/constants/messaging/question-replies.ts) — measured on a live account, ~60% of forwarded messages become a “who is this about?” card and 51 of them were resolved by hand. None of them can be filed automatically and that is deliberate: identity here is EXACT, because “Vikram sir”, “Vikram mama” and “Vikram” are three different people and nothing is ever stripped or normalised toward one of them. So the ask VOLUME is not going to fall; what changes is the COST of an answer — one word in WhatsApp instead of a trip to the app. No DDL and no EE registration were needed: messaging_pending_questions was built for exactly this flow and abandoned, with its table, its (provider, external_id, created_at) index, its TENANT_TABLES entry and its RLS isolation test all already on disk and referenced by nothing but the schema barrel. THE INBOX CARD IS STILL CREATED, ALWAYS. The chat question is a mirror of it, never a replacement — an unanswered chat question must cost the user nothing, and a chat-only question would be a lost note. ONE OPEN QUESTION PER CHAT, which is the constraint the table’s own docstring states and the reason the original mechanism was ripped out: a batch that offered several at once capped at one resolvable ambiguity and turned every other into a duplicate person. So every ambiguity is parked as a card as before, ALL of them are queued as rows, and only the head (oldest unexpired — FIFO, the order the sender forwarded them in) is ever asked; the next is asked in the same breath as the reply that answers the first, never alongside it. A meeting question is excluded outright: its answer is a group, not a person, and this channel can only hand the applier a contact id it already offered. Answering runs BEFORE the intent classifier (lib/messaging/inbound/index.ts), under /start and DONE, which still beat everything: the sender has just been shown a numbered list, so “2” is an answer by construction, and paying a model to re-decide it would let a cold call, a spent budget or a keyless self-host turn a working answer back into a note about nobody (Rule 5). Matching is matchPendingAnswer — whole-message equality on the number or the label, for the reason config/done.ts documents, since “Mira Sample called about the invoice” is a note and a substring match would file the parked words on him AND throw that sentence away. A name matching two candidates resolves to nobody, on purpose. The write is delegated, never forked: resolveConfirmation → applyNoteSubject, the same applier the Inbox card taps, inside the row’s own claim/rollback guard so an answer racing a tap in the app is a no-op rather than a second note; the repo layer is called with an explicit userId inside withUserDb, because a provider webhook has no session and resolveConfirmationAction’s requireUserId() would throw — the same trap scheduleTaskWriteOut set for the general-corrections path. Like correction/parked.ts it runs the note's fact extraction and then the capture-task retirement (2026-09-06, lib/actions/confirmations/note-answer.ts, shared verbatim with the card's own answer). It deliberately ran NEITHER at first, on the belief that both halves were session-bound; only the retirement was — extractAndApplyNote had always taken an explicit userId — so a chat answer filed the note and derived nothing from it: no facts, no edges and, the part a user actually notices, no reminder out of a message that asked for one, while the same answer tapped in the app produced all three. retireCaptureTasks now takes the acting user as an argument the way scheduleCalendarWriteOut does, and the two halves live in ONE function because running the extraction WITHOUT the retirement is the one combination that is actively wrong (the user keeps both renderings of every sentence). The chat answer costs the user 0 credits — its own metered action note_extraction_chat, priced 0 and gated on the inference-dollar ceiling ALONE rather than assertAiBudget, which would spend a burst token a sender mid-batch needs for the capture itself; the ai_actions row still carries the real token usage, so free-to-the-user is not invisible-to-the-ceiling on what is now the highest-volume extraction path. And the model call is bounded by the clock: the SDK's default is a ten-minute timeout with two retries, and overrunning the webhook is not merely slow — the invocation is killed, the provider re-delivers, the retry finds the question already expired and falls through to a capture the duplicate index drops SILENTLY, so the sender gets no reply to a question they answered correctly. The bound is charged against elapsed invocation time, not per message, because maxDuration = 90 belongs to the whole provider DELIVERY: the route loops await handleInboundMessage over an array whatsapp-client/parse.ts flattens with no cap, and raiseOpenQuestion opens the next parked question the moment one is answered, so a sender replying three times runs three bounded extractions plus three intent calls plus the 20s DONE grace against ONE ceiling — a flat per-answer 30s reaches the exact failure the bound exists to prevent, through the bound. withInvocationBudget (lib/messaging/invocation-budget.ts) starts one clock at the route and chatAnswerExtractionBudgetMs() hands out what remains after MESSAGING_INVOCATION_RESERVE_MS, so the first answer may take the 30s ceiling and a later one takes whatever is left, possibly nothing. A bound that expires costs nothing the user was promised: the note is filed, and with no outcome nothing gates the retirement, so the capture-time reminder stands. A timeout lands on the same safe side as every other failure here: no outcome, so nothing gates the retirement and the promised reminder stays. And the round trip should not have been needed at all. The reported note — one person, a purpose, “tomorrow at 3pm” — produced NO follow-up at capture either, from a batch plan already paid for: meetings refuses a single attendee, and the unclear rung’s followUps were described as what the note asks the user to DO, so an appointment (which asks for nothing) correctly came back empty and the 3pm passed. Fixed with descriptions only — unclear.followUps, the shared followUpSchema.action, and the two prompts now say a scheduled commitment counts — because the plan sits ON the 32-property grammar ceiling with zero headroom and one more property is the 400 that has taken inbound messaging down twice, while prose costs the grammar nothing (an 8.3 KB schema was refused, a 9.0 KB one accepted). budget.test.ts now asserts the count is UNCHANGED rather than merely under the cap, since <= cannot tell a deliberate spend from an accidental one at zero slack. resolveDatePhrase remains the only thing that turns a phrase into a day and due_time the only thing that carries the clock: the model copies words, code does the arithmetic (Rule 5). Whether the model now RETURNS the appointment is prose in a prompt, so only a live call can prove it — and as of 2026-09-07 it is proved, by packages/core/src/llm/prompts/dated-commitment.live.test.ts (real model, skipped without a key). Proving it corrected the claim: the batch plan is not the layer that carries a named person's appointment, and the earlier live case that looked for one there was red against the real model from the day it was written. Given one human named in full, the model correctly files them under people (measured: unclear and meetings both empty), and a person in the plan has no followUps field at all — their reminders come from the note extraction that runs afterwards. So the widening is asserted at the two layers that can carry it: noteExtractionSchema.follow_ups for a named person, and unclear.followUps for a commitment about somebody the plan will not name (the reported message said "her"). Both hold over three phrasings, with due_hint verbatim ("tomorrow at 3pm", "today", "thursday"), due_time set from the clock ("15:00", "14:30", "12:00" from the word "noon") and no date computed by the model. The deterministic half (given such a follow-up, capture writes a dated, timed, contact-less reminder) is pinned in general-tasks.test.ts. Anything that is not an answer expires the chat’s whole queue and is processed exactly as before — the guarantee that makes reviving this table safe: an open question can never block the chat, eat a capture, or survive into a second message. Expiring only the head would turn the chat into a quiz a person cannot leave. Expiry is MESSAGING_PENDING_QUESTION_WINDOW_MINUTES = 6 h, deliberately far longer than the 30-minute correction window and far shorter than the 24 h idle window, because it bounds a different risk from either: a correction can rename, move notes and hard-delete a contact off a parsed sentence, whereas an answer can only pick a candidate we ourselves rendered, with the card standing the whole time — so the bound is not about damage but about AMBIGUITY, and a bare “1” the next morning must read as a note. Nothing is ever DELETED from the table: “clear”, “answered” and “timed out” are all expires_at = now(), so one mechanism covers all three, the row stays readable afterwards, and the path is additive-only. The row’s id IS its confirmation’s id, which is what makes re-raising idempotent against the stalled-batch sweep (onConflictDoNothing). Copy lives in utils/constants/messaging/question-replies.ts as pure builders and never echoes the note body; the two replies after an answer are imported from correction/replies rather than re-written, since both paths end at the same applier on the same card. Tests: messaging-cases/chat-questions.test.ts (11 cases — card + question raised together, answered by number and by name, an ambiguous/unknown reply cleared and captured, DONE and /start still winning, the second question asked only after the first is answered, an expired question unanswerable, and a card answered in the app leaving no stale row to mis-resolve). Not verified against a real WhatsApp or Telegram message — every case is mocked, as the rest of this surface is.
  • A question asked as a VOICE NOTE is answered, not filed as a note about nobody (2026-09-08, lib/messaging/process-session/spoken-questions/) — the intent router above reads text, on receipt. An audio message is media, so it skipped that branch entirely: buffered untranscribed, transcribed only at flush time, then handed straight to the capture planner. "Who do I know at Northwind?" spoken was therefore filed as a note about nobody — you had to reply DONE to get anything at all, and what came back was "1 note needs your input", asking you who your own question was about. Typed, the identical sentence was answered in seconds. One product, two behaviours, and the only difference was which button you held down. The receipt path is still right to skip the classifier and is unchanged: media is refused there because audio is an opaque blob at that moment — classifying it buys a verdict on something nobody has read — and because the common case is a forwarded card, the exact flow the batch planner exists to make cheap. That argument is about the blob, not the words inside it: once the batch has transcribed the audio it is text, the transcription is already bought, and the classifier is the same tens-of-tokens call the text branch makes every turn. No new grammar property, so the 32-property compiled-grammar ceiling is untouched — this reuses the existing message-intent schema, which has never been part of the batch plan for exactly that reason. One classification per transcribed voice note, never per media item received: only audio is offered (a photo's derived text is what vision read off a card, a vCard's is a rendering of its fields — neither can carry a question); text in the batch is never re-classified, since it already got a verdict on receipt and is in the batch because that verdict was capture; and an item the batch resolved cannot pay twice, because the verdict is stamped processed and the stall sweeper reads unprocessed items only. The inference-dollar ceiling is honoured exactly as the text path honours it. Ten voice notes is ten classifications, accepted: the batch has already paid for ten transcriptions — billed per audio minute, which dwarfs a 500-token call — plus a batch plan, and the count is bounded by the per-batch item cap. A batch is not a single message. The text path answers and returns without touching a batch; here the question arrives mid-flush with other people's notes in flight. The question is pulled out of the batch, so the planner never sees it and it cannot also land on somebody's timeline as a sentence they never said; the answer is sent as its own message the moment it exists, before the closing summary. That order is load-bearing twice: everything after this step can fail, and a failure would swallow an answer already paid for; and the summary must stay the last word on the batch, because the one message that may follow it is the open "who is this about?" question, which your next reply is matched against. The summary names it beside the per-person lines, because you count the messages you forwarded and a batch of three reporting two outcomes reads as a message that vanished; a batch of nothing but spoken questions skips planning entirely and closes on that line instead of "I couldn't read anything in that batch", which would be a lie about a message that was read, understood and answered. Nothing is discarded, and this path is strictly safer than the typed one. Only a question verdict that produced a real answer diverts — a degraded notice (no AI configured, a spent credit cap, an empty graph), a thrown call, or a send that failed all return the item to the batch, where it becomes a note exactly as it does today. That is what makes the change unable to regress anything: a batch that cannot answer does precisely what it did before. And the transcript is already stored on the message before any of this runs, so your words survive in the capture log whichever way the verdict falls — a typed question is stored nowhere, a spoken one always is. A new "Answered in chat" verdict in the capture log, and no schema migration: it is deliberately not "Couldn't read" or "Not accounted for", both of which say something went wrong, and this is the message doing exactly what you wanted. A spoken reminder or snooze is deliberately NOT diverted, and that is a different gap rather than this one left half-closed: the text path diverts those for latency, but by the time this runs the batch has already closed, so the delay the diversion prevents has happened and queuing "in 16 minutes" here would fire an alarm you set up to a day ago — meanwhile the planner models a self-directed reminder perfectly well and keeps it as a general task, a real outcome unlike the "needs your input" note a spoken question became. Only the sub-hour fuse is missed, and this is not the layer that could honour it. Not verified against a real WhatsApp or Telegram voice note — every case is mocked, as the rest of this surface is.
  • One inbound message is answered ONCE, however many times the provider delivers it (2026-09-08, lib/messaging/inbound/{index,branches}.ts, lib/repo/messaging/deliveries/, lib/db/ddl/core/messaging.ts, utils/constants/account-deletion/tables.ts) — reported from production: ONE Telegram question came back as three differently-worded long answers over ~90s. Nothing had gone wrong with the answer. The webhook returns 200 as the last statement inside the awaited invocation budget — up to ~65s (MESSAGING_INVOCATION_BUDGET_MS 90s minus MESSAGING_INVOCATION_RESERVE_MS 25s) — and Telegram's webhook patience is a small fraction of that, so it re-POSTs the same update while the first attempt is still running. Nothing deduped it: update_id is never read (TelegramUpdate does not declare it), and the per-message idempotency key that already existed — chatId:messageId, built in telegram-client/parse.ts under the comment "globally unique for idempotency" — had exactly ONE consumer, appendSessionItem on the capture path. A question short-circuits before any item row is written, so it never met that unique index; nor do a correction, a reminder or a snooze, which are precisely the branches that spend a model call. Each redelivery was therefore an independent generation, separately billed, and they differed because the first attempt had already appended its exchange to messaging_conversations.turns and the next read it back as prior conversation and paraphrased it — a bot visibly talking to itself. The claim is now taken at the door, above every branch, as one insert … on conflict do update … where against a new messaging_inbound_deliveries table — a ROUTING table like messaging_identities (written before any tenant scope exists, so it declares its own user_id and stays out of EE's TENANT_TABLES), appended to the existing MESSAGING_DDL chunk rather than added as a new one. It sits just BELOW the identity resolution rather than at the very top, because the row has to name an owner for the account erasure to reach it — nothing cascades this table, and for Telegram the message id embeds the chat id — while an unlinked chat has no owner and its only outcome is the canned link prompt, which costs nothing to repeat. A claim is not permanent. An invocation killed mid-flight would otherwise leave the update claimed and permanently unanswerable — strictly worse than duplicate answers, since a routed question is stored NOWHERE and the sender would simply never hear back — so a claim that is still incomplete AND older than one whole invocation ceiling is re-claimable, in the same statement; a COMPLETED claim never is. The ledger is swept, not kept. One row per inbound message with no foreign key to anything would grow for ever, and only an account erasure would ever remove one — so the nightly job (api/jobs/daily, deliveryRetention, beside the chat-transcript sweep it is modelled on) reclaims everything past MESSAGING_DELIVERY_RETENTION_DAYS = 7 on a claimed_at index. Seven days is a wide margin rather than a balance: the claim is load-bearing for the 90s invocation ceiling and useful as an "already answered" marker only while a provider might still retry, which is minutes for Telegram and a day for Meta. It is a bound on GROWTH, not a retention promise about content — the row holds no message text — and it sweeps on claimed_at alone whether or not the claim completed, since an incomplete row that old is already re-claimable and a predicate reading completed_at would leave abandoned rows behind for ever. Unbatched, unlike the transcript sweep: that loop exists because it met an accumulated backlog on the day it was wired in, and this table and its sweep ship together. Ack-then-process was deliberately NOT part of this change — moving the 200 ahead of the work (an after() handoff) is the larger behavioural fix — and it landed straight afterwards, in the entry below, which also moved the DONE grace and says why the route's old comment about it was reasoning about the invocation rather than about the response. Tests: repo/messaging/deliveries/claim.test.ts (real PGlite — the second caller refused, a completed claim final even when aged, an abandoned one handed back only past the ceiling) and messaging-cases/redelivery.test.ts (one answer and one billed search for a doubled question, a genuinely new question still answered, a killed attempt retried, capture unchanged). Not verified against a real Telegram redelivery — every case is mocked, as the rest of this surface is.
  • The webhook answers the provider in milliseconds and finishes the delivery afterwards (2026-09-08, app/api/messaging/[provider]/webhook/route.ts, lib/messaging/inbound/index.ts, lib/messaging/invocation-budget.ts, utils/constants/messaging/config/limits.ts) — the 200 was the last statement inside the awaited invocation budget, so a routed question went unacknowledged for up to ~65s. That is the number the provider measures us by: Telegram re-POSTs the same update long before it, and while it waits for an ack it also holds back whatever else that sender writes. The entry above made those redeliveries harmless; it could not make them stop. The response now goes first and the delivery is finished in a background after(), on the same invocation. THE SPLIT IS AT THE CLAIM, NOT PER BRANCH. Only the identity lookup and claimInboundDelivery stay ahead of the 200, because the claim is the whole idempotency guarantee and taking it inside the background pass would re-open from the other end the race it closes — the ack on the wire, a retry already in flight landing on another instance, and no row yet for either of them to lose against. Taken at the door it is also stronger than it was: message three of a delivery used to go unclaimed until messages one and two had finished, which can be most of a minute, so a redelivery meanwhile claimed it a second time; now every message of the delivery is claimed before we say "ok". The door splits into claimInboundMessage and handleClaimedMessage, with handleInboundMessage kept as the two back to back — which is what every caller that is not the webhook wants. The DONE grace moved with everything else, and that is a decision rather than an oversight. The route's old comment justified the 90s maxDuration by the grace being spent before the response, but that argument was always about the INVOCATION and not about the response: 20s of grace plus the flush it hands off have to fit inside one ceiling either way, and the grace waits on items written by a different invocation, so nothing in it needs the response window. What forbids a per-branch split is ORDER. handleDone closes the batch that the messages ahead of it in the SAME POST were just appended to — a photo and the word "done" arrive in one delivery, and a photo CAPTIONED "done" is both at once — so running one branch before the response and the rest after would flush an empty batch and strand the note beside it, manufacturing exactly the reordering waitForLateArrivals exists to repair ACROSS deliveries. So the whole loop moves, in one piece, in arrival order. The clock still measures the invocation. maxDuration runs from the moment the request arrived and covers the after() pass, so a budget simply re-opened at the handoff would hand back the time the ack had already spent — and nothing downstream can notice, because invocationTimeLeftMs() is trusted absolutely by the agent loop and an over-reported clock does not degrade, it overruns. withInvocationBudget is therefore opened inside the background pass with the ack's own elapsed time deducted from MESSAGING_INVOCATION_BUDGET_MS, through the budgetMs option /api/search/ask already added. Letting the callback inherit an outer clock would also work — Next binds the ALS snapshot at the after() call site — and is deliberately not used, because a bound this load-bearing should not rest on a framework internal. What it costs, stated rather than discovered: an invocation that dies during the after() takes the message with it, since nothing re-delivers what we have already called "received". The claim already had that window — it is taken above every branch either way — but the provider used to notice and now does not; a captured item is still recovered by the idle sweep, a routed question is not and never was. Tests: messaging-cases/ack-then-process.test.ts, driven through the REAL route because no layer below it can see the ordering (the 200 landing with no branch having run and the ledger row already written, a redelivery mid-flight answered once, an empty DONE not holding the response open for its grace under fake timers, and a note plus DONE in one POST still closing a batch that contains the note), and messaging-webhook-clock.test.ts (the ack's elapsed time charged against the ceiling, and one clock shared across a delivery's messages rather than one each). Not verified against a real WhatsApp or Telegram delivery — every case is mocked, as the rest of this surface is.
  • An instruction the batch was given is CARRIED OUT, or named back — never dropped (2026-09-04, lib/messaging/process-session/apply/directives/, packages/core/src/schemas/batch-plan/note.ts, packages/core/src/llm/prompts/batch-plan.ts) — directive has been a real per-message verdict since the planner shipped, and it meant nothing: the sentence was cut out of the note by exact match and thrown away. A sender who forwarded "<name> name is <corrected name>" and then "Change the name please" got "✅ Added 1 note" back over a contact still carrying the old name. Two fixes, and the first is a definition. A directive is an instruction to Dhaga; an imperative the sender aimed at themselves is a note — "Need to schedule meeting with him" was being folded away as an instruction, taking with it the only path by which a follow-up is ever extracted from a forwarded message. The old wording keyed on the sentence being a command, which cannot separate the two, so the actor test is now stated in plannedNoteSchema.directives, unclearNoteSchema.directives, the planner prompt, and quick-add's own isInstruction (which had the same loose definition). Descriptions are prose to the model and cost the compiled grammar nothing, which is what makes this reachable at all while the plan sits at 32 of 32 (schemas/batch-plan/budget.test.ts). The second is execution, through the machinery that already exists. A name correction is routed into lib/messaging/correction/ — the model proposes two strings, CODE resolves them against the graph by exact name or nickname, the blast radius is this batch's own work, and merge stays gated on created because it deletes a row. Everything else is reported, not executed: contact creation is already the planner's job, and a second creator acting on one sentence is precisely how a duplicate contact appears; a relationship needs a second person nobody resolved; and a note's text is the sender's own words, which nothing here rewrites. Every directive now ends executed-and-reported or reported-and-not-executed, quoted back so the sender can finish it in the app (unactionedDirectivesLine).
  • Note source colours (utils/constants/note-accents.ts, 2026-08-23) — a 3px coloured left spine on the note card plus a source chip tinted to match, so a timeline can be scanned by channel. Not a full background wash (five tinted cards fight "one accent, generous whitespace, no visual noise") and not chip-only (you could not scan by colour, which is the point). Every colour is an existing brand accent pair: group note → magic, WhatsApp → calm, Telegram → trust, extension → human, and web/mobile/unknown → no spine and no chip, because "written in Dhaga itself" is the default a colour would only add noise to. Spine and tint use the fill half, chip text uses the -ink half, which aliases its base in dark, so every class here is correct in both themes and must never grow a dark: variant. Amber is reserved for primary actions and is not a source colour. A group-owned note's group colour wins over its channel — which meeting it belongs to is the more salient fact, and the channel is still spelled out in words. Colour is never the only signal: the chip always carries its text label, and on a person's page it is NAMED with the group (NoteMeta, via groupNamesByIds, a read that filters on neither left_at nor archived_at — a note from a group the contact has since left is still on their page, so the moment the name matters most is the one a membership read cannot answer). source (channel) drives colour; kind (text/voice/photo) stays an icon — a voice note can arrive from Telegram. The map is exhaustive over NoteSource, so adding a channel without deciding its colour is a compile error. Search receipts were changed in the same pass: the keyword UNION now projects notes.source as note_source and it rides to the gateway as SearchIndexResult.matchSources, an optional array index-aligned with matches appended only by pushMatch so the two cannot drift; the unaccented rule became bg-line rather than bg-amber/60, since amber as the DEFAULT made "we know nothing about where this came from" the loudest row in the list. One limit, stated: the group accent never appears on a search receipt at all — the UNION projects notes.source and not notes.group_id, and every one of its branches scores a contact, so a group-owned note is not in the result set to be coloured (§8 has the detail). Mobile has nothing to port and the premise was checked — apps/mobile has no note list and no endpoint that returns notes, and every note it creates is stamped source: "mobile", which this table maps to no colour anyway
  • Background-job UX for extraction & enrichment (2026-07-30) — built, not browser-verified. Start copy now states that the work continues if you leave the page (verified true — the worker writes server-side regardless of who is watching), and a finished run shows a self-clearing confirmation with real counts ("Extraction finished — 4 facts and 1 follow-up added.", useExtractionStream/done-message.ts) plus a non-blocking toast in the tab that ran the worker. Fixes in the same change: a sticky "extracting facts…" notice that never cleared without a reload; three spinner paths that could never settle; and a bug where controller.enqueue throwing after the user navigated away marked an already-successful job FAILED. Covered by useExtractionStream/{settle,live-state}.test.ts; manual click-through pending (docs/TESTING.md §7z)
  • Voice notes on mobile (whisper.cpp / Apple Speech — mobile milestone) — built via expo-speech-recognition (wraps iOS SFSpeechRecognizer/Android SpeechRecognizer, forced on-device, no audio/transcript leaves the phone); tap mic → live interim transcript fills the same text-review box typed input uses (e64b336). Typecheck/lint pass; package has no published SDK-57 tag yet (installed cleanly, types check, but native linking in an EAS/dev-client build is unverified) — needs a real device build before this can be checked off, not pushed
  • The same note, now failing slower — the extraction drain claimed work a 60-second invocation could never finish (2026-09-10, api/jobs/extraction/run/route.ts, api/jobs/tick/route.ts, lib/jobs/extraction-backlog/drain.ts, utils/constants/jobs-tick.ts, packages/core/src/llm/anthropic-client/index.ts) — the follow-up to the entry above, and it landed exactly where that entry's own "residual risk, stated rather than hidden" paragraph said it would. With the output ceiling raised there were no more StructuredOutputErrors; the re-run failed a NEW way instead. Measured, not theorised: the quarter-hourly backlog drain claims its job inside GET /api/jobs/tick, where it runs THIRD behind the daily brief and the nudge sweep and therefore inherits only the leftovers of that route's own 60s. Production, 2026-09-10T11:00:37Z: Vercel Runtime Timeout Error: Task timed out after 60 seconds, the invocation killed mid-LLM-call, ZERO ai_actions rows — so the model call never returned and nothing was billed — and the extraction_jobs row left status='running', stage='extracting' for 13.5 minutes until the reaper stamped "Timed out — retry." on it. A fast wrong failure had become a slow one, and the note still held 0 facts and 0 follow-ups. Three fixes, because the defect had three layers. (1) The ceilings were obsolete numbers, not platform limits. Both api/jobs/extraction/run and api/jobs/tick carried maxDuration = 60; the worker route's comment justified it as "a Sonnet web search + Haiku extraction on Vercel Hobby" — but both tiers have been Sonnet 5 since 2026-09-04 and Vercel's default function timeout is 300s on all plans. Both routes now carry 300, and at a quarter-hourly cadence the tick still cannot overlap itself (300s inside a 900s interval). (2) The structural fix — a drain that cannot finish must not CLAIM. Claiming is a guarded pending -> running UPDATE that also increments attempts, and claimExtractionJob only ever admits pending, so a killed invocation orphans the row with nothing able to reclaim it. The drain now asks invocationTimeLeftMs() for EXTRACTION_BACKLOG_JOB_BUDGET_MS before every job — re-asked per job, because the loop is what spends the clock — and leaves the rest pending for the next tick, which is strictly better in every direction: visible to the very next sweep, attempt count intact. The tick opens the budget itself (TICK_INVOCATION_BUDGET_MS / TICK_INVOCATION_RESERVE_MS, the reserve protecting the messaging flush that runs after it), following the rule lib/messaging/invocation-budget.ts states — the outermost caller owns the numbers. A per-run cap counts JOBS and says nothing about how long one takes, which is why EXTRACTION_BACKLOG_RUN_CAP alone was never a budget. (3) The SDK had no bound at all. The Anthropic client was new Anthropic({ apiKey }) — no timeout, no maxRetries — i.e. ten minutes with two retries, and timeouts are themselves retryable: thirty minutes of permitted wall clock against a 300s ceiling. That is why the call could never fail on its own terms; the platform always killed the invocation first, leaving nothing to classify and nothing to log. Now 120s with one retry, so timeout x (maxRetries + 1) = 240s fits inside 300s with room for the graph write and the notification that follow. (Verified against the installed SDK rather than recalled: the timer is armed around the inner fetch and cleared when response headers arrive, so it bounds generation on a non-streaming messages.parse() and does not throttle streamComplete deltas or getBatchResults JSONL.) Fail-loud: the drain reports a skipped count and warns on it, because if the jobs ahead of it ever grow slow enough that it never gets its clock it would stop draining entirely — and an empty backlog reports the same zeros. Considered and deliberately NOT done: lowering output_config.effort on the extraction call (it runs at the API default high). It would cut both latency and the thinking tokens that share the output ceiling, but this is a WRITE path — facts, edges, follow-ups and tags — and the house rule in utils/constants/agent/effort.ts already reserves the provider default for exactly that, dropping to medium only on the read-only agent loop. With no eval harness on extraction output, a quality regression here would be invisible and unattributable, and the three fixes above remove the failure without touching output quality. Still true (Rule 12): nothing can cancel an Anthropic call in flight, so a job admitted with enough clock that then runs long can still be cut off — the guard removes the guaranteed orphan, not the risked one; and the durable fix remains a worker whose clock is not an HTTP function at all.
  • The follow-ups both extraction PRs deferred — the same bug in three more places, and a pill that argued with itself (2026-09-16, lib/ai/note-extraction/group.ts, lib/ai/note-extraction/company.ts, lib/jobs/extraction/process/failure.ts, components/app/extraction/ExtractionStatus/, utils/constants/extraction-jobs.ts, packages/core/src/llm/openai-client.ts) — the two entries above each shipped a fix and wrote down what they had knowingly left behind. This is that list, cleared. (1) The ceiling was stated in one call site out of four. note-extraction/index.ts got NOTE_EXTRACTION_MAX_OUTPUT_TOKENS; group.ts (a meeting group's shared note) and company.ts (a company note, and the extraction pass inside company enrichment) were left on the gateway's bare 2048 — the identical latent bug, waiting for a note big enough. They now take the same constant, deliberately and not a larger one of their own: a shared note is a bigger ask (every fact, relationship, follow-up and tag, attributed across N people) but 16384 is already the house ceiling for the largest structured output the product generates, and the binding limit above it is the non-streaming call shape, not this call's appetite. The enrichment mode is the one that most needed it — its input is a findings note assembled from public-web results, so its length is bounded by nobody's patience. (2) The stall clock was measuring the old world. EXTRACTION_STALLED_AFTER_MS was 90s, chosen when an extraction could not outlive a 60s invocation. Against the new bounds — 240s of permitted SDK wall clock inside a 300s route — a healthy long-running job looked stalled for most of its life, and the UI invited the user to retry work that was actively running. Now 5 minutes, so the ordering reads 240s < 300s <= stall < reap (15 min). The docstring on EXTRACTION_BACKLOG_MIN_AGE_MS justified its own value as "2x EXTRACTION_STALLED_AFTER_MS" — arithmetic about a number that has moved, so it was rewritten rather than left asserting a relationship that no longer holds. (3) The truncation pill contradicted itself in the same breath. output_truncated says "try splitting it into shorter notes" — and a Retry button sat beside it, which re-runs the identical note at the identical tier under the identical ceiling and spends a real model call to reach the identical end. Truncation now keeps the notice and loses the button; blocked is the precedent for a terminal state that shows a notice and no control, and the way back is the same as that one's — change something, then re-run from the note itself. (4) A provider blip was being reported as our bug. errorCode read only the top-level .code, but the Anthropic SDK wraps socket failures as APIConnectionError with the real ECONNRESET hung off .cause — so every genuine transient landed in unknown ("Something went wrong on our side") instead of provider_busy ("Retry in a moment"). It now follows .cause one hop, bounded, so a wrapped transient reaches the label that tells the truth about who fixes it. (5) The OpenAI client had the defect Anthropic's had just lost — constructed with no timeout and no maxRetries, so the same unbounded wall clock that let a killed invocation leave nothing to classify. Same treatment, verified against the installed SDK rather than recalled. (6) Stale comments swept — utils/constants/batch-plan.ts still described the messaging flush route as maxDuration = 60 when it had been 300, and it was not the only one. Stale docs are a bug (Rule 13). Explicitly NOT done: extraction-jobs.ts is well past the 150-line file rule and was not split — a mechanical file split folded into behavioural fixes makes both harder to review, and it wants its own change. output_config.effort stays at the provider default for the same write-path reason the entry above records. And the retryability decision keys on the failure label text, because extraction_jobs has no structured failure-kind column — a pragmatic match that would need a migration to do properly, and which will break silently if that copy is ever reworded.
  • A note that was read perfectly, extracted as nothing — note extraction was running out of output tokens (2026-09-10, lib/ai/note-extraction/index.ts, utils/constants/note-extraction.ts, utils/constants/extraction-jobs.ts, lib/jobs/extraction/process/failure.ts) — the same defect as the batch-plan one in §14, on the other structured-output call, stacked with a copy defect that hid it. One note failed extraction twice — extraction_jobs.attempts = 2, the same note both times, because a retry only flips the row back to pending and a deterministic cause always reproduces — and both attempts logged StructuredOutputError with code: 'max_tokens': the generation was cut off AT the ceiling, the constrained-decoding grammar never closed the object, parsed_output came back null and runExtract threw. extractAndApplyNote passed no maxTokens, so a call that asks for four lists at once — every fact, every relationship, every follow-up and every tag — had to fit inside sync.ts's 2048 default, shared with the extract tier's adaptive thinking (claude-sonnet-5, no thinking field, so it runs by default and its tokens count as output). The ceiling is now NOTE_EXTRACTION_MAX_OUTPUT_TOKENS = 16384 — deliberately the same house number as the batch plan, for the same reason: Anthropic's guidance for a non-streaming request, well under the 128K the model allows, and materially higher would need .stream(), which messages.parse() is not. A ceiling is a cap, not a target, so an ordinary two-line note costs the same seconds at 16384 as at 2048; the only runs whose wall-clock changes are the ones that would otherwise have been truncated. The copy half, and the reason this was invisible: StructuredOutputError carries the stop reason as code, extractionFailureMessage echoes a message only from a bare Error with no code, and extractionFailureKind had no truncation case — so the one fact that identified the failure was discarded one layer before the user and the pill read "Something went wrong on our side. Retry, and tell us if it keeps happening." Wrong twice over: nothing went wrong on our side, and the retry re-runs the same note under the same ceiling. It now classifies as output_truncated via the SHARED isTruncatedOutput predicate (no second detector) and says something the user can act on. Residual risk, stated rather than hidden (Rule 12): api/jobs/extraction/run has maxDuration = 60, so a generation that genuinely ran to 16384 would exceed the invocation and be KILLED, leaving the job row running with nothing to fail it until the reaper — worse than truncating, which now fails in seconds. Not mitigated here; the mitigation is to bound the call by what the invocation actually has left (invocationTimeLeftMs), and the durable fix is a worker whose clock is not a 60-second HTTP function. Scope, stated: the shared-note and company-note pipelines (lib/ai/note-extraction/group.ts, company.ts) have their OWN extract() call sites and are still on the bare 2048 — same bug, deliberately not fixed in this pass. Tests: the truncation test injected a hand-rolled Error whose shape no longer exists and was green over a dead code path; it now throws the real StructuredOutputError and asserts the new label, refusal is asserted NOT to borrow that label, and the ceiling is asserted to be actually passed, because a regression there is a silent deletion.

Voice STT + phonetic teaching (2026-07-24) — built, pending real verification

  • STT is a pluggable engine gateway — the AsrEngine contract lives in @dhaga/core/src/voice/asr/types; an engineer adds a future model by implementing it and wiring a useDictation branch. Only Dhaga Voice (Moonshine) ships now — the browser SpeechRecognition engine, on-device Whisper, real-time Whisper, and the Settings engine-picker (VoiceInputSetting / the SttEngine preference) were all removed. The gateway remains the single extension point for a future engine.
  • "Dhaga Voice" (Moonshine tiny) engine, web — the only web STT engine; on-device WebGPU streaming. WebGPU is required and there is no fallback (no browser / CPU / WASM degrade), so voice notes do not work on iOS Safari or most mobile browsers — a real limitation of the product, not just of a deployment. No longer the whole story (2026-08-26): a second, server-side engine now takes precedence where the deployment has speech-to-text configured, and it runs in any browser — everything from here to the end of this entry describes what happens only when it does not. The on-device engine is gated behind a confirmed WebGPU adapter (resolveDictationState, components/app/contact/dictation-gate.ts): while the probe is still running the Voice button is not-ready and its start is inert, and once the probe comes back false the mic renders greyed out up front with a "Coming soon" reason (DICTATION_NO_WEBGPU_COMING_SOON through ComingSoonNotice, on the note composer, quick-add paste capture and the search palette alike) — it used to look live and explain itself in a warning toast only after a tap, which the beta product rule forbids. So a mistimed tap can never race the model loader into onnxruntime's "no available backend", and no "Downloading model…" progress ever shows on an unsupported device (loadTiny also throws fast when WebGPU is absent). @huggingface/transformers dynamic-import-only. COOP/COEP were scoped to /app/** for threaded WASM and removed 2026-08-25: they made Razorpay's checkout iframe unframable on every soft navigation into Settings, and WebGPU — which the engine hard-requires — needs no cross-origin isolation, so only the rare same-device WASM fallback loses threading. (typecheck + prod build pass; pending real-browser mic/WebGPU verification)
  • Server-side transcription — WhatsApp/Telegram voice notes, and a second dictation engine (2026-08-26) — built, not end-to-end verified: no voice note has ever been sent through a real WhatsApp or Telegram message, and there is no browser pass on the dictation path. What was live-verified on 2026-08-26 is the provider contract itself, against a real key: ogg/opus is accepted, and the keywords hint measurably fixes proper nouns — so the user's taught vocabulary now reaches the engine, which Moonshine could never use. packages/core/src/transcription/index.ts self-seeds ONE built-in provider (OpenAI, gpt-transcribe) inside its accessor, the way llm/registry.ts does, so hasTranscription() is true the moment OPENAI_API_KEY is set — no registerTranscriptionProvider() call and no extra config, and the seeding is inside the accessor deliberately, because a side-effect registration in apps/web is not guaranteed to be bundled into the serverless webhook that needs it. TRANSCRIPTION_PROVIDER is now read (it was documented and read nowhere — docs/UNPLUMBED_FEATURES.md said so); an unknown id throws rather than falling back silently, and unset means first-configured-wins. OPENAI_TRANSCRIBE_MODEL overrides the model. Two consumers. (1) Inbound messaging — lib/messaging/normalize.ts accepts kind === "audio" when transcription is configured instead of refusing it, and process-session/derive.ts transcribes through the metered lib/ai/transcribe.ts rather than the gateway client directly, so a forwarded clip costs the sender a credit like every other AI action on that path. Unconfigured, the refusal reply and the "voice note skipped" notice are unchanged — a voice note is never silently dropped. (2) Browser dictation — lib/voice/openai/ posts the recording to POST /api/voice/transcribe (GET answers { available }), and components/app/contact/dictation-gate.ts checks the server probe FIRST: configured ⇒ the server engine, in any browser, which is what makes dictation work on iOS Safari and every other non-WebGPU browser; not configured ⇒ exactly the previous Moonshine behaviour, WebGPU probe and greyed-out "Coming soon" pill included. The two coming-soon strings are therefore still accurate — they are only reachable on the on-device path. Trade-offs the server engine carries: no live partial transcript (the text arrives on stop), and a single recording truncates at ~117 s (utils/constants/voice.ts, derived from the route's byte cap rather than picked). Metered as a new voice_transcription action at 1 credit, capped at 4 MB and 5 estimated minutes per clip (utils/constants/app/transcription.ts). Those caps used to be the ONLY thing bounding this feature's cost, because gpt-transcribe bills per audio minute and reports zero tokens, so the master dollar ceiling priced every clip at $0 however long it was — fixed since: ai_actions.audio_seconds carries the clip's estimated duration and lib/ai/cost prices it per started minute, so the caps are now the per-call half of a two-part bound and the ceiling sees the month. The seconds are the byte-size estimate that already decides admission, so one number governs both and it over-reads rather than under. The response also carries a real durationSeconds, which would be exact and still nothing consumes it. Known consequence, not yet decided: where the server engine is active it displaces the on-device one for everyone, so dictation stops being unmetered for a free account — 10 credits a month instead of unlimited. Covered by dictation-gate.test.ts, server-asr.test.ts, lib/voice/openai/wav.test.ts and lib/__tests__/voice-transcription-caps.test.ts; manual cases in docs/TESTING.md
  • Dhaga Voice Phase-2 UX — the transcript streams live into the capture textarea as you speak (was: appeared only on Stop); the tap-to-fix word-chip review sits directly under the textarea, bounded to the last dictated segment (was: at the bottom of the modal); a silence gate stops the model transcribing dead air (fixes a garbled-transcription / hallucination bug); bounded memory supports long (~15-min) recordings; the microphone lifecycle was hardened — always tears down, rebuilds, resumes, and verifies (fixes a stale-mic-feeding-silence bug). (pending real-browser mic/WebGPU verification)
  • Phonetic teaching (double-metaphone) — shared pure-TS core (@dhaga/core/src/voice/teaching), deep-import-only, Hermes-safe (never the core barrel); web dictation applies taught spellings on finalize; teaching/vocabulary manager in Settings (Settings restructured into tabs — Account/Capture/Calendar/Suggestions/Import). (pending real testing)
  • Mobile phonetic teaching (parity) — shared core wired into expo-speech-recognition dictation (correct() on the transcript, interim + final) + a vocab screen; JSON-file VocabStore. (pending device build)
  • voice_vocab table (synthetic id PK, mirrors settings; EE TENANT_TABLES adds RLS) + /api/voice/vocab (GET/POST/DELETE) + "use server" actions + export coverage; core path unaffected (no apps/web→@dhaga/ee import). LLM self-correction stays PARKED (§20 backlog — needs a GPU).
  • Search dictation append-vs-replace bug fixed (multi-segment finals accumulate).
  • Real-browser + device verification (mic capture, WebGPU streaming accuracy, teaching round-trip) and commit (currently uncommitted on main).

7. Knowledge graph v0 (M5)

  • Relationship edges written from extraction (works_at, knows, used_to_work_at, …)

  • Jobs & education written from extraction — an affiliation to a company (works_at, studied_at, interned_at, …) becomes a positions row carrying the note as its receipt, not a literal edge (the graph re-derives the affiliation edge from the row), and the contact's title/company denorm is recomputed. Strictly additive: an existing position for that employer — the user's or an earlier note's — is never rewritten or displaced, and a note's own rows are cleared on reprocess/delete by source_note_id alone. Built 2026-07-29 (web); mobile parity pending

  • Contact page: same-company + same-event + edge connections render

  • Graph browser page (React Flow: people/companies, typed edges, ring layout)

  • Directional graph edges + full affiliation history — past employers now render as their own solid "worked at" edges (every positions role ships from the graph payload, not just the denormalised current company) and education as "studied at"; edges are drawn directed, and hovering a node emphasises its outgoing edges (full amber, thickest, arrowhead — the subject side) over incoming ones (softer, thinner) so relationship direction reads at a glance. Built 2026-07-28 (web graph)

  • Edit an existing relationship — every relationship row on a person/entity page carries a pencil that reopens the same target picker + predicate picker + live direction preview, so a wrong endpoint, a mislabelled type or a backwards edge is corrected in one dialog instead of delete-and-re-add. The edit rewrites the existing edges row in place, so the edge keeps its id and its source_note_id receipt; the action revalidates the endpoints the edge LEFT as well as the ones it joins, and an edge deleted elsewhere reports "no longer exists" rather than a phantom save. Only the node whose page you're on is fixed. Same pass made the target typeahead's state legible (Searching… / Couldn't search + Retry / No matches, plus one automatic retry) after a silently-failed search read as "search is broken". Built 2026-08-06 (web); mobile parity pending

  • Tag ontology v0 (tags from extraction, filter chips on People)

  • Tags are first-class entities, with their own surface (2026-08-12) — a tag used to be a bare string in the contacts.tags jsonb array, so it could carry nothing but its own spelling, and there was no read path for contact_tag_sources at all. Now tags (id, name), contact_tags (composite PK, cascade both ways) and tag_aliases are the source of truth, contact_tag_sources gained a tag_id FK, and contacts.tags survives only as a derived cache with exactly ONE writer — refreshContactTagsCache (lib/repo/contacts/tags/cache.ts), the same denormalisation contract contacts.title/company_id already have over positions. Every previous jsonb writer (extraction, addTagToContacts, tagCluster, contact merge, dropTagFromContacts, pruneUnvouchedTags, moveTagSourcesToContact) now writes normalized and recomputes; a grep for a second writer of the column returns only cache.ts. The migration is EXPAND-ONLY — nothing dropped, contacts.tags and contact_tag_sources.tag both still exist, and the text column is written in lockstep with tag_id. Surface: /app/tags (server-paginated, one grouped aggregate for tag + people count, cross-page selection, bulk delete, merge) mirroring /app/companies; tag chips on a person link to /app/people?tags=… with an active-filter affordance and a clear; per-tag Also known as editor plus a global /app/tags/aliases. Merge folds the losing names in as aliases of the survivor, and findOrCreateTagIds resolves name → alias → create — that is the anti-treadmill point: without it the next extraction re-mints the tag the user just merged away and they merge the same pair forever (regression-tested through the real applyExtraction path, not a direct resolver call, so a writer that bypasses the resolver fails the test). Receipts survive a merge re-pointed rather than deleted, source_note_id intact, hand-pinned (NULL) rows included. The RLS wipe hazard and its guard: every new table is under RLS and the boot DDL connection sets no app.current_user_id, so the DDL-time backfill legitimately sees zero rows on a hosted install — leaving contacts.tags full and the normalized tables empty, where the first cache refresh would write [] and unrecoverably destroy that tenant's tags. ensureTenantTagsBackfilled(tx) re-runs the same shipped SQL constant inside the tenant's own scope, and the cache writer fails closed structurally: it demands a TagsBackfilled receipt whose class value is never exported, so a refresh that skips the backfill does not compile, with a runtime instanceof + same-transaction check behind it. The probe matches only "jsonb non-empty AND zero membership rows", so a user who genuinely removed their last tag is never mistaken for an un-backfilled tenant. EE adds tags/contact_tags/tag_aliases to TENANT_TABLES with RLS specs; all three are in the JSON export. Tests: lib/__tests__/tags-normalization/{backfill,merge,aliases,lifecycle}.test.ts — including the wipe regression proved to go red with the guard defeated, tenant-scoped backfill idempotency, and a cache-drift assertion run after every mutation. User guide: content/docs/guide/tags.mdx. (web only — mobile parity pending; typechecked, lint-clean and unit-tested, but not yet browser-verified)

  • Confirmations centre — unified "AI proposes, code disposes" inbox (/app/confirmations + navigation badge; the former Home tile was retired in the lifecycle rebuild). The nav row is hidden entirely when the inbox is empty (2026-08-14) — an empty inbox is not a destination, and the row was unconditional, so every user carried a permanent link to a page with nothing on it. countPendingConfirmations() was already resolved once per /app render and threaded to the nav for the badge, so the visibility rule (AppNav/link-state.ts, isNavLinkVisible) costs no extra query — deliberately not folded into getCachedAppConfig, whose per-user cache has no TTL and is busted only by settings mutations, which would leave a stale row and badge behind after the last confirmation was resolved generalizing edge-suggestions into typed confirmations (entity_link / subject_resolution "who is he?" / enrichment_match / supplement); ambiguous extraction defers to a confirmation, deterministic resolver applies on confirm; opt-in email digest on the daily cron A note that turns out to be about nobody (2026-08-10): a note_subject card whose note already carries reminders offers two more answers beside the person ones — Not about a person (the general tasks stay exactly as they are) and It’s about a company (they are re-pointed onto follow_ups.company_id). Neither writes a note, and the card says so in one line rather than letting the user assume otherwise: facts.contact_id/embeddings.contact_id are NOT NULL and a note hangs off a contact — never a company — so the honest outcome is that the reminders survive and get labelled while the note body is not persisted. Answering with a person re-points those pre-written tasks onto the contact with the note's receipt (attachTasksToContact), and the extraction that runs on the newly attached note then re-derives the very follow-ups the user already has — that is where the duplicate came from. Which copy survives was inverted (2026-09-03): dedupeNoteTasks used to collapse each action to ONE open row oldest-first, so the capture-time row won. That is backwards. At capture nobody knows who him is — that is the question the card is asking — so the pre-written row can only hold the unresolved sentence (check with him for his feedback), while the second pass is what resolves the pronoun, the name, the date and the time (Check with Ada sir for feedback on dhaga app). Worse, the collapse matched lower(btrim(action)) exactly, and two renderings of one sentence are essentially never byte-equal, so it fired essentially never: one production account accumulated twelve duplicate pairs the user dismissed by hand. dedupeNoteTasks is deleted; retireCaptureTasks (lib/actions/confirmations/retire-capture-tasks.ts) soft-dismisses the capture-time ids after extraction instead, on the person and group branches alike — the group one gained the most, since extraction can attribute a meeting's follow-up to the member who owes the work while the capture row could only be filed on the room. Deliberately NOT the worker's clearNoteDerivations: that would have to run BEFORE extracting, and a failed or empty one-shot inline extraction would then leave the user with nothing — so the retirement is gated on applied && followUpCount > 0, and the reminder the bot already promised survives a bad model response. Soft-dismissed rather than deleted, so restoreTask can undo a wrong call. The company branch is untouched on purpose: it runs no extraction at all, so there is no second rendering and retiring would dismiss the user's only reminder. Covered by lib/__tests__/confirmations-note-subject-{person,group}.test.ts. Dismiss soft-dismisses those tasks too (deleteTask, never a hard delete) and the button says so — dismissing used to leave an open task about nobody, from a note the user had just thrown away. And the connected calendar follows the answer (2026-08-20): those reminders reach Google/Outlook the moment the message lands, so the card is answered against an event that already exists. Dismissing schedules write-out for the dismissed ids — a non-open follow-up maps to NO event, which is how write-out deletes what it wrote, and nothing scheduled it, so the card left a phantom event the user could not delete from inside Dhaga. It's about a company schedules it too: attachTasksToCompany writes no note and no source_note_id, so the note-based re-sync can never fire for it and the event kept its pre-answer title. The ids come from the pending payload, read inside the same mutation scope BEFORE the row stops being pending (lib/actions/confirmations/general-tasks.ts); the person answer needs nothing new, because attachTasksToContact gives the SAME rows the note's receipt and openFollowUpIdsForNote then hands those same ids to a write-out keyed on calendar_event_links.follow_up_id — an edit of the existing event, never a second one. Covered by lib/__tests__/confirmations-note-subject-calendar.test.ts. Both extra answers are hidden when the note carries no reminders: with nothing to keep, the only honest answers are a person or Dismiss. Covered by lib/__tests__/confirmations-note-subject-tasks.test.ts. The Links card can no longer dead-end (2026-08-12): its create affordance was gated on the user owning at least one node type — entities.type_id is NOT NULL and nothing seeds a type — so a user who had never opened the type manager saw a search box over an empty set and Dismiss, with no way to create the thing the question was asking about. With an empty ontology the type picker becomes a name field, prefilled from the extractor's own entityTypeHint, and one answer mints the node type and the entity together (components/app/confirmations/EntityLinkCard/NewObjectTargets.tsx). The same card also lets the user correct the object's KIND: a non-person mention is searched across entities and companies, and + New company resolves it through findOrCreateCompany — alias-aware, so a merged-away name links the surviving company instead of forking the duplicate createCompany would mint. The target union (EdgeSuggestionTarget) gained companyId/newCompany, ConfirmationResult's edge dstType is now the endpoint union rather than string, and both resolveTarget and the action's revalidation switch are exhaustive on it, so a future endpoint kind is a compile error rather than a silently stale page. Covered by lib/__tests__/entity-edge-suggestions/{company-target,no-node-types}.test.ts.

  • Attribute filters on the Network view (2026-10-05, web only — the mobile app has no graph screen) — a Filters button on /app/graph opens a docked panel (a bottom sheet with a sticky "Show N people" bar below lg) over 15 facets, grouped People / Company / Activity / Source & details: tags, location, job title, sector, connection count, saved, company, company sector, last interacted, date added, follow-up (overdue / soon / later / none), event attended, relationship type, source, reachable by (email / phone / LinkedIn). AND across facets, OR within one; every option shows a live count computed with its own facet left out (so ticking one tag does not zero the next), and an option that would match nobody given the other facets is muted, not removed. Active filters are removable chips above the canvas plus "Clear all", ResetChip carries "N of M people · Clear", zero matches is a real empty state with "Clear filters" / "Edit filters", and the state lives in the URL (tag, loc, title, sector, conn, saved, company, csector, touched, added, due, event, rel, src, reach — via replaceState, so ?focus= is untouched; plain URL params rather than nuqs, which docs/LIBRARIES.md confines to one adapter per page). Non-matching people hide through the existing hidden-node set (computeHiddenNodes, one extra argument) and companies / events / tag hubs left with no visible person hide with them; tag filtering reads the attributes, so it works with the Tags layer off. /api/graph/full is unchanged (payload, ETag, IndexedDB key): the per-contact attributes ship from a separate GET /api/graph/filters, columnar and dictionary-encoded (~120 bytes per contact), fetched only when the panel opens or when the URL already carries filters (after first paint), version-keyed server cache + ETag/304, no-store, held in memory client-side. "Last interacted" is the contact page's lastTouchSql (never = nothing since capture, as Home's recentReasonSql), "date added" is contacts.created_at; connection count and relationship type are derived from the loaded graph, event attended from its attended edges. Built and verified: typecheck, lint, unit tests (matching, boundaries, columnar round-trip, hiding, URL codec), a PGlite test of the SQL, and a browser pass at 1280px and 375px in light and dark and every theme preset against a disposable 90-contact PGlite. Not verified: behaviour at 20k contacts (payload ~2.6 MB raw before gzip; match/count loops are O(contacts) per facet, unmeasured in a browser), the SQL on a real Postgres (PGlite only), an organization workspace, Safari / Firefox, a screen reader, and per-node fade (nodes hide instantly; only the camera eases). Not offered: "event group" (meeting groups are not a graph edge), and a contact's company is its denormalised primary one, not every positions role.

8. Natural-language search (M6)

  • Keyword + structured search (SQL ILIKE over contacts/notes/facts) — free path
  • Query understanding: LLM → structured filters + semantic residual (Ask AI stage 1)
  • Embeddings + pgvector similarity (bge-small via transformers.js — local, $0). Built and working wherever the embedder can run, but OFF on the hosted deployment (2026-08-07): the deploy env sets DHAGA_EMBEDDINGS=off because the local ONNX embedder is unsupported on Vercel serverless (same constraint §14's goal-curation entry records), so embeddingsEnabled() is false there and hosted search is keyword + trigram only. Self-host, Docker and local PGlite are unaffected. The search palette's Semantic similarity weight slider is greyed out with SEMANTIC_SEARCH_COMING_SOON in that state rather than pretending to weight an empty set — searchAction returns semanticEnabled: embeddingsEnabled() and WeightTuner wraps only that one row in ComingSoonNotice; it defaults to false until the first response, so the fail-closed direction is "greyed out", never "silently useless"
  • "Ask AI" answer over retrieved candidates with receipts (Sonnet, explicit click)
  • Acceptance: seeded test set — correct contact in top 3 (vitest, keyword path; semantic covered by the standalone E2E check)
  • Conversational questions rank their real answers (2026-08-10) — asking "who can I reach out to for investor relations" returned vendors and every contact named "… Research" while the contacts actually tagged investor never reached the model's top 10. Two scoring bugs, both fed by treating a question's grammar as search terms. (1) The fuzzy-name gate admits a row when any word scores word_similarity > 0.3 — "reach" vs "Research" is 0.33 — but the score then summed every word's raw similarity, including the ones that failed the gate, on a scale several times ts_rank's; weighted as identity that buried genuine matches at ~3:1. Only the margin above the threshold counts now, so a typo stays decisive when nothing else matches and negligible when something does. (2) queryWords kept English stopwords that to_tsquery discards, so they reached the two paths that don't consult the dictionary — %out% matched inside a "youtube.com" link and %for% inside another, each worth a flat trigram bonus bigger than any real rank. The shared tokenizer now drops Postgres' own english stoplist, so all three halves of keyword search agree on what a content word is. Verified against a real 768-contact graph: the three investor-tagged contacts move from unranked to positions 1–3, and the MIT, typo ("amchit") and exact-name cases are unchanged
  • Sources beyond the contact's own record (2026-08-10) — positions (every job and education entry, not just the primary role mirrored onto contacts.title), entities, contact nickname, graph edges (a contact is credited when the node at the far end matches — the branch that makes "who do I know who knows an investor" answerable at all, with the other end's employer folded in since employment is a denormalised column rather than an edge), and notes written on an edge-linked entity (attributed through the edge and labelled with the entity, never as the person's own note). New GENERATED tsvectors on positions/entities plus an expression index for nickname — additive DDL only, no column rebuild. Two new ranking weights (positions, relationships) join the tuning panel
  • Calendar events and standalone tasks are findable (2026-08-15) — every keyword source before this scored a CONTACT (lib/repo/search/index.ts said so outright), so two things a user owns could not be found at all: an event on Dhaga's own calendar, and a task with nobody on it. The follow-ups branch INNER JOINs contacts, so a company-only task or a general TODO — both shipped shapes, since contact_id and company_id are each nullable — was dropped rather than surfaced as itself. Two new branches of the SAME UNION ALL (keyword/combined/plan-branches.ts, never a second query — see the header on keyword/combined/index.ts for the pool exhaustion that rule exists to prevent): local_calendar_events matched on title/location/description (Apple's field set minus invitees, which we don't store) behind a new GENERATED tsvector + GIN index (ddl/search.ts CALENDAR_SEARCH_DDL, concatenated after CALENDAR_DDL because it ALTERs the table that one creates; nothing is needed in packages/ee — its RLS DDL only adds user_id, an index and a policy, and a generated column is derived from columns EE never touches, so the branch is tenant-scoped with no extra clause), and open follow_ups WHERE contact_id IS NULL, the exact complement of what the contact branch already covers. Both are new SearchKinds — calendarEvent (never event, which is the CRM events-attended table) and task — that carry their own label and day instead of an identity, so they skip identity resolution and are not gated by restrictTo (a set of contact ids). Both link to /app/plan?date=YYYY-MM-DD: searchHitHref gained an optional date so it stays the one place that decides a destination, and without the day the board would open on today and not show the thing the hit just claimed to have found. The day is read in UTC — exactly right for a day-only due_date (pinned to UTC midnight), and capable of naming the neighbouring day for a timed event within hours of midnight in a distant zone, which the keyword phase has no user timezone to fix. Scoped down deliberately: status = 'open' only, matching the contact branch, so a task does not appear or vanish depending on whether it has a person on it; and the two sources reuse the existing Events/Follow-ups weight sliders rather than adding a third pair that would ask the user to rank the same thing twice. The palette still renders one flat score-sorted list — no grouping, no selection model — so the cmdk item below is still not forced. Tests: lib/__tests__/search-full-text.test.ts
  • Stage 1 also plans alternate keywords (2026-08-10) — a question rarely uses the words the records do, so the same extract-tier call that extracts filters now returns up to 6 domain synonyms ("investor" → vc, venture capital, angel, funding), OR'd onto the literal question. No extra model call and no third concurrent index.search (which would want a third connection from a max-of-three tenant pool). The plan's tag filter also matches by prefix in either direction — it planned "investor" against a graph tagged "investor-connections" and matched nothing — still per-element, so it cannot rediscover the "ai" ⊂ "ret-ai-l" false positive
  • An acronym question finds the affiliated people, not look-alike surnames (2026-08-26) — "who do I know in MIT" answered with every surname beginning "Mit…" and missed the people actually tied to MIT. Two independent causes. (1) buildTsQuery prefix-matched every word (mit:*), which exists so a word someone is still TYPING matches live — but a submitted question has no half-written word, so the expansion only invents matches, and mit:* scores the lexeme mittal exactly as strongly as mit. Because contacts.name sits at setweight 'A' while notes/facts are unweighted (db/ddl/search.ts), that coincidence earned 10× a genuine prose mention before the identity weight (the highest bucket) multiplied it again — measured on the real graph, 13 of the 14 identity-branch hits for mit:* were surname false positives, all tied at rank 0.303964, against note evidence at 0.043–0.086. SearchQuery.matchMode gains "phrase", which Ask-Dhaga's two retrieval passes use: same ranking, prefix wildcard off, so a look-alike surname is not a match at all rather than a demoted one. Search-as-you-type keeps prefix matching, which is what it is for, and a test asserts that split so the fix cannot leak into the palette. This is the door PR #121 knowingly left open when it gated the word_similarity path at NAME_FUZZY_MIN_TOKEN_LEN — that gate still holds (word_similarity('mit', <surname>) = 0.75, and contributes nothing). (2) positions.search_tsv covers title/department/note and positions has no org-name column — the organisation is positions.company_id — so the institution someone studied at was reachable through no branch at all: the position matched nothing, the identity branch sees only their CURRENT employer, and a hand-entered position writes no edge for the relationships branch to walk. positionsBranch now joins the position's company (GIN probe on companies_search_tsv_idx, no schema change), excluding the contact's denormalised primary employer so a current employer keeps exactly the score it had and only the previously unreachable affiliations gain one
  • Ask Dhaga answers two question shapes by code (2026-09-24, lib/ai/search/code-answer/, lib/repo/search-members/, utils/constants/jev/ask-code-answer.ts, utils/constants/ask-answer.ts) — With JEV_API_KEY set, one Jev call (a 1s share shared with the read of the user's event and company names, which code shortlists) classifies the question. Two shapes are then answered from the database with no Claude call: the people linked to one event and/or company ("who did I meet at X", "who works at Y" — the primary company or any current role; never tags, since a tag list misses the untagged people Claude's answer finds), and "when did I last talk to" ONE person that code resolves by name (the newest note the user wrote, a shared meeting note they were linked to, or "I reached out", dated in the asker's zone, the note quoted). Chat gets plain text, the palette markdown plus receipts; a list caps at 15 with "+N more". Any other question, any answer under 0.8, a Jev failure or timeout, or no key runs the Claude plan + answer unchanged (no key: no extra read or call). A code answer is charged the same 2 search credits as a Claude answer — Jev's usage is that action's row. Live eval vs real Claude, two runs (lib/ai/search/__tests__/code-answer.eval.test.ts, 66 questions on a synthetic graph): code answered 21 each run and none scored below Claude; event lists 100/100 recall/precision vs Claude's 81/78 and 74/81, company lists 100/100 vs 100/100, "when did I last" right 5 of 5 vs Claude's 0 of 5 (Claude's retrieval never showed it the dated notes). Latency caveat: Jev answers in ~350ms on a warm connection but ~1.2s cold, and in the eval (every Jev call cold, after two Claude calls) all 21 missed the 1s share, so the product would have run Claude for them — never worse, but no faster; production's hit rate is unmeasured. Evidence swap — built, OFF (lib/ai/search/answer/evidence.ts, ASK_EVIDENCE_ENABLED = false): showing Claude the full, dated note/fact text instead of search highlights scored 87/66 then 84/64 vs the highlights' 86/64 then 86/67, with 6 and 13 questions below the highlights (3 in both runs), so the answer still reads the highlights. Agent mode (DHAGA_ASK_AGENT) is untouched. Not browser-verified.
  • The message router settles more verdicts without Claude, and stops dropping notes and slow verdicts (2026-09-24, lib/ai/message-intent/, utils/constants/jev/message-intent*.ts, utils/constants/messaging/config/intent.ts) — In the SAME Jev call as the intent: a reminder whose one duration code reads (the subject cut by code), a list_add naming a list the user does not have yet (Jev picks a span code found), and a pure one-clause question (guards: p(also a note) ≤ 0.2, p(is a question) ≥ 0.9, one clause; the question is the message's own trailing slice) — every field copied or code-read, never model-written. Live eval vs real Claude, two runs of 88 synthetic messages (message-intent/question.live.test.ts): questions settled 26 / 25 with 0 misroutes vs Claude and vs label; reminders + lists 14 each with 0 misroutes vs label and every field equal to Claude's (one run Claude itself called "can you remind me in 10 mins?" a snooze). Correction gate — built, OFF (MESSAGE_INTENT_JEV_CORRECTION_ENABLED = false): on "why did you save it under Tom? it was about Tim" Jev said different_person and Claude same_person in both runs, and that kind decides rename vs move. Bug fixes, on: a question that also carries a note is now captured AND answered (alsoNote; before, Claude routed 8-9 such messages question and the note was never stored); the router's Claude call gets 6s (was 4s) and 1024 output tokens (was 500), and a timeout is logged — on the eval Claude's router latency was p50 ~2.2s, p90 ~4.1s, 9 of 88 over 4s each run. Residual: 2-3 of 88 calls still ran past 6s (and one hit the 1024 cap at 14.6s) and fall back to capture. Not browser-verified.
  • Group-level facts are invisible to semantic search and Ask-Dhaga (2026-08-23) — a fact extracted from a shared note with no resolvable about is owned by the group (facts.group_id), and upsertEmbedding asserts a single owner while embeddings carries only contact_id / company_id, so that fact has nowhere to hang a vector and none is written. It is fully readable on the group page and keeps its source_note_id receipt, but it will not come back from a semantic query until embeddings gains a group owner — a schema change plus an EE tenancy rule, deliberately not taken in v1. Do not work around it by attributing the vector to an arbitrary member: that puts the meeting's words on one person's record, which is the exact rule the attribution path (§6) exists to enforce. Keyword search does not reach them either, and this is wider than the spec states — every branch of the keyword UNION scores a CONTACT, so the notes branch is JOIN contacts c ON c.id = n.contact_id … WHERE n.contact_id IS NOT NULL and the facts branch joins the same way. A group-owned note has a NULL contact_id (its owner is group_id, enforced by assertSingleOwner), so a shared note's body and a group-level fact are absent from keyword search as well, not only from the semantic path. They are readable on the group page and reachable from a member's timeline, and nothing is lost — but a user who searches for words they wrote in a shared note will not find it. The shape of the fix is the one /app/plan's events and contact-less tasks already took: a new branch of the SAME UNION ALL (never a second query) carrying its own SearchKind and its own label, since a group is not an identity the palette can resolve. This also means the group accent can never appear on a search receipt — the honest version of the spec's note that "a shared note reads as its channel in search"
  • Rebuild SearchPalette's keyboard/list layer on cmdk if the palette next grows modes or item types (docs/LIBRARIES.md §8) — current custom modes (search vs metered Ask, dictation, weight tuner) don't map 1:1, so no forced swap

9. AI follow-up drafts (M7)

  • One-tap draft using notes + event context + facts (Sonnet, cache-friendly prompt)
  • Draft references ≥1 note-derived fact (acceptance — verify with a live API key)
  • Edit + copy-to-clipboard flow

10. Metering, cost control (BRD §8.2–8.3)

  • Every AI call logged to ai_actions (feature, model, tokens in/out) — metered per user-visible ACTION, not per call (2026-07-30): a card scan is one row whose tokens are the sum of its field-extraction and transcription calls, and enrichment folds its web search + extraction pass into one. withAiAction (lib/ai/metering/action-scope.ts) is an AsyncLocalStorage scope that nested AI calls join instead of double-billing; no schema change, and pre-existing rows still count as one action each
  • Free-tier cap enforced (10 credits/month since 2026-07-31, was 0 — PLAN_AI_CREDITS_PER_MONTH.free, which FREE_TIER_AI_CREDITS_PER_MONTH now derives from so the free tier has one number; an admin re-sizes it at /app/admin/ai-credits like any other plan, and DHAGA_AI_MONTHLY_CAP seeds it for an instance that has set nothing in the DB) with clear UI message. Free is a real taster rather than zero: 10 credits = 10 card scans, or 5 scans + 5 notes, or 5 Ask-Dhaga questions — deep research (20) never fits in a free month, and enrichment + pre-meeting briefs stay feature-gated to paid plans regardless of credits (PLAN_FEATURES). The cap is now denominated in AI credits, priced per action from real measured cost (BRD §8.3, packages/core/src/metering/credits.ts): 1 credit = a card scan, note, quick add or draft; 2 = Ask Dhaga or a brief; 20 = deep research; 0 = a nightly watchlist scan (throttled by the watch limit instead)
  • User-facing credits page (/app/settings → Credits, 2026-07-31) — the answer to "where did my credits go?", built as a Settings tab rather than its own route because it sits next to Billing in the IA and because the onboarding tour already has a settings leg to hang it on. Three cards: allowance (remaining of total, used-bar, reset date, and — only when something was added — a base / grant / promotion split, so a bigger-than-expected number is explainable rather than mysterious); breakdown by action kind for the current month, whose rows sum to the total by construction (the total is derived from the same per-feature aggregate, and ai-credits-overview.test.ts pins it against aiCreditsUsedThisMonth() — the figure the cap is enforced against); and recent activity, now keyset-paginated (2026-08-01) rather than capped: the first AI_ACTIVITY_LIMIT (20) rows render server-side, and a Load more button pages through the rest of the account's full history via getAiCreditActivityPageAction → listAiCreditActivityPage (lib/repo/ai-usage/activity.ts), a (created_at, id) keyset cursor rather than OFFSET so it stays fast as ai_actions grows without bound (it's append-only and never pruned). Zero-credit watchlist scans are listed and marked Free, never hidden — a month of nightly scanning that vanished from the ledger reads as a bug. Nothing user-facing renders a feature id or a token count (AI_ACTION_LABELS, utils/constants/ai-credits.ts); unlimited plans read "Unlimited" instead of dividing by a zero cap. One aggregate read on the request-scoped connection (lib/repo/ai-usage/overview.ts), no getDb() fan-out. Added to the driver.js walkthrough, and since the tour was rebuilt to start on Settings (§ onboarding walkthrough) it is its second step — a new user meets the price here rather than as a cap message later. Guide: AI credits
  • AI controls grey out before the click when credits are gone (2026-07-31) — a UX layer over assertAiBudget, never a replacement: aiGateReason (lib/ai/gate.ts, React-cache()-memoized so one request resolves it once instead of every server component fanning out its own getDb()) returns a reason or null, and AiGateNotice renders it beside the disabled control in the calm amber pill — never red, because running out of credits is a plan state, not an error. Greyed: Extract contact, card scan, Ask Dhaga (palette Ask tab + search-tab bridge), Brief me, Draft follow-up, Enrich from public web. Deliberately not greyed: manual capture and CRUD, hand-added facts/follow-ups, typed + photo notes (the note still saves; extraction degrades to the existing "…so facts weren't extracted" notice), note re-process, confirmation cards, extraction-job retry, keyword search, graph, import, export, on-device voice — greying those would break the free/no-AI product commitment. The predicate is zero credits left, not "can I afford this action": assertAiBudget refuses on used >= cap and never prices the action, so a price-aware gate would disable a button the server would happily run. !hasLLM() ⇒ null, leaving the existing "Configure an LLM provider…" degraded messages to own the no-key case. The two controls the client-only app shell mounts (nav quick-add, Ask palette) have no server parent to take a prop, so they read /api/ai/gate lazily (useAiGate). Since 2026-08-14 that route serves a second field alongside the reason — llmConfigured (hasLLM() evaluated server-side, a configuration boolean that never carries key material) — used only to pick the search palette's default tab (Ask when AI is usable, Search otherwise, so ⌘K never opens onto a disabled box) and never to grey a control; aiGateReason itself is unchanged and stays credit-only. 8 tests in lib/__tests__/ai-gate/; manual walkthrough in TESTING.md §7r-ii. Guide: AI credits
  • Monthly inference-DOLLAR ceiling — the master cost gate (2026-08-03) — a second ceiling, independent of credits, because credits stopped bounding spend the moment three metered features were priced at 0 credits on purpose (signal_detection, person_classification, goal_matching; folding them in at 1 credit each was rejected as ~26× mispriced, so an uncredited nightly sweep costs real money and moves no counter). Dollars are computed, not estimated: utils/constants/model-pricing.ts holds the only copy of provider rates (MODEL_RATES_PER_MTOK, BATCH_PRICE_MULTIPLIER = 0.5) and lib/ai/cost/ turns a recorded ai_actions row into USD — an unknown model is priced at the dearest known rate and a dated snapshot matches by longest known prefix, since under-reporting cost is the dangerous direction. Prompt caching is deliberately not modelled (see the next item — there is no discount to apply). Batch-ness is recorded, not inferred: ai_actions gained batch boolean NOT NULL DEFAULT false (lib/db/ddl/core/meta.ts, self-healing ADD COLUMN IF NOT EXISTS) because goal_matching runs both a nightly Batch pass and a synchronous goal-resolve path, so inferring from the feature would halve a real bill; pre-existing rows read false, over-stating an old batch action by 2× (the safe direction) and ageing out within a month. Precedence (lib/ai/metering/dollar-cap.ts), mirroring the credit ladder rung for rung: per-user ai_monthly_dollar_cap_override (0 is a valid override) → the subscription's monthly revenue × multiplier (default 3.0 since 2026-08-19, raised from 2.0 — the arithmetic is below; revenue, not a list price, and every plan is recurring, so each has a real monthly figure) → the floor ($0.50) for any plan with no recurring revenue, which is the case that breaks a percentage model on day one since 0 × any multiplier = $0 would refuse a free user the ten credits they are owed → no ceiling when no plan is in play (a self-host with no billing) or enforcement is off. No promotion rung, deliberately: a promotion raises what a user may do, not the bill we absorb. The revenue basis changed on 2026-08-19 (§18 carries the prices): the rung reads the SUBSCRIPTION, not the plan. monthlyPlanRevenueUsd({ plan, cadence, currency }) (utils/constants/ai-budget/plan-revenue.ts) resolves tier × cadence × the currency the PROCESSOR charges — never the one a visitor toggled on /pricing (introductory offer prices were included until 2026-10-06, when that price was removed), because the rule is now that the ceiling follows what a customer is paying at the moment. cadence: null (an admin comp, a referral grant, a plan whose processor we could not reach) falls back to the standing price via STANDING_MONTHLY_REVENUE_USD (pricing/prices.ts), which is now only that fallback and no longer the general basis: there is no charged price to read, and refusing them a ceiling would be a $0 refusal. PLAN_MONTHLY_REVENUE_USD survives but is narrowed to the representative standing-USD figure the admin per-plan table renders (that screen lists plans, not subscribers, so it has no cadence or currency to resolve against) — it is not what any subscriber's gate computes. An INR subscription is expressed in the dollars the ceiling is denominated in through the pinned INR_PER_USD = 87 + inrToUsd() (pricing/currency.ts): a constant and deliberately not an FX feed, because it sizes a loose backstop where a few percent of drift moves a ceiling by cents and can never change whether a real user is refused, while a live rate would put a network call — and an outage mode — behind every metered AI action. It must never be used to charge anyone. (Superseded 2026-10-06: the introductory rows below no longer exist; the thinnest charged row is now a standing one, and the multiplier is still 3.0. The arithmetic is kept as the 2026-08-19 record, not as current figures.) The multiplier moved 2.0 → 3.0 the same day, and the reason matters more than the number (DEFAULT_AI_DOLLAR_CAP_MULTIPLIER, ai-budget/dollar-ceiling.ts): tying the ceiling to the price paid pulls the thinnest CHARGED row down to Pro introductory yearly, ₹167/mo ≈ $1.92. The measured heavy-user month (BRD §8.3) is $2.84 of inference plus ~$0.40 of uncredited watchlist ≈ $3.24 — at 2.0 that is a $3.84 ceiling with that user at ~84% of it, so real paying customers would have started being refused mid-month by a gate they had never met, with nothing on screen explaining why (the pre-click grey-out is credit-only — see the known gap below). At 3.0 the ceiling is $5.76 and the same user sits at 56%: a backstop again. Resulting ceilings on Razorpay, the only live processor — Pro standing monthly ₹499 → $17.21, Pro standing yearly ₹400/mo → $13.79, Pro introductory monthly ₹199 → $6.86, Pro introductory yearly ₹167/mo → $5.76; Power standing monthly ₹899 → $31.00, Power standing yearly ₹750/mo → $25.86 (Power's four rows became two on 2026-08-24, when its standing price was cut to ₹899 / ₹8,999 — exactly what its introductory rows already held — so the standing and introductory pairs collapsed; the superseded standing rows were ₹1,499 → $51.69 and ₹1,200/mo → $41.38); legacy founding_yearly (₹6,999/yr = ₹583/mo) → $20.11; free → the $0.50 floor; no plan in play at all → no ceiling. Two caveats, to be repeated every time this arithmetic is quoted. (1) The ~$0.40/month watchlist figure is BRD §8.3-flagged ESTIMATED, never measured, and it UNDER-reports by ~$0.01 per contact per cycle: search writes a 0-credit signal_detection row carrying the search turn's tokens, but the $10/1k per-search charge has no column on ai_actions. (2) The thinnest row in the whole table is not the ₹167 one — it is USD introductory yearly at $1.67/mo, a $5.01 ceiling, 65% utilised by that same heavy month, and that is only safe because no USD processor is live; re-run this arithmetic before the first USD charge. The danger did not disappear when the invariant flipped, it changed shape: it used to be "an offer price must never reach the ceiling", and it is now "the multiplier must clear the measured heavy month on the CHEAPEST thing we sell" — which has to be re-checked every time a cheaper offer is added or an uncredited feature grows. Instance state reuses ai_budget_settings (dollar_cap_enforcement on by default, dollar_cap_multiplier, dollar_cap_floor — utils/constants/ai-budget/, a DIRECTORY since 2026-08-19: index.ts, allowances.ts, dollar-ceiling.ts, plan-revenue.ts. The import path @/utils/constants/ai-budget is unchanged because index.ts re-exports everything, so any doc still naming utils/constants/ai-budget.ts points at nothing); no new table and no new env var (there is no DHAGA_AI_MONTHLY_DOLLAR_CAP). Enforced inside the existing metering path (assertAiBudget + the hasMonthlyAiBudget pre-flight, lib/ai/metering/index.ts) so it covers every action including the three uncredited ones: credits are checked first, dollars second — the credit message is the one a user can act on (upgrade), the dollar gate is the operator's backstop — and the dollar check sits outside the hasUnlimitedAiCredits early return on purpose, since an unlimited-credit plan (one an admin has uncapped) is exactly the account nothing else bounds. Failure matches the credit cap: AiBudgetError with kind: "dollar_cap" (kinds are now "cap" | "dollar_cap" | "burst"), non-retryable at both sites that branch on kind. Known gap, stated rather than hidden: the pre-click grey-out (aiGateReason, lib/ai/gate.ts) is still credit-only, so an account stopped by the dollar ceiling learns at click time from the error rather than from a greyed control — acceptable because the dollar gate is an operator backstop sized to never touch a normal account, but it is not the same UX the credit cap gets. Tests: lib/__tests__/ai-cost.test.ts, ai-action-metering/dollar-cap.test.ts, ai-action-metering/dollar-gate.test.ts. Admin visibility in §19; rationale and the per-plan table in BRD §8.3
  • Prompt caching markers on stable system prompts — markers are in place (cachedSystem) but have never been observed to engage: measured 2026-07-30, zero cached tokens across 33 calls, because every Dhaga system prompt was far below the model minimum (Haiku 4.5 4,096 / Sonnet 5 1,024 tokens). Two things changed on 2026-09-04 and neither is a measurement, so the box stays unticked. (1) Every tier now resolves to Sonnet 5, whose minimum is a quarter of Haiku's, and several prompts already clear 1,024 — so the discount may now be firing where it never could before. (2) ai_actions gained cache_write_input_tokens / cache_read_input_tokens and recordAiAction writes them, so the next measurement can answer this from the table instead of from a one-off script. Until someone reads those columns back, no cost model should assume the discount — and it should not assume the 1.25× write premium away either (BRD §8.2)
  • Bill what we actually spend — cache tokens, audio minutes and web searches (2026-09-04, lib/ai/metering/record-ai-actions.ts, lib/db/ddl/ai-cost-accounting.ts, utils/constants/model-pricing.ts) — built and unit-tested; no reconciliation against a real Anthropic or OpenAI invoice has been done, which is the only thing that would tick this. Found by auditing every outbound paid call against what ai_actions actually records. Two real billing bugs in streamed Ask Dhaga, both in the same three lines. recordAiAction ran outside withAiAction(action, …), and AsyncLocalStorage does not survive a yield — so it fell through to a fresh randomUUID() and wrote a second ai_actions row. search is priced per action, so every streamed question charged 4 credits instead of 2, and the tokens were split across two rows so neither one priced the answer. Worse, that call sat after yield {type:"receipts"}: a generator suspended at a yield whose consumer has gone away is .return()-ed, so a reader closing the tab dropped the metering entirely — a full Sonnet answer we paid for and never recorded. Metering now runs ahead of every remaining yield. Anthropic web search was captured nowhere: it is billed $10/1k requests on top of tokens, enrich.ts sets webSearch: true and makes up to 3 searches, so ~$0.03 of the ~$0.0975 enrichment — by BRD §8.3 the app's dearest action — was invisible, and the nightly watchlist sweep already had usage.searches in hand and threw it away. Transcription was billed by the token, which OpenAI does not do for audio; the minute is the unit, and lib/ai/transcribe.ts now records duration so the dollar ceiling can see it. All three land in the same row as the action that caused them, so the inference-dollar ceiling (above) stops being blind to them
  • Every tier runs on Sonnet 5; Haiku is retired for now (2026-09-04, packages/core/src/llm/anthropic-client/shared.ts) — TIER_MODELS.extract moved claude-haiku-4-5 → claude-sonnet-5. The extract tier was never doing cheap mechanical parsing: it makes judgement calls on the user's own data, and Haiku was not good enough at them. Vision already knew this — card-scan/reads.ts has been pinned to reason since Haiku proved unable to read a card Sonnet read perfectly at the same 408px. The batch planner then failed the same way on a live capture: a voice note naming one of several similarly-spelled contacts was answered "already in your graph, and there was nothing new in that to add" while the call it described, its meeting, its action item and its budget were never written — zero notes, zero follow-ups, the text surviving only inside a parked confirmation. That class of failure is silent by construction; the user cannot see the note that was not saved, so there is nothing to report and nothing to retry. The tier split is kept, not collapsed — every call site still declares what kind of work it is, so restoring the split is one table edit and no call-site churn. Cost: ~1.6× the whole instance bill, not 3×, because Sonnet already carried 62% of it (Haiku was 85% of calls but 38% of cost); the measurement is docs/AI_COST_ANALYSIS.md, whose ~$5/month figure is the whole instance across 22 spending users, not a per-user number
  • A tier change is also a structured-output budget change (2026-09-05, packages/core/src/llm/anthropic-client/output-format.ts) — inbound capture was down from the 23:59 deploy on 2026-09-04 until this shipped. Every forwarded WhatsApp and Telegram batch failed with 400 invalid_request_error — "The compiled grammar is too large", reported as "I sent a voice note and got an error". Transcription was never involved (every audio item in the database has its transcript); the batch failed one step later, was recorded apply_failed (a mislabel — a plan failure was being filed as a save failure; fixed 2026-09-08, see below), and nothing was written. It is the reference FORM, not the size: Zod hoists any sub-schema used more than once into $defs and points at it with $ref — here the note shape, used under both a person and a meeting — and that form blows the grammar compiler. Measured against the live API: the same schema, same model, same property count, $defs+$ref refused and the identical schema with $refs inlined accepted. outputFormat() now resolves every $ref before sending, enforced by a source-level guard so nobody calls zodOutputFormat() directly; budget.test.ts holds the re-measured property ceiling (32 accepted, 33 refused on Sonnet 5). No tier was repointed and no schema field was given up. The lesson worth carrying: the grammar ceiling differs per model and is documented nowhere, so a schema that has worked for months can start failing on the day a tier's model changes, with no schema edit at all — after any such change, run the live tests, which call the real model, cost real money and skip without a key: packages/core/src/llm/prompts/batch-plan.live.test.ts for the plan schema, and — added 2026-09-07 — packages/core/src/schemas/agent-turn/grammar.live.test.ts, which sends the agentic loop's turn schema on the extract tier and asserts the call is served. budget.test.ts counts properties in a JSON artefact and cannot tell you the API agrees; the second outage happened with it passing throughout. A 400 on the turn schema is not one failed capture but every turn of every question, so it is the schema that most needed a live check
  • A failed capture batch says what failed, and keeps the proof (2026-09-08, lib/ai/batch-plan.ts, lib/messaging/process-session/fail.ts, lib/repo/messaging/sessions/audit/reply.ts, lib/db/ddl/core/messaging.ts) — found by asking why a 3½-minute voice note came back "Saving failed part-way" on 2026-09-07. It had not: the transcript was written, the plan call died, and the batch was labelled a save failure. planMessagingBatch classified exactly one failure — no_llm — so a provider 400/429/5xx, a timeout, or a response the schema refused escaped raw into the caller's catch-all and was stored apply_failed: "Saving failed part-way" in the capture log and "I read that batch but couldn't finish saving it" in chat, for a batch that never reached a write. That is also what the 2026-09-04 grammar outage was recorded as, which is why it then read identically to the unrelated save outage the day after it. Everything that is not an AiBudgetError now wraps as plan_failed, carrying the real error as cause (and, since 2026-09-09, an output the model could not finish writing splits off as plan_truncated); a spent budget still passes through untouched as over_budget, because "you're out of credits" is the user's own ceiling and a different action for them. The cause is now persisted (messaging_sessions.error_detail), because error alone says a batch broke and never what broke it, and the cause went only to logActionError — a console line the hosted deployment discards inside the hour, so the 2026-09-07 batch was already undiagnosable when it was looked at. PII-free by construction via errorSummary (packages/core/src/logging), shared with the per-item save_failed detail so the two renderings cannot drift: a class name, a driver code, an HTTP status, code locations, and a message only for TypeError/ReferenceError — never error.message, whose text quotes the input on exactly the classes that matter here. It follows cause one hop, since without that a wrapped provider failure would persist only the wrapper's name. The failure reply is stored in summary instead of null: that column is documented as the exact reply the sender was sent, and nulling it made the failed batch the one row the capture log could say nothing about — "what did it tell me?" answered for every batch except the ones with reason to ask. A reply that could not be DELIVERED is recorded too (reply_send_failed | …, recordReplyUndelivered) rather than caught and dropped: the verdict is committed before the send, so a bot that never answered stored identically to one that did, and the only person positioned to notice is the one who got no message. Tests: lib/__tests__/batch-plan-failure.test.ts (classification, cause, non-Error throws, budget passthrough), messaging-cases/batch-failure.test.ts (what the row keeps, and that the send failure never marks a landed batch failed or rethrows out of the background after()), packages/core/src/logging/index.test.ts (redaction + the one-hop rule)
  • A failed note extraction says what failed, and re-extraction is anchored to the note's own day (2026-09-08, lib/ai/note-extraction/outcome/failure.ts, lib/jobs/extraction/process/failure.ts, lib/ai/note-extraction/prep.ts, lib/repo/extraction-jobs/mutations/lifecycle.ts) — the entry above, one layer over. A note's fact extraction failed in production and left status='error', error='The AI call failed.', attempts=0, no ai_actions row and no log line anywhere, so the cause was unknowable — and Retry, which only flips the row back to pending (same note, same tier, same limits), reproduced it exactly, as a deterministic cause always will. mapExtractionError was a catch-all that kept only an AiBudgetError's message and discarded the error object, which left the classifier (extractionFailureKind, already separating provider_auth 401 from provider_busy 429/5xx/network) and the PII-safe logger unreachable in processExtractionJob's OUTER catch — that catch never ran, because the AI phase returned an outcome instead of letting the error through. Every non-budget error is now classified, logged (note-extraction: AI call failed, carrying reason, status, the note/job id and which of the four pipelines threw) and surfaced as a sentence naming who can fix it: an administrator for a rejected key, time for a busy provider, and our own thrown sentence where we wrote one. The two log lines share one builder, extractionFailureFields, so they cannot drift on what is safe to record; it adds detail — the sentence the user is shown — which is the ONLY place a message this app itself threw survives into a log, since errorFields redacts a bare Error's message and that is exactly the class carrying "Structured output did not match the expected schema (stop_reason: max_tokens)". attempts is now counted, on the CLAIM rather than the failure, so an invocation killed mid-call and later reaped counts too; the column had existed since the table did and stayed 0 through every retry. The max_tokens ceiling was NOT raised. Measured against noteExtractionSchema, a dense 1,076-character note yielding 8 facts / 4 relationships / 3 follow-ups / 4 tags with every nullable field populated serialises to ~3.4 KB ≈ 850–1,140 output tokens — under half the 2,048 default — so truncation could not be demonstrated for the failure that prompted this, and raising it would have been a guess. (Long enrichment findings through the same schema plausibly can exceed it; when they do, the message now says stop_reason: max_tokens instead of "The AI call failed.") Second bug, same commit: re-extraction dated relative phrases from the moment of the RE-RUN. prepareNoteExtraction now takes the note's created_at as writtenAt, which moves BOTH anchors at once — todayLine() in the prompt, and resolveDatePhrase(due_hint, today) inside resolveFollowUpDues, the half that actually writes a reminder on the wrong day. Fixing only the prompt would have left the due date wrong. notes has no occurrence date (only created_at / deleted_at), so a note imported or forwarded long after the conversation it records still anchors on the day its row was made; adding a real occurred_at is a schema and capture-surface change, and deliberately not in this fix. Apply/reprocess semantics are untouched. Tests: lib/__tests__/extraction-failure-and-anchor.test.ts — a 401 becomes an auth reason and is logged with no note text, contact name or provider response body; a schema failure keeps its own sentence and reaches the log; attempts reaches 2 across a retry; a note written on X resolves "tomorrow" to X+1 whenever it is reprocessed, and a note extracted as it is saved still means today
  • An answered card's extraction survives the invocation that started it (2026-09-08, lib/actions/confirmations/note-answer/, lib/jobs/extraction-backlog/, lib/repo/extraction-jobs/) — found by asking why a note filed from a WhatsApp answer held zero facts and zero follow-ups. Production evidence: the note row exists, and there is no ai_actions row of any kind after the card was resolved — so the model call never completed. Three faults, each of which alone loses the note's contents silently. (1) The chat path abandons the work. finishNoteAnswer time-boxes the extraction against what the webhook invocation has left (withTimeout(chatAnswerExtractionBudgetMs())) and, on a miss, simply returns. Its docstring claimed "the work may still land its rows afterwards — nothing can cancel an in-flight Anthropic call", which is false on a serverless runtime: the invocation ends, the process is killed, the call dies. In the incident the same invocation was also running a voice-note batch in after() and lived 27s, against an extraction that needed longer — the sender was told "Filed on …", a success message for a half-done job. Both channels now enqueue the durable note_extraction job every other note path already creates, on no-outcome and on a failed outcome. blocked is deliberately excluded (a credit/dollar wall standing until the month rolls over is not retryable) and so is a calm applied-nothing outcome, which covers both "found no fact in the sentence" and noLlmOutcome() — indistinguishable by flags, and neither fixed by re-reading the same sentence. The app path was fixed too, not just the reported one: !outcome is unreachable there (the await is unbounded), but failed is reachable and finishNoteAnswer returns void, so its caller discards it — a tapped card whose extraction failed left zero facts, no queued work and nothing on screen. (2) Nothing drained that queue. processExtractionJob's only production caller was api/jobs/extraction/run, a route fired by a React component in a browser tab, so a job enqueued from a webhook had no driver at all; its docstring's "pure background drain (the daily reaper, retries)" named a caller that does not exist. Nor did the fallback: reapStuckExtractionJobs is called only from api/jobs/detect-signals, which is in no cron in apps/web/vercel.json and in no entry of api/jobs/daily/jobs.ts — so on the hosted deployment those rows sat pending forever without even an error. runExtractionBacklog now drains them from the */15 tick, not the nightly cron: what an extraction produces includes follow-ups with due times, and the reminders this incident lost were due the next afternoon, so a day of latency is not a late reminder but a missing one. Bounded by EXTRACTION_BACKLOG_RUN_CAP (a cron that can start unbounded LLM work is the thing it must never become), oldest-first so a backlog cannot starve its own head, and floored on updated_at at EXTRACTION_BACKLOG_MIN_AGE_MS so it does not race a tab that is already driving a job. Tenant scoping is the shared lib/jobs/tenant-sweep harness (eight other jobs use it), reads inside the scope and the LLM call outside it. note_extraction ONLY, and that is a spending decision: the other three kinds are all started by a person with a tab open, and enrichment is the dearest action in the product ($0.10 with web search), so draining an abandoned one from a cron would buy AI nobody asked for at that moment. The honest consequence, stated not hidden: an abandoned enrichment, connector or sector job still has no driver. (3) The deferred run would have repriced the work. The inline chat call bills free (0 credits, ceiling-gated) because answering the bot's own question must not cost a credit; the worker defaulted to credits, so the price depended on whether the model happened to be fast — and at a monthly cap assertAiBudget refuses, the outcome is a non-retryable blocked, and the note ends with nothing. extraction_jobs.billing (additive ADD COLUMN IF NOT EXISTS in the existing EXTEND_DDL chunk; NULL ⇒ credits, so every existing row is unchanged) now carries the price of the attempt it stands in for. A capped-user test proves the difference end to end. The drain also frees what it strands: it claims pending → running and then spends 20-40s in Anthropic on a route with maxDuration = 60, and claimExtractionJob only ever admits a pending row — so reapStalledRunningExtractionJobs (running-only, scoped, so it cannot touch queued rows the drain is about to take) errors those, rather than re-queuing them into an unbounded loop that buys a model call every quarter hour. Known exposure, inherited rather than introduced: the worker clears a note's derivations before re-extracting, by hard delete and ungated, so a re-run that clears and then fails again leaves neither rendering of a capture-time reminder (the same exposure actions/notes/reprocess.ts already carries). Judged the smaller loss — not queuing is a guaranteed total loss; this risks one reminder only if the background attempt also fails. Tests: note-answer-requeue-channels.test.ts, extraction-backlog.test.ts, deferred-extraction-billing.test.ts, and messaging-cases/chat-answer-extraction.test.ts; the enqueue condition, the billing carry-through and the channel widening were each mutation-tested red before being restored
  • Batch API for nightly jobs — lib/jobs/detect-signals/ (§14) is now a two-phase job through Anthropic's Message Batches API (BatchLLMClient, packages/core/src/llm): each run applies the previous run's finished batch (dedup + signal insert + metering, unchanged) then submits one fresh batch for whatever's newly due, persisting the pending batch id via settings (getPendingSignalBatchId/setPendingSignalBatchId). Vercel Hobby's once-daily cron means a contact's signal now lands with a ~1-day lag instead of immediately — accepted tradeoff for a job that was already nightly and latency-insensitive. Typecheck/lint/tests pass (signal-detection-batch.test.ts); no live-API manual run

11. Privacy & export (M8, BRD §7.5)

  • "Forget this person" — full cascade delete with confirmation
  • Self-serve account deletion (2026-08-19) — Settings → Account → Danger zone, the LAST card on the tab. Closes a live erasure exposure rather than adding a feature: /terms (accepted at signup), /refunds, /contact and the pricing FAQ all already promised it, so until now the app was out of step with its own contract under GDPR Art. 17 / India DPDP. The user types their own email address (or the literal word DELETE) before the destructive button arms — one click must never erase an account, and a native confirm() is too easy to click through for something with no undo. The typed value is a proof of INTENT only: the server re-checks it and always acts on the session's own id and email, never on anything the form names. Everything runs in ONE transaction, graph tables first and children before parents, with the "user" row LAST, so a partial failure rolls back rather than leaving an ownerless graph that can neither finish erasing nor be used. The cascade has to name its tables and this is the reason: almost nothing cascades from "user"(id). Only the better-auth tables carry a real FK to it (session, account, passkey, two_factor, oauth_*), so deleting the user row retires those and nothing else; every per-tenant table gets its user_id from packages/ee's RLS DDL as a bare text column with no foreign key at all, and apikey hangs off reference_id with no FK either. A table missing from ACCOUNT_OWNED_TABLES (utils/constants/account-deletion.ts) is therefore a silent breach — add one there whenever one is added to EE's TENANT_TABLES. How each table is scoped is decided ONCE from the SCHEMA, never from an env flag anyone could set wrong (lib/repo/account/scope.ts): scoped when contacts has a user_id (Dhaga Cloud) → explicit predicate on every table; sole on a core-only self-host with one account → unqualified deletes, since the graph is unambiguously theirs; shared on a core-only self-host with several → the graph is left ALONE and returned in skippedTables, because nothing in that schema says which contact belongs to whom and erasing a bystander's data to satisfy this request would be the worse bug. That branch also fixed a real abort: follow_ups.user_id exists in core but is NULL without EE, so a predicate delete cleared none of them and the later notes delete died on its RESTRICT FK. Refuses while a paid plan is live rather than cancelling it — deleting the account would remove the only record of whose subscription it is while the subscription kept renewing at the processor, and cancelling silently is an irreversible money decision (/refunds governs it) that has to be the user's own act. Deliberately retained, and this is a decision, not a gap: payments (the charge ledger — a financial record kept under Art. 17(3)(b) tax/accounting obligation) and admin_access_log (whose subject is the admin who looked, per the entry above). Sign-out fires after the commit and is load-bearing, not cosmetic: the session config runs a 60-second cookie cache, so without it a deleted account would keep resolving from the cached cookie for up to a minute after its rows were gone.
  • Forgetting a contact cannot delete a shared note that is also about other people (2026-08-23, documented rather than fixed — decided). note_contacts.contact_id is ON DELETE CASCADE, so the erasure takes their link to the note, their membership rows and everything the note attributed TO them; what it cannot take is the note itself, whose owner is the group and whose subject is several people at once. Their name can therefore survive in the note body. This is not new with groups and must not be overstated: it is already true of any note that mentions a third party ("met Priya, she introduced me to her co-founder"), and groups only make it more visible by putting one body on several timelines. Building redaction — rewriting a note the user wrote, on someone else's behalf, with no way to check the edit is right — was considered and rejected for v1. Stated on /privacy and in BRD §7.5 rather than silently relied on
  • Account deletion erases groups — closed; this line read “does not erase” until the 2026-09-06 audit re-checked it against the code. The gap was real when recorded on 2026-08-23: group_members and note_contacts cascade from contacts, but groups had no FK to anything the erasure named, and a group name and description are the user’s own words about their own people. It is now in ACCOUNT_OWNED_TABLES, deleted by the erasure transaction after notes, facts and contacts, and covered by a parity test that fails the build if any table is left un-erased and un-excused
  • Export: contacts CSV
  • Export: vCard — GET /api/export/vcard stays the full portability download (every contact, whatever its provenance) and gains an opt-in address-book seed scope, ?scope=authored[&provider=device|google|microsoft]: only contacts the user authored, minus anyone already linked on that provider (tombstoned links included, so a bulk seed cannot resurrect someone deleted on the phone). The predicate is the one offerUnlinkedCreates uses, shared via lib/repo/sync/authored.ts so the bulk path and the sync path cannot disagree about what may be written into an address book. An unrecognised scope/provider, or either on format=json, is a 400 rather than a silent fallback to everything. The card now also carries NICKNAME, BDAY and item-group X-ABDATE/X-ABLabel, emits one ADR per entry of the synced contacts.addresses array (labels ride X-ABLabel because resolveLabel maps only HOME/WORK/OTHER out of a TYPE token and would silently drop a custom one), and omits the TYPE parameter on an unlabeled email/phone instead of defaulting it to WORK — all round-trip correctness rather than completeness (see §20). ADR previously came from contacts.location, a free-text display column that is not a synced field, so exporting it as a structured address meant the first sync wrote a fabricated {label:"Work", street:<location>} entry into contacts.addresses; it is now left out of the vCard entirely (CSV and JSON still carry the column, so portability is unchanged). One item-group counter is shared across addresses and dates — an item1.ADR and an item1.X-ABDATE on one card would otherwise both resolve to whichever X-ABLabel came first, and an address would return labelled "Anniversary". Two round-trip gaps remain, both pre-existing, both the same delayed shape (run 1 looks fine, the base records the merged value, run 2 reads the shortfall as a deletion): a custom label on an email/phone/link is emitted as TYPE=NEWSLETTER, which resolveLabel discards, so the label can be cleared on the second sync — the value itself is never lost, and addresses and important dates are immune because they carry X-ABLabel; and import/vcard/tokenize.ts unescapes a value before map.ts splits it on ;, so an escaped \; inside a structured component lands in the wrong field (visible in a current fixture: ORG:Stripe\; Inc parses to company Stripe, department Inc with a leading space) — which now reaches ADR too, since it is the first five-component property we emit. Both were scoped, traced and deliberately left for a follow-up rather than folded into this change. The CSV export takes the same two parameters but is the lossier seed (no nickname, no important dates)
  • Export: full JSON dump (contacts+events+notes+facts+edges)
  • No contact PII / transcripts / extraction output in server logs
  • Enrichment & cloud AI strictly user-triggered (no background calls)
  • Operator access policy, stated and enforced (2026-08-12) — /privacy now says plainly who can reach hosted data (whoever holds the production database credentials) instead of implying nobody can, and why end-to-end encryption is not on offer: a server that answers questions about your notes has to be able to read them, so E2EE and cloud AI are mutually exclusive and claiming both would be a lie. Two claims that were false for Dhaga Cloud were corrected rather than softened — "there is no central Dhaga server reading your graph" (true self-hosted, not hosted) and AI calls running "only using your own API key" (true self-hosted; hosted runs on ours, metered in credits). The commitment that replaces them is narrow enough to be checkable: nobody opens a graph except to fix a reported problem, and the admin panel has no screen that renders anyone's contacts, notes or facts.
  • Admin access log (2026-08-12) — every administrative screen opened and every administrative mutation writes one row to EE's admin_access_log (actor, action, target, timestamp), readable at /app/admin/audit, which logs itself. The write sits inside the two authorization chokepoints — requireAdminForPage() for pages, assertAdmin() for server actions — so an unlogged admin surface would have to be an unauthorized one; a logAdminAccess() call beside each guard would be one forgotten line away from a blind spot. It fails closed: a failed log write throws and the page or mutation never runs, because a control that fails open stops existing exactly when the database is misbehaving. The table stores identifiers only — names and emails are joined in at read time, so the audit trail never becomes a second copy of the user directory with weaker controls around it. No FK to user: cascade would make account deletion a way to erase the trail, restrict would make the trail a way to block a deletion the user is entitled to. Honest limit, stated on /privacy: append-only by construction (no code path updates or deletes a row), not by grant — a superuser can still rewrite it, so this is evidence of routine access, not proof against a determined operator. Consolidated the two hand-rolled requireAdmin() copies in lib/actions/admin/ onto assertAdmin() — they were the audit-less duplicates.
  • Terms accepted at signup, and recorded (2026-08-12) — a required checkbox on /signup that also holds the OAuth buttons closed, so consent can't be walked around with one click, plus a server-side record of what was accepted: user.terms_accepted_at / terms_version stamped in better-auth's user.create.before hook against TERMS_VERSION (utils/constants/legal.ts, the same constant /terms prints). Stamped there rather than in the form because the form is one of three signup paths — OAuth and magic link create accounts through better-auth directly — and because input: false on the additional fields means the value can never be posted by a client. Hosted-only: /terms is Dhaga Cloud's contract, and a self-hoster has no counterparty, sees no checkbox, and gets no row. Not backfilled — an account that predates the checkbox genuinely did not tick it.
  • /terms refund wording corrected, terms edition bumped to 2026-10-06 (2026-10-06) — the Beta section said the company "can withdraw access from an account, for example after a refund or a chargeback", which stopped being true for Razorpay with #540 (a FULL refund or a dispute cancels the subscription and drops the account to free; account, sign-in and export are kept; a part refund cancels nothing). It now says that, and the billing paragraph no longer says prices are shown "in the currency you check out in" (every plan is charged in INR via Razorpay; dollar prices are approximate, as /pricing already says). TERMS_VERSION / TERMS_UPDATED bumped, so signups from now on record the new edition; earlier accounts keep the edition they ticked. This is legal text: still not lawyer-reviewed. The Stripe path still revokes approval on a refund (Stripe cannot sell today), so /terms describes Razorpay behaviour only. terms/terms-wording.test.ts pins the wording. The same edition also replaced "taxes are added where they apply" (the product adds no tax today; /terms now says the price shown is the amount charged and that we do not currently add taxes on top of it) and added the 18-or-older minimum age to "Your account", matching the signup box and the Privacy Notice. Still legal text that no lawyer has reviewed.
  • Separate privacy-notice consent at signup, recorded (2026-10-05) — built, unit-tested, not browser-verified (the e2e signup-visual/gate.spec.ts was updated but not run). A SECOND, unticked-by-default required checkbox on /signup ("I consent to Dhaga processing my personal data as described in the Privacy Notice"), deliberately not bundled into the Terms tick (GDPR Art. 7 / UK GDPR, DPDP s.6, LGPD Art. 8 want consent unbundled and affirmative; one flow for every visitor, no geo-gating). It gates the email form AND the OAuth buttons exactly as Terms does (ConsentCheckbox is the shared row; TermsAcceptance and PrivacyConsent wrap it). Server stamps user.privacy_consent_at / privacy_notice_version (PRIVACY_NOTICE_VERSION in utils/constants/legal.ts, which /privacy also prints) in user.create.before, input: false, hosted-mode only; the two columns come from PRIVACY_CONSENT_DDL, appended last in the DDL chain, never backfilled. What is verified server-side, honestly: a hosted /sign-up/email must carry x-dhaga-privacy-consent equal to the current notice version or it is refused (stops stale tabs and clients that skipped the form) — but the header is client-supplied, so it does not stop a determined direct API caller, and OAuth signups are covered by the UI gate only (the provider callback carries no form state). New signups only: no re-consent flow and no backfill for existing accounts. Update 2026-10-06: the /privacy copy has since been rewritten (next item) and a third, 18+ box added. Superseded 2026-10-06 (consent-stamp integrity, next-but-one item): the x-dhaga-privacy-consent header described above no longer exists; the stamp now rests on a server-issued signed acknowledgement, and OAuth signups are no longer stamped unconditionally.
  • Privacy Notice rewritten + 18+ affirmation at signup (2026-10-06) — built and unit-tested, not browser-verified, no visual check done, and not reviewed by counsel. /privacy now carries, each written from what the code does: what is collected (account data, what the user enters about third parties, records of use), why per feature, the legal bases (a neutral description, no compliance claim), the sub-processor table rendered from SUBPROCESSORS with regions plus a cross-border paragraph, retention (periods only where code enforces one: the 7-day chat window and its daily sweeps; otherwise "until you delete it"; backup rotation window still unconfirmed and not stated), what survives account deletion (payment records, the admin access log, organization-owned records, backups), cookies and similar technologies (table rendered from STORAGE_INVENTORY, plus the IndexedDB graph/map copy, the notification service worker and Cloudflare Turnstile), children (18+), how agreement is recorded (edition + timestamps), rights and how to use each, withdrawing consent, regulator complaints, and a privacy and grievance contact (the company mailbox in LEGAL_ENTITY, 7 days to acknowledge and 30 to resolve, from utils/constants/privacy-requests.ts). PRIVACY_NOTICE_VERSION bumped to 2026-10-06. 18+ gate: a third unticked required box (AgeConfirmation) on the same gate as Terms and privacy, so it blocks email submit and the social buttons together; the server stamps user.age_confirmed_at (new AGE_CONFIRMATION_DDL, appended last, never backfilled, input: false) and refuses a hosted /sign-up/email without x-dhaga-age-confirmed equal to MINIMUM_AGE. Same honest limit as the privacy header: client-supplied, and OAuth signups rest on the UI gate alone. It is an affirmation, not age verification. Known gaps, not fixed here: the /terms page says nothing about age (another change owns it); OpenAI (server voice transcription) and Cloudflare (Turnstile) are not in SUBPROCESSORS and their regions are unconfirmed; the graph/map IndexedDB copy is not cleared on sign-out; no admin tool deletes an under-18 account (an operator runs the erasure by hand). Superseded 2026-10-06 (next item): x-dhaga-age-confirmed was removed; the age stamp rests on the signed acknowledgement, and an OAuth creation without it is left unstamped rather than stamped.
  • Consent stamps only for what was verified (2026-10-06) — built, unit-tested and exercised against a real betterAuth() instance on its in-memory adapter (the real Google provider code and /callback/:id endpoint, only Google's token call stubbed); not browser-verified, no real provider round trip run. Fixes a review finding: user.create.before stamped terms_accepted_at, privacy_consent_at and age_confirmed_at for EVERY hosted creation but only checked the two consent headers on /sign-up/email, so a Google/GitHub/magic-link/scripted signup got a record saying 18+ and privacy consent were given when nothing had been affirmed. Now POST /api/signup/ack (hosted-only, IP rate-limited by signup_ack_ip, JSON-only, refuses unless all three boxes are true) sets a signed (HMAC over BETTER_AUTH_SECRET), HttpOnly, SameSite=Lax, 10-minute cookie bound to the current terms edition, privacy edition and MINIMUM_AGE; the signup form calls it just before email submit and just before each social redirect (SocialButtons.beforeSignIn). beforeUserCreate verifies that cookie from the request context and stamps each timestamp only for what it carries, otherwise writes NULL (a forged payload value is overwritten with NULL). A hosted /sign-up/email without a valid ack is still refused (400, no user); an OAuth/magic-link/scripted creation is still allowed but unstamped; existing accounts' sign-in never reaches the hook. The old constant headers are gone. Honest limits: the endpoint is public, so a script can mint an ack (it proves our server was asked with all three ticked, not that a human did); the cookie is not cleared after use, so it covers any creation from that browser for 10 minutes; an unstamped OAuth account has NO record and no screen yet asks that person to confirm (a post-OAuth confirmation screen was not built); no existing user is backfilled.
  • Separate privacy-notice consent at signup, recorded (2026-10-05) — built, unit-tested, not browser-verified (the e2e signup-visual/gate.spec.ts was updated but not run). A SECOND, unticked-by-default required checkbox on /signup ("I consent to Dhaga processing my personal data as described in the Privacy Notice"), deliberately not bundled into the Terms tick (GDPR Art. 7 / UK GDPR, DPDP s.6, LGPD Art. 8 want consent unbundled and affirmative; one flow for every visitor, no geo-gating). It gates the email form AND the OAuth buttons exactly as Terms does (ConsentCheckbox is the shared row; TermsAcceptance and PrivacyConsent wrap it). Server stamps user.privacy_consent_at / privacy_notice_version (PRIVACY_NOTICE_VERSION in utils/constants/legal.ts, which /privacy also prints) in user.create.before, input: false, hosted-mode only; the two columns come from PRIVACY_CONSENT_DDL, appended last in the DDL chain, never backfilled. What is verified server-side, honestly: a hosted /sign-up/email must carry x-dhaga-privacy-consent equal to the current notice version or it is refused (stops stale tabs and clients that skipped the form) — but the header is client-supplied, so it does not stop a determined direct API caller, and OAuth signups are covered by the UI gate only (the provider callback carries no form state). New signups only: no re-consent flow and no backfill for existing accounts. Not done: the /privacy copy was audited, not edited — it still lacks several items regulators expect (retention periods, named sub-processors and cross-border transfer, a grievance officer/DPO, how to withdraw consent, regulator-complaint and children/minimum-age statements); see the PR notes.
  • Post-signup "check your inbox" screen is recoverable (2026-10-06) — built, unit-tested, not browser-verified at 375px. It names the address the link went to, offers Resend email (30 s client cooldown; refusals shown, not reported as sent; fresh Turnstile token because /send-verification-email is captcha-protected) and Wrong email? Use a different one (form returns with name and email kept). The resend is now one hook, components/app/auth/useResendVerification.ts, shared with /login's unverified-account screen, whose resend now also reports a refused send instead of always saying "Sent". The confirm-password field stays by decision. The /pending aside no longer says the account "needs to be let in" (approval is by verifying the email; the page is reachable only for revoked or never-approved accounts). Not done: social buttons above the email fields, because the Terms/Privacy gate sits below them. Tests: SignupForm/__tests__/SignupNotice.test.ts.
  • Access asked before consent, not after (2026-08-24) — connecting a calendar or a contacts account now opens ONE dialog first (components/app/connect/) instead of connecting at the narrowest tier and leaving the useful one behind a second control found later. The old flow was a support problem rather than a preference: a calendar connected at free/busy showed no events, the separate "Upgrade to full access" link went unfound, and the user's conclusion was that the product was broken. Calendar's two answers were already reachable — ?upgrade=1 was and remains the entire opt-in mechanism in api/calendar/connect — so that half is UI only, with "see what's on my calendar" pre-selected and marked recommended, so the hurried answer is the working one. Contacts needed real plumbing. ContactSyncProvider.getAuthUrl gained write?: boolean, and one-way now asks for contacts.readonly / Contacts.Read instead of the write scope both providers previously requested unconditionally; that also closes a gap between the code and /privacy, which already described two-way as "a separate, wider grant you opt into on its own" while the write scope was in fact being requested for everyone — the page's claim is now true, which matters because Google verification reviews that wording against the scopes the code asks for. The answer rides the signed OAuth state (signState(provider, userId, intent) plus a new readState), never a callback query parameter: it decides whether Dhaga may write to an address book that syncs onward to every device the person owns, so a value the browser could edit would be a privilege escalation. The callback sets two_way_sync from that intent in BOTH directions and deliberately never derives it from the granted scope — Google's include_granted_scopes=true unions in permissions the account granted on an earlier connect, so a "just read them" answer could otherwise return write-capable and silently switch two-way back on. Two Graph bugs the read-only tier exposed, both already fixed this same way in the calendar provider: the contacts exchangeCode posted a fixed scope (would hand a two-way consent a read-only token) and refresh fell back to the write constant when the stored grant was unknown (would widen a one-way connection on its first refresh) — a new sync/microsoft-provider/scopes.ts derives both, mirroring the calendar's split. All six connect entry points go through the dialog, the reconnect prompt included, so no surface can link straight at a connect route and skip the question. Pending: no live OAuth round trip — there are still no Google/Microsoft credentials in the build environment, so the consent screens themselves remain unexercised and the read-only contacts scope has not been seen against a real account.

12. Mobile app — apps/mobile (BRD MVP platform; separate milestone)

  • PWA: installable web app (manifest, standalone display, brand icons) — the interim mobile surface

  • Per-user API keys (better-auth apiKey plugin) for non-browser clients — replaces DHAGA_API_TOKEN; code confirmed fully wired end-to-end (2026-07-07 audit): apiKey plugin, settings UI, /api/capture auth fallback all connected, DHAGA_API_TOKEN no longer referenced anywhere in code. Creating a token is payment-gated since 2026-08-07 — it is the enforcement point for the multi_device_sync plan feature (requireFeature in lib/actions/api-keys.ts), since a token is how the mobile app, scripts and local MCP clients authenticate. It is not how the browser extension authenticates — that rides the logged-in cookie session (credentials: "include", apps/extension/src/popup.ts) and is deliberately ungated; the label and the pricing copy claimed otherwise until 2026-08-07. Verification and revocation are deliberately not gated, so a downgrade never breaks an integration a user already set up; a self-hosted instance has no plan in play and is unaffected. Two sibling enforcement points on the same feature cover what a token gate cannot reach: /api/mcp itself (the OAuth connector path never sees a token) and linking a WhatsApp/Telegram chat. Only remaining bar is a human clicking through the settings UI in a browser

  • /api/capture accepts card images (base64) — ready for the Expo app to call

  • M0 spike: Expo app, camera → Vision/ML Kit OCR → Haiku parse → contact saved — built 2026-07-04 (apps/mobile: Expo SDK 57, dev-client, expo-text-extractor on-device OCR primary + server photo-scan fallback, x-api-key auth); typecheck green; needs on-device verification on Android + iPhone (see apps/mobile/README.md), not pushed

  • M1 card scan: edit-before-save (2026-09-06) — POST /api/capture gains an explicit two-step contract alongside the original one-shot one: step: "parse" scans a card photo or extracts the on-device OCR text and returns the contact for review WITHOUT writing anything (not the contact, not the receipt note, not the card photos); step: "save" persists whatever the user confirmed or edited, re-validated server-side against extractedContactSchema — the posted contact is never trusted verbatim, same rule as every other write of a user's data about a third party. Omitting step keeps the exact one-shot behaviour every existing caller (the browser extension, an already-queued mobile request) already gets; the split is explicit on the wire, never inferred from which fields are present. Wire types split into packages/core/src/api/capture/{request,response}.ts under the 150-line rule (CaptureParseResponse, CaptureSaveResponse added). Mobile gets a new review screen (apps/mobile/src/components/scan-review/) between the scan and the save: editable name/title/company/location plus labeled emails/phones and links, a "Check these against the card" notice when the card's two independent reads disagreed (mirrors the web app's ScanCheckNotice), and Save/Back kept clear of the keyboard (KeyboardAvoidingView plus a footer outside the ScrollView, not inside it). Server tests: apps/web/src/app/api/capture/__tests__/scan-review-{parse,save,compat}.test.ts (35 capture tests green: parse writes nothing, save persists the EDITED fields not a fresh extraction, a wrong-typed or blank-after-trim contact is rejected and creates nothing, the one-shot path is provably unchanged). Not done, and it is a measurement, not code: the same checklist line's ≥90% accuracy on clean cards and <5s targets are unaffected and unbenchmarked by this change. Not verified: no eslint run on apps/mobile in the build environment (eslint-config-expo unresolved) and no simulator/device pass of the new screen.

  • M2 auto event grouping (time + geohash clustering, name-once prompt)

  • M3 voice notes: whisper.cpp / Apple Speech on-device transcription — built via expo-speech-recognition (§6 has details); typecheck/lint pass, needs a real device build to confirm native linking on SDK 57, not pushed

  • M1 data-layer parity: reuse the @/lib/data gateway contract with a TanStack Query adapter in Expo; FlashList v2 for all long lists (docs/LIBRARIES.md §§1, 6; mobile/web parity rule)

  • Meeting groups on mobile — nothing built, and the parity rule is only half-satisfied. The colours half genuinely has nothing to port and this was verified rather than assumed (2026-08-22): apps/mobile has no note list and no note surface at all, no endpoint it calls returns notes, and every note it creates is stamped source: "mobile", which the accent map deliberately leaves neutral. The groups half is a real gap — no /app/groups equivalent, no shared-note composer, no "note from this meeting", so a group created on the web is invisible on the phone. When a note list is eventually built there, the colour port is: mirror the four accent pairs into COLORS by hand (RN cannot read a CSS custom property), restate the source→accent map in RN terms, and accept that the two copies can drift with nothing to fail on it — moving NOTE_SOURCE_LABELS into packages/core first would at least share the labels. Colours only; group features are a separate decision

  • M8 local-first SQLite (op-sqlite) + sqlite-vec; full offline

  • Sync engine — decide per BRD §11 Q2: field-level LWW vs PowerSync vs TanStack DB + ElectricSQL (lead candidate, docs/LIBRARIES.md §5); evaluation → decision doc → sign-off before any sync code lands

  • E2E-encrypted backup/sync

  • EAS builds: .aab (Play) + .ipa (App Store); store listings

13. Browser extension (v1.1)

Store status (owner-confirmed 2026-09-30): apps/extension is a working MV3 extension. A 0.0.1 build has been installed from the Chrome Web Store; this change is manifest 0.0.2, which still has to be uploaded and reviewed. There is no install link anywhere on the site. The landing hero lists it as a capture channel marked Coming soon with no install link, matching the mobile app's treatment. Everything ticked below is the extension's behaviour; distribution is the unticked item at the end.

  • Chrome/Edge extension (MV3): the side panel captures page selection → /api/capture
  • One-click capture from any page (activeTab on explicit click only, page URL as receipt)
  • /api/capture REST endpoint (session-gated; shared by extension + future mobile share)
  • "Save this article to {contact}": attach mode in the side panel + contact search API
  • Points at Dhaga Cloud by default (2026-08-09) — the default instance is https://www.dhaga.app (the bare dhaga.app 308-redirects to it, so the Grant click requests both hosts; fixed 2026-09-30 after a redirect to an ungranted origin surfaced as "Could not reach"). No origin is declared in host_permissions (see Runtime host access below): the Dhaga origin, including localhost:3000 for self-hosters and local dev, is granted at runtime from the panel
  • Settings behind a gear icon in the side panel, both fields optional: instance URL and API key. One implementation (src/settings-form.ts) mounted by both the side panel and the standalone options page. Auth is cookie-first — the key is only a fallback for browsers that drop the cross-site session cookie, or instances behind SSO — and rides the x-api-key branch requireUserIdFromRequest already supported. Using the extension is not plan-gated even though minting a key is (multi_device_sync), because the cookie path needs no key. The key is stored in chrome.storage.local, never sync, so a credential is not replicated through the user's Google account
  • Screenshot capture (2026-08-10) — the panel captures the visible tab (chrome.tabs.captureVisibleTab, downscaled to a 1280px long edge, JPEG), up to 6 views of one page, sent as images[] with imageKind: "page". Server side: a page_scan prompt + schema separate from the card scan (the card prompt is instructed to return empty fields for anything that is not a card, so screenshots through it came back blank), its own metered action priced at 2 credits, and /api/capture now checks the page branch BEFORE the card branch so contactId is honoured — previously images.length > 0 returned first and "screenshot this profile onto Sarah" silently created a duplicate Sarah. Whether a screenshot is kept as a visual receipt follows the user's store-captured-photos setting (shouldStoreCardPhotos, in api/capture/handlers/screenshot-shared.ts); otherwise it is read and discarded, and the response reports which happened. 15 tests over the capture route
  • Popup → side panel (2026-08-10) — multi-shot capture needs the UI to survive scrolling the page, which a popup does not: any click outside it closes it and discards the shots already taken. Adds the sidePanel permission and a service worker whose only job is setPanelBehavior
  • Runtime host access (2026-08-10) — host_permissions is now EMPTY. an earlier build declared https://dhaga.app/* there while optional_host_permissions also matched it via https://*/*; Chrome resolves that overlap by withholding the grant, so the extension looked correctly installed while every request failed as a console-only CORS error with a silent UI. The panel now checks chrome.permissions.contains on open and shows a Grant access button. Since 2026-09-30 that one click requests the Dhaga origin plus https://www.linkedin.com/* (not <all_urls>), and Add screenshot asks for <all_urls> separately on first use (optional_host_permissions). Also fixed: picker.ts had a try/finally with no catch, so a blocked contact search escaped as an unhandled rejection
  • Web Store packaging/listing (privacy policy page now live at /privacy) — further along than it looks: build/zip pipeline exists (apps/extension/build.mjs), a pre-built .zip and STORE_LISTING.md with listing copy are ready; only remaining blockers are a reviewer demo account + the $5 Chrome dev registration fee (both manual/external, not code). Until that clears, unpacked-only is the shipped reality and every surface that mentions the extension has to say so — the pricing FAQ does (utils/constants/landing/pricing/faq.ts), and no page offers an install link
  • Company capture (extension "Company" mode), built, NOT verified live (2026-09-30) — server: POST /api/capture/linkedin/company (save) and POST /api/capture/linkedin/company/resolve (advisory preview, writes nothing) accept up to 25 second-degree people at a named company, each with up to 50 mutual connections; one transaction under the LinkedIn capture lock, dedupe by profile URL then exact name, ambiguous matches return the 409 choice-required response (resend with decisions), knows-edges target-to-mutual with a "Found via company search" receipt note, re-running an identical batch creates nothing. No LLM call. Covered by PGlite + route specs. Extension: the user types a company, picks it from LinkedIn's own results, and the extension reads 2nd-degree people via LinkedIn people search in the user's OWN active LinkedIn tab, then the mutuals of the first N 2nd-degree people (LINKEDIN_COMPANY_RUN; user-chosen in the Company form and remembered: Pages to read default 1, up to 5, about 10 people per page; People to read mutuals for default 2, 0 to 10, where 0 lists people only; both stored in the run checkpoint so a resume keeps them; an independent 50-person cap; jittered 4 to 9 s delays, a longer pause every 5 people); Stop / Pause / Resume; hard stop on a checkpoint / authwall / login / captcha page or two empty pages; resumable from chrome.storage.session for 30 minutes; a one-time acknowledgement that heavy automation can lead LinkedIn to restrict the account; nothing is saved until the user reviews and presses Add (chunks of at most 25). Reader, run controller, UI and wiring are covered by jsdom tests against synthetic markup only. Follow-up (2026-09-30, after the first live run): that run reached LinkedIn's people-search page but stopped as "blocked" with no check present, a FALSE POSITIVE: the challenge detector counted the reCAPTCHA iframe LinkedIn embeds on every page. It now ignores iframes entirely and blocks only on a checkpoint / authwall / uas / login URL, or on a results-free page whose visible title, heading or alert names a check; a page that shows people results is never "blocked" (two empty pages still hard-stop). The primary flow is now: filter on LinkedIn first (2nd connections, Current companies), then press Read people from this search. The run reads the open page in place (no navigation for page 1), keeps every filter the user set and changes only page; the company-name flow stays as "Or find a company by name". Cards now yield the degree marker, the "Current:" role and a mutual count of names listed plus "N other"; mutuals are read only for 2nd-degree (or unknown-degree) people, 1st-degree people are listed as "Already connected" and never visited. Degree and role stay client-side; the server contract is unchanged. Covered by jsdom tests on synthetic markup. Still NOT verified on a live LinkedIn session end to end: the card markup it parses (marker, Current: line, social-proof line) is modelled on what the first run showed, not yet read by a real run. Not verified on a live LinkedIn session, not browser-tested at 320px in both themes, and 0.0.2 not yet submitted to the Web Store. Same change: accounts pending approval get 403 (not 401) from the capture, destinations, resolve, contacts and company routes (lib/auth/response.ts), and the extension turns every failed request into a specific message (no grant, offline, 401 sign-in hint, 403 server message, 429, non-JSON, 5xx). One Grant access click now requests the Dhaga origin and https://www.linkedin.com/* only; the panel and options page were redesigned on the design tokens. Manifest is 0.0.2 (the store needs a version above the live 0.0.1). Known limit, unchanged: the extension imports @dhaga/core by deep relative paths because its tsc uses lib ES2020.
  • Live-test Company mode on a real signed-in LinkedIn account — run one small company end to end (pick company, read a page or two, Stop, Resume, Add) and confirm the selectors for company results, people cards and the profile mutuals link still match, that the tab is restored afterwards, and that the checkpoint stop fires; check the panel at 320px in light and dark. Until this is done Company mode is unverified

14. Proactive intelligence (v1.2)

  • Job-change detection — simplified to web-search-only per product decision (2026-07-05), superseding the CSV-diff mechanism in BRD §6.7: shares the signal-detection sweep with the news watchlist below, kind: "job_change" when the search results show a different title/employer than what's on file. Typecheck/lint/build/tests pass; manual click-through needs a real ANTHROPIC_API_KEY, not done. Wired but unproven (2026-08-08). Firecrawl is no longer paid for, and the sweep now searches through Anthropic's own server-side web_search tool (packages/core/src/search/anthropic-client/) behind the same gateway; the default provider is anthropic wherever FIRECRAWL_API_KEY is unset, so hasSearch() (packages/core/src/search/index.ts) is true on the hosted instance, which already has ANTHROPIC_API_KEY. runSignalDetection() therefore no longer short-circuits on { skipped: "no_search" } — but no end-to-end run has been observed against a live key, so nothing is claimed about what it produces, and the box stays unticked for exactly that reason. The credit price is unverified too: signal_detection is still 0 credits, but the cost it was argued from is stale — see §8.3 of the BRD

  • Opt-in news watchlist: per-contact "Watch for job changes & news" toggle (contact page) → nightly cron (/api/jobs/detect-signals, apps/web/vercel.json) → provider-agnostic web search (packages/core/src/search, Anthropic's server-side web_search tool by default since 2026-08-08; Firecrawl still wins where FIRECRAWL_API_KEY is set) → the extract tier classifies hits → signals table → signal-scored Home Moments + contact page, "Add as note" (receipted) or dismiss. Capped per plan (PRO_TIER_WATCHLIST_CAP/FREE_TIER_WATCHLIST_CAP) — Pro's cap dropped 25 → 5 on 2026-08-19 alongside the (since removed) introductory ₹199 price, because the scan is 0-credit and therefore invisible to the credit allowance: at the estimated ~$0.016/contact/cycle, 25 slots was ~$2/month against ~$2.30 of revenue. The cap is now enforced at both ends — when watching a contact, and as an oldest-scanned-first bound on how many contacts one nightly sweep looks at — so an account already above the cap keeps every row it has (nothing is deleted or auto-unwatched) without costing more than the cap allows. Typecheck/lint/build/tests pass (signals.test.ts, search-client.test.ts, anthropic-client.test.ts, provider-selection.test.ts, signal-search-metering.test.ts); manual click-through not done (needs a live API key). The toggle is greyed out only while no search provider is configured — /app/people/[id] passes hasSearch() into WatchToggle as searchConfigured, and a false value wraps the control in ComingSoonNotice with SIGNAL_WATCH_COMING_SOON and swaps the "opt in to get alerted…" line for SIGNAL_WATCH_DISABLED_DESCRIPTION (utils/constants/coming-soon.ts). It is a runtime check, not a build flag, so the toggle un-greys itself the moment a key is set — no code change. That gate is now open (2026-08-08): with anthropic the default provider, ANTHROPIC_API_KEY alone makes hasSearch() true, and the hosted instance has it. What has not happened is a single observed end-to-end run — no live search, no live classification, no signal seen in the UI — so treat the feature as armed, not proven. Search is metered (lib/jobs/detect-signals/search-phase.ts: one 0-credit signal_detection row per search carrying the search turn's tokens, so the instance dollar ceiling sees them; Firecrawl/SearXNG report no cost and get no row), but the $10/1k per-search charge has no column on ai_actions and is under-reported Jev pre-gate (2026-09-23, lib/jobs/detect-signals/jev-gate.ts): a zero-result search skips the Claude classifier with or without JEV_API_KEY (the prompt could only say "(no search results found)", so code answers), and the contact is still stamped scanned and its search metered; with a key, a confident Jev "no update" (p(update) ≤ 0.05, SIGNAL_JEV_MAX_UPDATE_PROBABILITY, compared directly — kept after a 2026-09-23 injection re-eval, where 0.98 skipped nothing) skips it too. Jev runs through the DB-free judgeEach within the job's 20s of Jev-only time (SIGNAL_JEV_BUDGET_MS, split fairly per tenant and clamped further by the daily run's shared Jev budget) and is metered in one scoped statement; any real signal is still Claude's.

  • Keep-in-touch cadence reminders + Home Moments (ideas.md #2). Rewritten 2026-08-02 as one scored list (lib/repo/daily-suggestions/, weights in utils/constants/suggestions.ts). The retired Home "Today" tile used to walk three first-come-first-served buckets — daily check-ins, then cadence-due contacts in FNV-1a-hash order (so how overdue someone was never affected where they landed), then a degree-centrality filler — with three chips: Check-in / Due / Network. The surviving engine now runs one additive score and supplies Moments for today: five sources (cadence, dated open follow-ups, upcoming important dates, watchlist signals, graph degree) nominate candidates, six reason-bearing terms score them against each other (cadence 40, follow-up 30, important date 30, signal 25, quiet/decay 15, degree 10) plus two modifiers (starred 10, day-keyed rotation 5), and the top N are taken. The winning term names the row, so a chip can never claim a reason that didn't rank the person; the two modifiers can lift someone into view but are excluded from the copy outright ("you starred them" is not a thing to do today, and the reason string is emailed verbatim by the digest). Seven chips now: Check-in, Due, Follow-up, Occasion, Signal, Quiet, Network — an eighth, Goal, and an eighth scoring term landed the next day and were removed again on 2026-08-22, when goal left the suggestion engine for surfaces of its own; the engine is back to seven chips, six terms and five sources, with no SUGGESTION_WEIGHTS.goal (see the goal-driven curation entry below). Still zero AI — deterministic SQL + pure TypeScript, quantised to the user's local midnight so the list is identical on every render within a day: no LLM call, no withAiAction entry point, no credit price, no inference cost, and it therefore works unchanged on the free tier (CLAUDE.md Rule 5). Three behaviour changes worth naming: (1) "Due" now means real last touch — lastTouchSql (GREATEST(created_at, last_reached_out_at, newest live note, newest event scan)) rather than only the "Reached out" button, so writing a note about someone or scanning them at an event resets their cadence clock; (2) source = 'mentioned' stubs ("Prashant's son" — referenced in a note, never captured) no longer reach the due feed, filtered once for every source in daily-suggestions/facts.ts; they were already excluded from the graph filler and the Going-quiet tile, the due feed was the one hole; (3) a busy calendar shrinks the list to a floor of one (MIN_SUGGESTIONS_ON_BUSY_DAY) instead of potentially silencing it — daily check-ins used to be added ahead of the meeting-load subtraction to escape it, and no longer need to, because the list is urgency-ordered and an urgent check-in is already row 0. Covered by lib/__tests__/daily-suggestions.test.ts. content/docs/guide/home.png now shows the current lifecycle composition with initials-only synthetic data. Pending: live click-through of the production Moments actions with populated data. User guide: apps/web/content/docs/guide/index.mdx

  • Goal-driven curation (2026-08-03; surfacing rebuilt 2026-08-22) — the engine and controls are built, and the 2026-08-14 lifecycle composition re-homed them in a Relationship goal panel below the daily briefing — superseded on 2026-08-22, when goal left the shared suggestion engine entirely for a route and a Home tile of its own (docs/GOAL_ENGINE.md; the rebuild is detailed at the end of this entry, and where it contradicts an older sentence here the rebuild is what shipped). Nothing is verified beyond typecheck/unit level. The user writes one objective in plain words ("reach out to VCs", "reconnect with people from the Delhi trip") through the Set a goal dialog on Home (which moved into goal's own Home tile on 2026-08-22), and Dhaga resolves it into a finite cohort out of the user's own graph that burns down as they reach out. Retrieval is deterministic and LLM-free (lib/repo/goals/recall/ calls hybridSearch(objective) — CLAUDE.md Rule 5: only the judgment of whether a person fits is a model call); a nightly Batch pass (lib/jobs/match-goal/) ranks the recalled pool with Haiku and writes goal_members rows carrying the frozen 0–100 fit, with anything under GOAL_MIN_FIT (40, added 2026-08-22) stored rejected rather than surfaced. GOAL_DAILY_SLICE (3) members surface each morning — on goal's own surfaces since 2026-08-22, not behind a Moments chip — rank-banded (GOAL_RANK_BAND, narrowed 25 → 5 on 2026-08-22 because a model that reliably answers 85 put every member in one band, so ordering degenerated to the day hash and the model's judgment contributed nothing) then day-key rotated so the cohort can't stall behind whoever the user least wants to message. Scoring: goal originally rode the shared scorer as an 8th additive term at weight 28 (SUGGESTION_WEIGHTS.goal, modulated by SUGGESTION_GOAL_BASE 0.7), reserving no slots — all of that was deleted on 2026-08-22: there is no SUGGESTION_WEIGHTS.goal, no SUGGESTION_GOAL_BASE and no goal candidate source (lib/repo/daily-suggestions/candidates/goal.ts is gone), so a goal member never appears in Today/Moments at all. "Done" is DERIVED, never stored (lib/repo/goals/cohort/): a member drops out once lastTouchSql moves past matched_at, so a note about them or an event scan burns them down exactly like the Reached out button, and markReachedOut — six callers, none goal-aware — needed no change. MAX_ACTIVE_GOALS = 1, enforced in the repo write rather than by a unique index: creating a second goal archives the oldest instead of erroring. GOAL_MATCH_RUN_CAP (150) is the per-run cost fuse; the nightly goal_matching pass is priced 0 credits (see §8.3). Saving a goal is free and calls no model (2026-08-03): matching is the nightly pass's job, the waiting copy says so (GOAL_NIGHTLY_LINE, corrected 2026-08-22 to say two nights rather than "tomorrow" — see the rebuild below), and a Request now button buys the same match immediately for 8 credits via lib/ai/goal-resolve/ — its own metered feature goal_match_now, confirmed behind a dialog that shows the price and that waiting is free, with the credit gate resolved before the click; it was priced at 3 until 2026-08-22, when a measured run (~1,059 in / ~20 out per candidate ⇒ ~$0.046/request at ~$0.006 per credit) repriced it to 8 and GOAL_SYNC_RESOLVE_CAP went 20 → 40 so one request can reach more of the graph. Credits (plus the dollar ceiling) are now the only fuse on that path; the old RATE_LIMITS.goal_resolve 3-a-day bucket was removed with the free-on-save behaviour that needed it. The surfacing half of this entry was rebuilt on 2026-08-22 (docs/GOAL_ENGINE.md), and the sentences above it about WHERE a goal appears no longer hold. The diagnosis was that the cohort was real and the user never saw it: goal nominations were merged into the shared daily-suggestions list where goal: 28 lost to every term that could realistically fire on the same person (cadence 40, followUp 30, importantDate 30) — though the weight was never the decisive problem (28 sat above signal 25, quiet 15 and degree 10); the caps were. Goal was capped at 3 candidates against every other source's 10, trimmed by meeting count, index-capped out of MomentsTimeline, and — on the rare render where a member survived — badged Cadence, because reason.ts names a row after its highest-scoring term (§2.4). So goal has left the suggestion engine entirely (§3.1): there is no Goal chip, no 8th scoring term and no SUGGESTION_WEIGHTS.goal. It has two surfaces of its own instead (§3.7). /app/goal — a real route, registered in APP_MORE_LINKS/APP_MORE_MENU_LINKS — renders four lists off ONE cohort read: Today (up to GOAL_DAILY_SLICE, never offered before), Already shown (offered earlier, still open, never buried), All matches (the whole cohort in one list, which was the user's explicit ask — "I should see all 14 at once") and Skipped, with the model's one-line reason printed on every person in every list. It can add a person by hand (the shared EntityCombobox) and reject one with Not relevant, which captures a reason — four one-tap answers plus free text — because a skip and its words are the only training signal the matcher gets (§3.5) and state = 'skipped' previously had no writer anywhere in the codebase (§2.5). Home's goal tile is a preview of that route: objective, counter, today's people with their reasons, and a link through. The bare 0/14 bar is gone — the counter reads "3 of 14 today · 3 already shown", since the user's first question about this feature was literally "what is this 14?". surfaced_at is stamped from a server action fired by a client effect on /app/goal only, never on read (a write during a React server render is forbidden) and never from Home (which router.refresh()es itself on the calendar pulse and would walk the cohort into "Already shown" with nobody watching). Waiting copy now matches the measured ~48h floor instead of promising "tomorrow" — GOAL_NIGHTLY_LINE says two nights, which is what a daily cron plus a Batch that submits one night and applies the next actually costs (§2.6a). The cohort can grow now, which it could not before. Three changes make that true: (1) goal recall stopped inheriting hybridSearch's 20-row presentation slice — GOAL_RECALL_LIMIT (150, = GOAL_RECALL_POOL) is passed explicitly, and that slice was the measured ceiling, not a theoretical one (~69 qualifying contacts existed in the observed account, ~20 were ever judged, and the cohort stalled at 14 forever); (2) a below-floor verdict is stored rejected and every already-judged contact id is excluded from the next recall, so each pass reaches genuinely NEW people instead of re-judging the same top 20 to the same answer; (3) the batch pointer gained a 36h staleness bound and is cleared on abandonment, so one wedged or expired batch can no longer freeze goal matching indefinitely (§2.9). Rewording a goal clears the model's rejected rows but deliberately keeps the user's skipped rows — the model may be wrong about a new objective, the user's "not relevant" is not ours to discard. GoalResolveSkip also stopped collapsing every refusal into one no_budget: it is now no_credits / spend_limit / rate_limited / partial, so a 5-second burst limit no longer reports "out of AI credits this month" (§2.6c). New/moved code worth knowing about: lib/repo/goals/recall/, lib/repo/goals/cohort/, lib/repo/goals/curation/, lib/actions/goals/, lib/ai/goal-resolve/ (new examples.ts, outcome.ts, report.ts), utils/constants/goal-surface.ts, components/app/goal/, app/app/goal/. GOAL_MATCH_RUN_CAP (150), MAX_ACTIVE_GOALS (1), GOAL_DAILY_SLICE (3), the derived-"done" behaviour and the 0-credit nightly goal_matching price are all unchanged by the rebuild. Pending / not verified — the honest state (Rule 12): verified at typecheck, lint and unit level only (apps/web 2521/2521, packages/core 289/289; home-connection-pressure.test.ts still proves goal_members is joined exactly once per Home render). /app/goal has never been loaded in a browser at any width, and there is no screenshot, and no production build has been run against it — so nothing here is proven past unit level. The nightly production path has never completed a cycle for the reporting account: the pass is wired into /api/jobs/daily (after signal detection, before the reach-out digest, so the digest emails the freshest cohort), but that route has never been driven end-to-end with a live key, and the Batch pass has never been exercised against the real API. The credit price is the one thing that is measured now — goal_match_now's 8 credits come from an observed run (see above), so it is no longer an estimate; the nightly goal_matching and watchlist figures in BRD §8.3 still are. content/docs/guide/home.png predates the 2026-08-22 rebuild and shows the old Relationship goal panel below the briefing rather than the goal tile above the moments list; it was not recaptured, because there has been no browser pass to recapture it from. Known limitation, by deployment: hybridSearch's semantic stage runs through a local ONNX embedder that is unsupported on Vercel serverless (lib/ai/embedder.ts), so hosted degrades to keyword + trigram — concretely worded objectives ("designers in Bangalore", "people to see on my Delhi trip") recall well because those words appear in notes and titles, abstract ones ("people who could open doors for me") recall poorly because nothing in the graph is worded that way. There was a second ceiling — hybridSearch returns at most 20 hits, so GOAL_RECALL_POOL (150) was an upper bound this path could never reach — fixed on 2026-08-22 by passing GOAL_RECALL_LIMIT explicitly (see the rebuild above); the deployment limitation above is unchanged. User guide: apps/web/content/docs/guide/index.mdx Jev screen (2026-09-23, lib/ai/goal-resolve/jev-gate.ts, lib/jobs/match-goal/jev-screen.ts): on both the paid resolve and the nightly top-up, Jev screens candidates first and only rejects (p(match) ≤ 0.02); every match and its reason stays on Claude, and time-bound objectives skip Jev entirely (GOAL_ENGINE §3.9). A Jev rejection writes no goal_members row, so the candidate is re-screened next run; recall pulls GOAL_JEV_RECALL_DEPTH_FACTOR × deeper (lib/ai/goal-resolve/jev-fill.ts) so rejected candidates do not crowd out the cohort; the "now" path starts each candidate's Claude call as soon as its own Jev answer returns. A "Request now" run in which Jev rejected every candidate reports no_candidates and is not charged (lib/ai/goal-resolve/jev-only.ts); its Jev calls are recorded as 0-credit goal_matching rows. A run in which Claude was asked and every judgement failed returns failed, uncharged, whatever Jev rejected.

  • Organization relationship goal (2026-09-30, docs/GOAL_ENGINE.md §5) — Today in an organization workspace now shows the ORGANIZATION's goal, and the personal goal no longer follows a member in (goals.organization_id, getActiveGoal workspace-scoped; the two are separate rows). Any member can create, reword, skip and add people; only an org owner or admin sets the daily count (goals.daily_count, default 3), which applies to everyone and is read-only for other members. The day's list is shared: members stamped today stay in today's slice for all. Policy lives in packages/ee rls-ddl/org-goals (read/write by any member, unlike the other org tables). Verified at typecheck, unit and RLS-policy level only; never loaded in a browser. Known gaps: a colleague's private note does not count as outreach for you; the daily-count rule is app-enforced, not in SQL; the nightly pass does not match organization goals (Request now only); the organization export omits the goal.

  • Address-book noise suppression — person-vs-service classification (2026-08-03) — built, nothing verified beyond typecheck/unit level. Bulk imports drag in "Vegetable Vendor", "Ola Support", "AC Repair"; a nightly extract-tier Batch pass (lib/jobs/classify-people/, two-phase over the Message Batches API exactly like detect-signals) classifies each row person / service / unknown into contacts.person_kind. The governing rule: a service row is never suggested, but is always findable. lib/repo/contacts/surfaceable.ts is the single definition of "a row Dhaga may nominate", applied only to surfaces the app chose (Today, graph fallback, cadence due list, going-quiet, recommendations, Recent people) and never to People, Saved, search, duplicates, merge, Wrapped, address-book sync or export — filtering there would be an irreversible invisible hide. Nothing is deleted, archived or hidden from a place the user navigated to on purpose. Three values not two: NULL means "never judged, batch it" and unknown means "the model looked and declined", so a row that says only a first name is left alone rather than coin-flipped; only service suppresses, and person_kind_confidence orders the review list but never decides suppression. Visible and correctable: an "N hidden from suggestions" link on /app/people (linking to ?kind=service, the rows themselves — not to an explanation of them), a provenance chip on the contact page ("Dhaga's guess" vs "You marked this"), one tap to overrule, and a People bulk action. A user ruling is a permanent lock (person_kind_by = 'user') the sweep never re-judges. A contact the user has already acted on is never nominated for classification at all (classify-people/due.ts): starred, watched for signals, has a cadence, has ever been marked reached out to, or has any live note — the model only judges rows nobody has touched, because the worst failure this design can have is labelling a real friend a service. Throttled by PERSON_CLASSIFICATION_RUN_CAP (1000 contacts/night), not by billing: person_classification is priced 0 credits (see §8.3). The due-predicate plus the cap is the backfill — deliberately no one-shot script to drift away from it. DDL in lib/db/ddl/core/extend.ts (four columns + one partial review index), covered by lib/__tests__/surfaceable-suppression.test.ts. Pending / not verified: no live Anthropic API run, and no browser click-through of the People link, the provenance chip or the bulk action. The pass is wired into /api/jobs/daily (ahead of the reach-out digest so it emails the freshest labels), but that route has never been driven end-to-end with a live key. content/docs/guide/people-bulk-actions.png is now stale too — it predates the Not a person / Is a person buttons. User guide: apps/web/content/docs/guide/index.mdx

  • Jev (TypeSafe) pre-pass on person_classification and the four sector passes (2026-09-23, lib/jobs/jev/ — judge-each.ts, sector-choice.ts, sector-chunks.ts — and lib/jobs/classify-people/jev.ts) — one Choice per subject, kept only above its bar: service ≥ 0.99, person / unknown ≥ 0.8, a sector ≥ 0.85. Bounded concurrency (JEV_JOB_CONCURRENCY) and a per-job budget of Jev-only seconds (*_JEV_BUDGET_MS); every subject Jev does not place confidently, or does not reach in time, goes to the Claude batch exactly as before, and the pre-pass never throws (a failure sends every row to Claude). Every Jev response is validated in packages/core/src/judgment/validate.ts (every key present, right type, a choice among the options offered, values in [0, 1], and usage.input_tokens a non-negative integer — a missing output_tokens is recorded as 0); anything off-contract throws and takes the Claude path. unknownCompany / unrecognised (the flag that opens the paid web lookup) stays Claude-only, except for a contact with no employer, where the flag is false by definition and a confident "cannot be placed" settles as Claude's null. Jev usage folds into each job's existing per-chunk metering, so a sector chunk is still one credit; a chunk whose Claude call failed is written from Jev's placements but not billed (lib/jobs/jev/sector-merge.ts). A folded ai_actions row is labelled with its first non-Jev model (lib/jobs/record-ai-actions.ts, lib/ai/metering/record.ts). Budgets count only Jev time: each job's budget is a JevBudget pot (lib/jobs/jev/budget.ts) charged the union of in-flight time and split fairly per tenant, unused time rolling forward, and every pass inside api/jobs/daily is still clamped by, and charged to, the shared DAILY_JEV_TOTAL_BUDGET_MS (60s, lib/jobs/jev/run-budget.ts). The Jev settle units — both sector passes and person classification — meter, write and stamp in one real transaction on a core-only self-host too (lib/jobs/in-transaction.ts), where a scoped unit alone would autocommit. Thresholds were set on live evals over synthetic rows; not yet observed on a production run.

  • Jev in front of two more Claude calls, and nightly Jev budgets that count only Jev time (2026-09-24, lib/jobs/jev/budget.ts, lib/ai/agent/after-filing.ts, lib/ai/agent/types/write-words.ts, lib/messaging/process-session/derive-image-card.ts, utils/constants/jev/{after-filing,card-scan}.ts, packages/core/src/judgment/constants.ts) — Budgets. A nightly Jev budget is now seconds spent IN Jev, not a wall clock fixed at the job's start that ran through its web searches and batch fetches (in production that left the gates dead): each job's *_JEV_BUDGET_MS (persons 30s, contact sectors 20s, company sectors 20s, signals 20s, goals 20s) is a JevBudget pot charged the union of in-flight time, split fairly per tenant (an equal share of what is left, so unused time rolls forward), and still clamped by the shared 60s daily run budget. Goal recall goes deeper only when Jev will screen AND the job has Jev time left (docs/GOAL_ENGINE.md §3.9). No Batch API: the nightly Jev passes need a batch-capable provider, like the jobs themselves — person classification, both sector passes, signals and goal matching don't run at all (Jev included), exactly as before Jev. Without a batch a job cannot persist Jev's unsure rows or partly settled chunks, so Jev would re-ask and re-bill the same rows every night. Client: the TypeSafe API root is pinned in code (JUDGMENT_BASE_URL) so TYPESAFE_BASE_URL cannot redirect request bodies or the key; the client is cached per key. On a cold instance the first Jev call can run past its share and fall back to Claude — safe, only the saving is lost. The turn after a filing: after the agent loop files a note, Jev asks whether the user's message asks for any OTHER change; at p(yes) ≤ 0.1 the loop ends with state identical to the discarded extra turn answering. Capture path only (not the pending-question answer), 1s share; eval 0/70 false skips, 16-18 of 22 plain notes (~77%) skip. Residual: a request made in an EARLIER chat message is not shown to Jev. Forwarded-photo card scan: the transcript read runs first, then Jev (isCard ≥ 0.8 and hasPerson ≥ 0.9 → card; hasPerson ≤ 0.1 → photo note; else the fields vision read runs); no key = both reads in parallel exactly as before. Settled scans take about as long as before (transcript + Jev); when Jev is unsure or fails the reads run serially (transcript, then ≤1s Jev, then fields), so roughly transcript + fields instead of the slower of the two — about +3s per photo. Eval 55 texts × 2 runs, 0 confident disagreements. Billing is exact parity: Jev is asked through judgeEach and its tokens join the scan's one recordScanReads write, so a successful scan is one card_scan row (one credit) with Jev included and a failed scan records nothing. Thresholds set on live evals over synthetic data; not yet observed in production.

  • Jev empty-note gate for the note extraction: built, SHIPPED OFF (2026-09-24, lib/ai/note-extraction/empty-note-gate.ts, utils/constants/jev/empty-note.ts — EMPTY_NOTE_JEV_GATE_ENABLED = false; lib/ai/note-extraction/group/ is now a directory). Switched on, a user's note of up to 280 characters (the note job for a person, company or group, quick-add, the capture API, a chat-filed note, a chat answer) would be asked "anything extractable?" first, and at p ≤ 0.2 the Sonnet extraction would be skipped and the empty extraction run through the unchanged apply phase (same rows, outcome and credits). Why off: the owner's ship rule is zero skipped notes that the real Claude extraction finds anything in. The live eval (note-extraction/__tests__/empty-note-gate.live.test.ts, JEV_LIVE_EVAL=1, 63 synthetic notes including 18 held-out borderline ones, ground truth = the production extraction call) had a false skip in 2 of 4 runs, both on the same note: Jev scored "🎂🎉" at p 0.05, and Claude extracted a birthday fact and tags (plus a follow-up in one run). The other 2 runs passed, but their per-note output was not captured. Either way, a note that is sometimes skipped wrongly fails the rule, and no bar separates it from the truly empty notes. With the flag off, every note reaches Claude and Jev is never asked. Mocked suite lib/__tests__/empty-note-jev-gate.test.ts covers the ON path (driven through a mocked flag) and pins the shipped OFF state. To turn it on: change the question or model until the eval passes twice with zero false skips, then flip the flag and update this entry, docs/PROVIDERS.md and the dhaga-ai-calls skill.

  • "The week after Monday" no longer lands on that Monday (2026-09-24, packages/core/src/dates/follow-up-date.ts) — resolveDatePhrase matched the bare weekday and put the follow-up a week early. A span counted from an anchor now resolves to anchor + span ("the week after Monday", "2 weeks after Friday", "the week after tomorrow", "the Monday after next", "the weekend after next"), and any other "after" an anchor ("after Monday", "Friday the week after next") is unresolved, leaving the date for the user as every other vague phrase does. "Week after next" stays unresolved, as "next week" always was.

  • Jev in front of quick-add's text parse, by picking spans rather than writing fields (2026-09-24, lib/ai/contact-extraction/jev/, utils/constants/jev/contact-parse.ts) — code finds every candidate span (emails, phones and URLs by regex; lines, comma parts and word runs, lower-case chat included), and one Jev call picks name, title, company and location or "none", plus whether it is a note about a person and whether it is an instruction. Cascade-whole: every answer at least 0.8 and no code veto, or Claude runs as before and its whole output is used. Straight to Claude, without asking Jev: an email or phone with words beside it (a label Claude copies), more than 200 spans; after Jev: an instruction, or a note with no name. A Jev note's body is the capture verbatim. One contact_parse row and credit either way; no JEV_API_KEY = unchanged. Covers mobile's OCR-text parse too; card images stay on Claude vision. Eval (cascade.live.test.ts, JEV_LIVE_EVAL=1): Jev's accepted answers 97% whole-record correct on dev (Claude skipped 34/67) and 100% on held-out (10/24). Eval, two live runs against real Claude (2026-09-24), whole-record correct: dev (67 cases) Claude 84% / 81%, cascade 90% / 88%, Claude skipped on 34 / 36; held-out (24) Claude 79% / 71%, cascade 92% / 88%, Claude skipped on 10 / 10. The ship rule (cascade at least Claude on both sets in both runs) held, so it is ON whenever JEV_API_KEY is set. The one case the cascade lost (run 1: a company-only sign-off where Jev dropped a trailing "Office") is outweighed by Claude leaving the contact name empty on short notes, which the capture API needs, and copying a badge ribbon as a title. Not yet Jev-answered: contact-method labels (a possible follow-up is a Jev choice over the label words beside each value).

  • Opt-in morning follow-up reminder email (daily nudge for open follow-ups + due reach-outs via /api/jobs/daily, dummy-account exclusion via isDummyAccount()). Correction (2026-07-30): this job, the reach-out digest and the confirmations digest read their opt-in setting on an unscoped connection, so under packages/ee RLS the read matched zero rows and all three could never send for a hosted user, whatever their toggles said. All three now use the hostedTenants() + withUserDb fan-out (the shape follow-up-reminders / linkedin-export-reminders already had), with per-tenant local-day idempotency via lib/jobs/last-run.ts. 32 new unit cases model RLS by returning rows only inside a tenant scope, so the old implementation fails them; no live hosted send verified. Superseded 2026-08-09: all three are sections of the one daily brief (lib/jobs/daily-brief/) rather than separate jobs — see the "One daily email per person" entry below. See docs/FOLLOW_UPS.md and docs/TESTING.md §7aa

  • Follow-up calendar (/app/calendar; superseded 2026-08-13 — now the Month view of /app/plan) — month + agenda view of every open follow-up (getCalendarFollowUps), built on FullCalendar v6 (@fullcalendar/react + daygrid + list + interaction) themed to the amber tokens, not a hand-rolled grid (see docs/LIBRARIES.md §12); follow-ups are all-day single-date items. Shipped on feat/followups-calendar

  • Drag-to-reschedule a follow-up on the calendar (FullCalendar interaction plugin, pointer + touch), persisted via the follow-up repo/action layer

  • Calendar readability + filtering (2026-08-06) — four reported problems, one change. (1) Unscheduled chips are now real <button>s opening the same details dialog the grid does, so the truncated action text is readable at all — it was previously reachable only via a native title tooltip, invisible on touch and unreachable by keyboard. They stay FullCalendar drag sources; a pointerdown/click slop threshold (TRAY_CHIP_CLICK_SLOP_PX) separates the two, and keyboard activation is detected on event.detail === 0 rather than on the absence of a press point — a drag released off a chip fires no click, so the stale press point would otherwise measure a keyboard event's 0,0 as a long drag and silently swallow it. (2) Done follow-ups render on the grid and list, struck through and faded (.fc-done), never overdue and never draggable; loadFollowUps reads listTasks() (open + done) directly instead of listAllOpenFollowUps(), and getNotificationSummary / getDueFollowUpRemindersForUser narrow back with a named isOpenFollowUp first, so the nav bell and reminder email are behaviourally unchanged — widening what the calendar loads must never widen those. (3) Search + person + company + scope + status filters trim the grid and the tray from ONE predicate (filter-follow-ups.ts); a filter that moved only one of the two would misreport what "3 results" means. Person/company options derive from the loaded follow-ups, so the control can never offer a name with nothing behind it. External calendar events and birthdays are deliberately unfiltered. (4) General/People/Companies uses the same scope vocabulary as /app/tasks, now hoisted with the free-text predicate into lib/filters/follow-up-scope.ts and consumed by both screens — the two must not be able to disagree about what "General" means. reconcileResolvedFollowUpEvent was replaced by pure list transforms (applyFollowUpOutcome/applyFollowUpDueDate): once events derive from a filtered list, an imperative event.remove() is undone by the next keystroke, and an unscheduled item has no grid event to remove. lib/repo/reminders/calendar.ts split into a directory (150-line rule); import paths unchanged. Pending: no browser pass — the drag-vs-click discriminator, the <button> chip binding as a Draggable item, and the .fc-done cascade are covered by unit tests and reasoning, not a real interaction; screenshots not recaptured

  • Tasks + Calendar + Follow-ups merged into /app/plan (2026-08-13) — built, nothing verified beyond typecheck/unit level; no browser pass. Three routes were three windows onto the same follow-ups (a due date is a list row and a calendar chip), so they are now one page with a Day / Week / Month / List switcher; /app/tasks, /app/calendar and /app/follow-ups permanentRedirect to it and the nav carries one "Plan" entry. List is Dhaga's own task board, not FullCalendar's listWeek — it hosts per-row actions a FullCalendar row cannot — and it is the DEFAULT view, so the page opens on the work you owe; Day/Week needed @fullcalendar/timegrid@6.1.21 (new dep) and the view is switched via getApi().changeView() rather than by remounting, so paging to a date survives a switch. One filter state and one follow-up list feed every view (components/app/calendar/use-plan-board.ts), so a row completed in the list is gone from the month grid without a reload. The one real collision was the status axis: the calendar's all|open|done vs the task board's active|completed; resolved as the superset all|active|completed defaulting to active, which let components/app/calendar/filter-follow-ups.ts be deleted rather than kept in sync with components/app/tasks/filters — the cross-screen parity test (lib/__tests__/follow-up-scope.test.ts) was re-pointed at guarding that the surviving board still delegates to lib/filters/follow-up-scope instead of re-inlining a predicate. CalendarFollowUp gained createdAt at the reminders mapper, which is what let the second row type (and therefore the second filter module) go. Imported events are now clickable — a read-only dialog naming the calendar, account and location, which is the only way to tell a work meeting from a personal one on a merged grid; they stay undraggable and unsaveable. Dhaga's own calendar events (local_calendar_events) join the grid as a fourth kind with create/edit/delete; the form posts ISO instants built in the BROWSER, and all-day ends are stored EXCLUSIVE (pinned by LocalEventDialog/instants.test.ts). Every follow-up row offers an inline DatePicker in both views, showing the free-text dueHint a note-extracted follow-up arrived with — those rows were previously invisible on a calendar and unactionable without opening an edit form. The two orphan blocks from /app/follow-ups (due check-ins, upcoming dates) now render from one component in two places: a collapsible side panel on /app/plan (a Sheet under lg) while Home consumes the same due/date facts through Moments rather than duplicate tiles; its upcoming-dates read is hoisted out of buildDailySuggestions and injected back in, so HOME_DB_ROUND_TRIP_BUDGET is unchanged. Known gaps: no browser/screenshot pass at 375px or on the timeGrid views; the #due anchor is unreachable on mobile because the panel is a closed Sheet there; the notification bell still links /app/calendar and /app/follow-ups and relies on the redirects

  • Follow-ups carry an OPTIONAL time of day (2026-08-13) — built, typecheck/unit level only; no browser pass. A due date was a calendar DAY and nothing else, so "call Priya at 3pm" could only be written as a day and landed on the connected calendar as an all-day banner. Time is now optional and absent is still the default and the common case: an untimed follow-up is byte-identical to what shipped — due_date pinned to UTC midnight, an all-day chip, an all-day calendar event. The flag is a real column, follow_ups.due_has_time boolean NOT NULL DEFAULT false (lib/db/ddl/core/extend.ts), not an "is it midnight?" inference: that inference loses the time of anyone who genuinely schedules 00:00, and misreads every legacy row in a non-UTC deployment. DEFAULT false makes the migration a no-op. The instant is built in the BROWSER and only validated on the server (components/app/tasks/due-instant.ts → the hidden dueAt field → lib/actions/task-input.ts), because the server does not know which zone "15:00" was typed in; it reuses LocalEventDialog/instants.ts, which Dhaga's own calendar events already do this way, rather than growing a second pattern. Three consequences fall out: recurrence advances through the new pure nextRecurrenceInstant (packages/core/src/dates/recurrence.ts), which preserves the offset from the occurrence's UTC midnight so "every Tuesday at 15:00" does not come back at midnight on the second occurrence; overdue is by instant when there is a time (a 15:00 item is not late at 09:00 that day) and by the user's day boundary when there is not; and a timed follow-up is written to Google/Outlook as a timed event, 30 minutes long (FOLLOW_UP_TIMED_DURATION_MS) — a follow-up is a prompt to act, not an hour of the user's day, and an hour-long block per follow-up makes a light day look fully booked. The time control sits beside the date in all four surfaces (TaskForm, FollowUpDuePicker, AddFollowUpForm, FollowUpEditForm) via one shared DueFields, is disabled until a day is picked, and is clearable back to no-time; no default time is ever invented. A drag onto a time slot makes a row timed, a drag back onto a month cell or the all-day row makes it a plain day again. Covered by new cases in packages/core/src/dates/recurrence.test.ts, lib/actions/task-input.test.ts, lib/__tests__/tasks-due-time.test.ts, lib/__tests__/calendar-follow-up-event.test.ts (including both provider bodies), lib/repo/reminders/calendar/overdue.test.ts and the calendar event-map tests. AI-extracted follow-ups now carry the time too (2026-08-20) — the extractor writes it, and note extraction stopped throwing it away. followUpSchema.due_time is a required-but-nullable 24-hour "HH:MM" wall-clock in the USER's zone (resolveDatePhrase is zone-free by construction and cannot read a clock, so only the model can hand the time over), and lib/repo/graph/follow-up-due.ts is the one place both note writers (apply-extraction, apply-company-extraction) turn day+time into a row — the same three-case table the message-capture path uses: day+time ⇒ instantFromZonedWallClock, due_has_time true; day only ⇒ UTC midnight, all-day, byte-identical to before; no day ⇒ no date, because a time alone would have to invent a day. An unparseable time degrades to all-day rather than guessing an hour, and "00:00" is a real time (=== null, never a truthiness check). The zone comes from the ONE prefs read lib/ai/note-extraction/prep.ts already makes for today and is threaded through as ExtractionPrep.timeZone; the supplement-confirmation caller passes none, so the writer reads it lazily and only for a note that actually states a time. Behaviour change on the main capture path: a note saying "meet at 3pm" now creates a TIMED task and a timed 30-minute calendar event where it used to create an all-day one. Covered by lib/__tests__/extraction-follow-up-time.test.ts. Pending: no browser pass at 375px or on the timeGrid views; mobile parity not done

  • Home and /app/plan keep themselves current (2026-08-15) — built, typecheck/unit level only; no browser pass, and no run against a live Google connection. Three reported symptoms, one cause: "Next 7 days" showed 0 events on a first load and only corrected itself after a trip to /app/plan and back, a tab left open overnight showed yesterday's date until a manual reload, and an invite accepted elsewhere never appeared. All three are the same gap — Home renders from the free/busy snapshot and scheduled its refresh in after(), so the render asked for data whose arrival it had no way to show, and now was baked in at render time. The refresh moved to GET /api/calendar/pulse, which every open Home/plan tab polls once a minute while visible (plus immediately on becoming visible); it returns a short token hashing the user's LOCAL day plus the snapshot's intervals and titles (lib/calendar/pulse.ts), and the tab calls router.refresh() only when that token changes — so a poll is two indexed settings reads and a page render happens only when there is something new. A poll that finds the snapshot stale schedules the provider read in after() (same invariant as before: no tenant connection held across a Google round-trip) and the tab looks again in 2 s, at most 3 times, which is what closes the cold-start gap. FREE_BUSY_SNAPSHOT_STALE_MS 5 min → 60 s: it used to bound how stale a fresh render could be and now bounds how long an outside change stays invisible. A hidden or closed tab polls nothing and spends no provider quota. Known limit, deliberate: the token watches Home's seven-day window, so on /app/plan an event created further out appears on the next focus or navigation, not on its own. Not attempted: Google/Microsoft push channels — seconds instead of a minute, but they need a public webhook, per-channel renewal (Microsoft's expire in days) and a cron Vercel Hobby caps at one a day, and they fall back to exactly this polling on a self-host behind NAT

  • /app/plan renders from cache on a repeat visit (2026-08-17) — built, typecheck/unit level only; no browser pass, and no timing measured against a live Google connection. The page was slow to open and slowest of all on a return trip, because every render redid the same ~8 strictly serial awaits (they must stay serial — the tenant pool caps at 3, see docs/SCALING.md lever 2) and the last of them, the connected-calendar read, made outbound Google/Microsoft calls while the render held one of those connections. CalendarPulse made that worse rather than better: a router.refresh() on a changed pulse token re-ran the whole thing, provider call included. Three changes, no new Suspense boundaries and no concurrency added anywhere near the DB — concurrent RSC branches would each want a tenant connection, which is the documented outage. (1) The external read is cached per user + window + 5-minute bucket (lib/cache/external-events.ts, PLAN_EXTERNAL_EVENTS_CACHE_MS), through the existing cachePerUserVersioned — a time bucket rather than a data hash because the data lives in Google, drifts with no Dhaga write to observe, and so has nothing to invalidate on. The window is in the key for correctness: it is anchored on the FOCUSED day, so a link three months out must not be served another day's range. The rate-limit gate stays inside the read, so a cache hit spends no provider quota. The cost, stated plainly: an invite accepted in Gmail can take up to 5 min plus one poll to reach that grid — acceptable because the grid is three months of near-static data, while Home's free/busy path keeps its 60 s FREE_BUSY_SNAPSHOT_STALE_MS. (2) Per-calendar listEvents calls within one connection now run concurrently (repo/calendar/events/read.ts) — a user with 4 selected calendars paid 4 sequential 200–800 ms round-trips. This is the one sanctioned fan-out in that file and its docblock now says why: it is pure outbound HTTP that opens no DB connection, unlike the getDb()-under-Promise.all shape that has taken the pool down before. The loop over connections stays serial (usableAccessToken can refresh a token and queues row writes into a shared array), per-calendar failure handling is unchanged (one dead calendar increments failed, all-failed still marks needs_reconnect), and results are appended in target order so the grid cannot reorder between two identical reads. (3) Three redundant settings round-trips removed: the same schedule_prefs row was read 4–5× per render, because getCalendarFollowUps, listImportantDateOccurrences and listDueReachOuts each re-derived the user's zone on their own. getSuggestionSettings() now runs FIRST and prefs.timezone is threaded into all three through the injection slot lib/repo/reminders/local-today.ts already documented; each parameter is optional and defaults to the old self-read, so every other caller is untouched. readCalendarPulse still reads settings once and is deliberately left alone (Home shares that token). The page's reads moved to app/app/plan/load.ts (150-line rule; a route file cannot become a directory). Not fixed: a cold read still holds a tenant connection for one provider round-trip — only its frequency dropped

  • The plan board keeps its place — URL state, no page reset, and a width-keyed default view (2026-08-21) — built, typecheck/unit level only; the e2e specs were updated but NOT run (they need a dev server and a seeded PGlite). One reported bug and two things it exposed. (1) Completing a follow-up on page 3 of the List view threw the user back to page 1. PlanList built useReactTable({ data: items }) without autoResetPageIndex, which defaults to true — and completeTaskAction → revalidatePath("/app/plan") hands PlanBoard a fresh array, which mergeFollowUpUpdates (flatMap) → filterFollowUps (.filter) → sortTasks ([...].sort) each re-allocate unconditionally, so data changed by reference on EVERY render and TanStack queued a resetPageIndex() on a microtask. The hand-rolled resetKey effect that existed for exactly this was being silently overridden by it. Worse than a mis-click: CalendarPulse's background router.refresh() walks the same path, so an idle tab lost the user's page on its own. Fixed in three parts, and the split is worth recording because the obvious one-line version is not the whole fix. Pagination is now fully controlled from the URL — no useState, and critically no onPaginationChange — which is what actually stops the reset, since resetPageIndex() only lands somewhere if the table has a pagination writer to call. autoResetPageIndex: false stays as the half that keeps the door shut: someone will eventually want table.setPageIndex to work and re-add the writer, and without the flag that innocuous change silently restores the bug. Measured, not assumed — wire a writer back up and drop the flag, and tasks/plan-list-pagination.test.ts fails on the reset firing. Third, a clamp: completing the last row of the last page used to leave pageIndex past the end, which renders the "Nothing waiting" empty state over a list that is not empty. The render clamps so the user lands on the last real page immediately, and an effect corrects the URL so the address bar agrees with the screen. (2) View, search, scope, status, contact, company, sort, page and size now live in the URL through nuqs (components/app/calendar/use-plan-url-state/, the one file on this page that imports it — docs/LIBRARIES.md §3), joining the ?date= param that was already there. Options are { history: "replace", scroll: false, shallow: true }; shallow: true where the sibling use-table-url-state uses false, because DataTable's server mode paginates on the server while this board filters/sorts/paginates on the client over an already-loaded array, so a round-trip per keystroke would be pure cost. Every parser carries a default and clearOnDefault is on, so a default visitor still sees a bare /app/plan. A filter or sort change resets page in the same batched write — one URL update, which is what replaced the deleted resetKey effect rather than re-creating it in a new place. ?date= is untouched and stays deliberately one-way (docs/CALENDAR_PARITY_PLAN.md says why: FullCalendar's own prev/next do not write back either). (3) The default view is width-keyed. PLAN_DEFAULT_VIEW is gone, replaced by PLAN_DEFAULT_VIEW_COMPACT (timeGridDay) and PLAN_DEFAULT_VIEW_WIDE (timeGridWeek) behind useDefaultPlanView() over PLAN_WIDE_VIEW_QUERY = "(min-width: 1024px)" — a separate named constant from the identically-valued DESKTOP_QUERY, which answers a different question (the side panel's shape). This is the fix the parity plan's 375px caveat named and deferred: a seven-column week gives each day ~41px, which truncates a title to about three characters, and no free FullCalendar option widens it. Hydration-safe by construction — useSyncExternalStore with a mobile-first server snapshot, the mechanism useIsDesktop already uses, and the grid sits behind useCalendarMounted() so FullCalendar never mounts on the compact answer and then swaps; app/app/plan/loading.tsx's skeleton matches in pure CSS at lg, since a server component cannot read a media query. An explicit ?view= always beats the width default. e2e updated: plan-visual/views.spec.ts and week-density.spec.ts asserted Week is pressed at every viewport, which is wrong at 375 now; both are viewport-aware, and a new plan-visual/url-state.spec.ts covers the reported bug directly (complete a row on page 2 and stay on page 2) plus a reload that must preserve view/filter/sort/page. Pending: no browser pass, and the new specs have not been run

  • Editing a follow-up's due date says what it will do, and writes once (2026-08-31) — built, typecheck/lint/unit level only; no browser pass at 375px and no light/dark check. Three reported problems on the two surfaces that edit a due date, all of them about the CONTROLS rather than the dates. (1) The contact page's edit form confirmed with an icon. FollowUpEditForm ended in an amber tick (SaveButton) with a ✕ beside it, and the same row one state earlier carries a tick of exactly that size and colour meaning "mark done" — so the control that saves the edit read as "complete it" and its neighbour (which only clears the time field) read as "delete it". It is now a worded SubmitButton "Save" next to the existing Cancel. Nothing on that form ever wrote before the button was pressed; the fix is entirely about saying so. SaveButton stays as-is for FactEditForm, whose read-mode row has no tick to be confused with. (2) /app/plan's inline due picker wrote on every change. FollowUpDuePicker POSTed rescheduleFollowUpAction from onDueDateChange AND onTimeChange, so setting "the 3rd at 15:00" was two round trips, two toasts, and one intermediate save of a date the user had not finished choosing — and a mis-tap was already persisted, with the old date gone. It now holds a draft: local state that is null while clean (so an untouched row still follows a re-date from a drag or the details dialog, which is why the props were the source of truth in the first place), and Save / Revert appear only once the pair actually differs from what is stored. A failed save keeps the draft on screen. This makes the two surfaces agree — before, a user who learned one was wrong about the other. (3) A follow-up could be dated into the past. DueFields now passes fromToday and quickPicks to the shared DatePicker, so all five forms that set a due date (FollowUpEditForm, AddFollowUpForm, TaskForm, FollowUpFromSelection, FollowUpDuePicker) grey out past days and offer Today / Tomorrow / Next week chips above the grid — the friction was tap count, not the calendar. The floor is futureFloor (components/ui/date-picker/future-days.ts), which is today except when the row already holds an earlier day: an overdue follow-up is the normal case, and a hard floor would grey out the very cell it sits on, so its edit form could not be re-saved without also re-dating it. Computed from the picker's LOCAL-day value, never the stored UTC midnight — comparing those directly is wrong by the viewer's offset, a whole day either way in Kolkata or New York. Both new props are opt-in and default off: a birthday or a "we met on" date must still accept the past, and a quick pick there would be a wrong answer offered in one tap. Past times on today are deliberately NOT rejected — the calendar can only express days, and a 09:00 row read at 22:47 is overdue by the clock but not by the day. Covered by components/ui/date-picker/future-days.test.ts. The popover's Clear footer moved from date-picker/index.tsx into PickerCalendar so both files stay under the 150-line rule. Pending: browser pass, mobile parity (there is no follow-up edit UI in apps/mobile at all), and the dead updateFollowUpAction in lib/actions/follow-ups.ts — unreachable, but its docblock still claims to be the edit form's action — is left for its own change

  • In-app notification bell — nav bell with overdue + due-today follow-up counts and a capped preview (getNotificationSummary)

  • Notification feed (2026-07-30) — built, not browser-verified. The bell (moved to components/app/AppNav/NotificationBell/, header renamed "Reminders" → "Notifications") now carries three kinds in one list: derived follow-up reminders, derived upcoming important dates, and persisted notifications from a new notifications table (lib/db/schema/notifications.ts, DDL lib/db/ddl/core/notifications.ts, enrolled in EE TENANT_TABLES). A row is written when an extraction/enrichment job reaches a terminal state (job_done / job_failed / job_blocked) — the first thing that tells a user a background job finished after they navigated away. Dismissible + mark-read (status unread → read → dismissed; rows are never deleted, so a re-run cannot resurrect a dismissed one). Both FKs (contact_id, job_id) are ON DELETE CASCADE because titles embed contact names, so the privacy cascade must take them. Badge counts overdue + due-today follow-ups, unread notifications, and important dates only on the day — a birthday six days out is in the panel, not on the badge. Covered by __tests__/notifications.test.ts + __tests__/notification-feed/; click-through pending (docs/TESTING.md §7y)

  • Background-job alert email (2026-07-30) — built, not browser-verified. Opt-in (job_email_notifications_enabled, default off) email when an extraction/enrichment job fails or is blocked, capped at one per user per 15 minutes (JOB_EMAIL_COOLDOWN_MINUTES). Successful runs are deliberately not emailed — they ask nothing of the user and the notification row is already the record; emailing every success would get the channel muted and take the failure alerts with it. The in-app notification still fires for every job either way. The send happens strictly after the notification's withUserDb scope commits and releases (never across an open connection), and a send failure can neither fail the job nor lose the notification; a bounce does not write the cooldown stamp, so it cannot silence the next window. lib/email/job-notification.ts + lib/repo/notifications/job-email.ts; covered by __tests__/job-notification-email.test.ts

  • Birthdays & anniversaries as real reminders (2026-07-30) — built, not browser-verified. contacts.important_dates (JSONB, free-text values: YYYY-MM-DD, year-less MM-DD, or verbatim December 9 from the Google/vCard importers) now drives reminders that are derived, with no new table and no DDL — so editing a date or forgetting a contact needs no cleanup. Pure recurrence maths in packages/core/src/dates/ (Feb 29 clamps to 28 Feb in a non-leap year, local-midnight arithmetic, never toISOString()); reads via listUpcomingImportantDates(leadDays) / listImportantDateOccurrences(range) (lib/repo/reminders/important-dates.ts). Surfaces: read-only all-day entries on the calendar views of /app/plan (/app/calendar when this shipped) (not draggable, no Done/Reschedule — a birthday is not a task; edited on the contact), an Upcoming dates block in /app/plan's side panel, due occasions folded into Home Moments, and the nav bell. The contact form gained a real calendar picker with a year dropdown (1900 → current+10, components/ui/date-picker/, which also now opens on the selected date's month rather than today); verbatim and year-less stored values are preserved byte-for-byte and shown as placeholder text. Covered by packages/core/src/dates/important-dates.test.ts + upcoming-date.test.ts + calendar/__tests__/event-map.test.ts; click-through pending (docs/TESTING.md §7x). (web only — mobile parity pending)

  • Birthday/anniversary reminder email (2026-07-30) — built, not browser-verified. lib/jobs/daily-brief/sections/important-dates.ts (+ sections/important-date-state.ts, was lib/jobs/important-date-reminders/) + lib/email/important-date-reminder.ts, wired into /api/jobs/daily with the same per-tenant withUserDb fan-out. Gated on a new important_date_reminders_enabled setting (default off — imported address books arrive full of dates the user never reviewed) with a configurable important_date_lead_days (0–90, default 7). Anti-spam: at most two emails per occurrence — one when it enters the lead window, one on the day — keyed on [contactId, label, occurrenceDate, stage] tokens stored under the important_date_reminders_sent settings key (a JSON array, mirroring linkedin_export_reminders_sent; no table, past occurrences pruned). Covered by daily-brief/important-dates.test.ts; no live send. Superseded 2026-08-09: it is the "Important dates" section of the one daily brief now, not its own email; the toggle, the lead time, the two-per-occurrence rule and the important_date_reminders_sent key are all unchanged

  • Per-user IANA time zone (2026-07-30) — browser-verified, and it now prompts instead of waiting (2026-08-14). Set in Settings → Suggestions (components/app/settings/TimezoneSetting/), stored as SchedulePrefs.timezone inside the existing schedule_prefs blob, default "UTC" so existing users see no change until they choose; helpers in lib/time/zone.ts are Intl-only (no date-fns-tz, and it lives in apps/web not packages/core because Intl.supportedValuesOf is unreliable on Hermes — see docs/LIBRARIES.md). The setting no longer waits to be found: it was correctable only by opening Settings and noticing the passive Use detected zone link, and a stale zone is the worst kind of bug here — it never errors, it just puts every reminder, birthday and "due today" on the wrong morning. components/app/TimezoneMismatchBanner/ (TimezoneMismatchBanner.tsx, session.ts, index.ts, __tests__/) now raises a banner whenever the browser's IANA zone differs from the saved one, mounted in app/app/layout.tsx in normal document flow, immediately after LapsedPlanBanner and above {children} — so it reaches a user landing on any /app route, not only Settings. In-flow placement is also what keeps it off a collision course with the two onboarding surfaces: the /app crayon annotation overlay is fixed inset-0 z-50 behind an 0.88 scrim and driver.js paints its own overlay over Settings, and an in-flow banner can neither stack on, dim, nor steal a click from either — while an overlay is up it simply sits behind it and is there when it clears. It offers Switch to the detected zone, a Pick a different zone link to /app/settings#suggestions, and a dismiss X; accepting posts to the same setTimezoneAction (lib/actions/suggestions/schedule.ts) the settings card already uses, so there is no second write path. Dismissal is sessionStorage (dhaga:timezone-notice), not localStorage, and there is deliberately no "don't ask again" — dismissing silences it for that tab session only, so signing in again or reopening the tab prompts once more. Known and accepted trade-off: someone who wants a saved zone different from their browser's (managing a team in another zone, travelling briefly) is prompted once per session, indefinitely; the escape hatch is to save the zone they actually want. Zones are compared canonically (canonicaliseZone in the new lib/time/browser-zone.ts) so IANA alias pairs — Asia/Calcutta/Asia/Kolkata, Europe/Kiev/Europe/Kyiv — count as the same place; without it a user in India would be prompted every session forever, because this repo's Node ICU lists Asia/Calcutta while newer browser ICU says Asia/Kolkata. The browser-zone read (readBrowserZone, humaniseZone, subscribeNever, readNoZone) moved out of TimezoneSetting/zone-options.ts into that new module so both surfaces share one detector; zone-options.ts keeps only the combobox option shaping. app/app/layout.tsx now calls getSuggestionSettings() where it called getImportantDateLeadDays() — both are ONE settings round-trip, but the former also carries the saved zone, so the banner costs no extra DB read. Scope limit as shipped: reminders land on the correct day in the user's zone, but every email still sent at one global time. Superseded 2026-09-01 — the send time is now a per-user preference (schedule_prefs.digestHour/.digestMinute, default 09:00, quarter-hour grid) compared with minutesSinceLocalMidnight — but only where something drives /api/jobs/tick every ~15 min; the once-a-day /api/jobs/daily deliberately does not enforce it and stays at "17 6 * * *". The preference stopped being inert on 2026-09-04: the account moved to Vercel Pro and apps/web/vercel.json gained a SECOND cron, {"path": "/api/jobs/tick", "schedule": "*/15 * * * *"}, alongside the unchanged daily one. A self-hosted instance has no Vercel cron and still drives that route itself. Covered by timezone-zone.test.ts + timezone-settings.test.ts + TimezoneMismatchBanner/__tests__/session.test.ts + __tests__/TimezoneMismatchBanner.test.ts (15 cases, all passing; jsdom with createElement and real react-dom/client, since this repo has no @testing-library). What "browser-verified" covers: the picker and the new banner, rendered in Chromium under a Playwright timezoneId override at 375px and 1440px, light and dark — that pass is what caught the alias bug above, which every unit test had agreed with

  • Web Push / OS-level notifications — built (this line read "not built" until the 2026-09-06 audit). A service worker with push and notificationclick handlers and deliberately no fetch handler — Dhaga has no on-device store, so a caching worker would make the app look like it works offline. Still owed: no notification has yet been delivered to a real device on any platform

  • Daily due-follow-up reminder email — per-tenant sweep (since 2026-08-09 the "Follow-ups due" section of the daily brief, lib/jobs/daily-brief/sections/follow-ups.ts; was lib/jobs/follow-up-reminders.ts) emailing each opted-in tenant a "N follow-up(s) due" summary of overdue + due-today follow-ups (getDueFollowUpRemindersForUser); reuses the morning-reminder opt-in (morning_reminder_enabled), per-tenant fan-out via hostedTenants() + withUserDb, contact names HTML-escaped, no email when nothing is due or Resend is unconfigured; pure subject/HTML/send-guard covered by daily-brief/follow-ups.test.ts. Email sweep built + unit-tested this change; calendar/bell UI built by the sibling agents on the same branch. Lead window added 2026-07-30: the email set is now overdue, due today, or due within FOLLOW_UP_LEAD_DAYS (3), each row tagged Overdue / Due today / Due tomorrow / Due in N days — before this an item due in three days was never emailed until it was already late. The bell's predicate was deliberately left alone (its badge means "act now")

  • One daily email per person — the daily brief (2026-08-09) — built, not verified by a live send. A user was receiving three emails inside one minute, because the reach-out digest, the confirmations digest, the morning reminder, the due-follow-up sweep, the birthday/anniversary reminder and the LinkedIn nudge each decided independently that it was allowed to send. All six are now sections of one consolidated email (lib/jobs/daily-brief/: index.ts tenant fan-out, sweep/ the one send decision, subject.ts, activation.ts, one file per section under sections/), in urgency order — follow-ups due → important dates → waiting for your review → people to reach out to → also waiting → your LinkedIn export — with the subject taken from the first section that has content. Every section still honours the opt-in it always had (morning_reminder_enabled for follow-ups and the ambient totals, important_date_reminders_enabled, confirmations_digest_enabled, daily_digest_enabled for reach-outs; the LinkedIn nudge still has no toggle — clicking "Get contacts from LinkedIn" is the opt-in). If every enabled section is empty, nothing is sent — no "you have nothing today" email, which is the disturbance this change exists to remove. daily_digest_enabled doubles as the daily check-in: with nobody due from the suggestion engine the reach-out section falls back to going-quiet contacts (listQuietContacts, capped at QUIET_CONTACTS_IN_BRIEF = 3), and when that is empty too the section — and possibly the whole email — is dropped. Activation nudge: an account with zero contacts and morning_reminder_enabled on gets a short "add your first contact + read the guide" email instead of the brief, at most ACTIVATION_NUDGE_MAX (3) sends ACTIVATION_NUDGE_INTERVAL_DAYS (7) apart, tracked in the activation_nudges_sent settings key, then silence forever; never in addition to a brief, and an established account having a quiet day still gets nothing. Opt-out footer: the brief and the nudge render through the new notificationEmailShell() (lib/email/send.ts), which always appends "If you'd rather not receive these, turn them off in Settings → Suggestions" pointing at /app/settings#suggestions via the shared emailLinkBase() (moved out of lib/auth/config/emails.ts so every mail links absolutely); transactional mail — welcome, verification, password reset, magic link, access requests, admin notices, feedback, the user-triggered event digest, background-job notifications — deliberately keeps the plain emailShell(), because no setting turns those off. Duplicate suppression is now one record, daily_brief_last_local_day, covering brief and nudge together; the five retired per-job rows are inert and left in place, so on the day this ships a user who already received the old emails that morning can also get one brief. /api/jobs/daily returns a single dailyBrief key ({ sent, activation, skipped }) where it returned six. Deleted: lib/jobs/{morning-reminder,daily-digest,confirmations-digest,follow-up-reminders,important-date-reminders,linkedin-export-reminders}; lib/email/morning-reminder.ts → lib/email/pending-summary.ts. Unchanged: the hostedTenants() + withUserDb fan-out, the clean no-op without Resend, and a missing settings row still reading off. (The EMAIL_JOBS_HOURLY local-08:00 gate this inherited was deleted 2026-09-01 — replaced by a per-user send time enforced only by the route that drives the job; see the time-zone entry above.) Covered by daily-brief/{index,activation,follow-ups,important-dates}.test.ts (headline: one email where there used to be three) and lib/__tests__/email-opt-out-footer.test.ts; manual cases in docs/TESTING.md §7aa

  • The daily brief goes out at a time the user chose, on their own clock, down the channels they chose (2026-09-01, lib/jobs/daily-brief/sweep/, lib/jobs/daily-brief/chat.ts, lib/messaging/outbound/, utils/constants/digest.ts, app/api/jobs/tick/) — every user was emailed on the ONE cron run at 06:17 UTC, which is 11:47 in Delhi, 07:17 in London and 23:17 the night before in Los Angeles. The zone was already stored (schedule_prefs.timezone) and already decided which calendar day the brief reasoned about; what it never decided was when the mail went out. Now digestHour/digestMinute (default 09:00, quarter-hour grid) ride on the same blob — one clock, one card, so a send time and the zone that interprets it cannot be written by two places and drift, which is the failure that retired utcOffsetMinutes. The gate is minutesSinceLocalMidnight(now, tz) >= digestHour*60 + digestMinute — "has their time passed today", not "is it that minute now". >= is the whole design: combined with the per-local-day record it is self-correcting and frequency-agnostic, the FIRST run at or after their time delivering and every later run finding the record. === would drop a user for the whole day on any late run, which is exactly what the retired EMAIL_JOBS_HOURLY gate did and why it had to be opt-in. The MINUTE is not decoration: India is UTC+05:30 and Nepal +05:45, so "09:00 for the user" is not expressible as a UTC hour at all and isLocalHour could not have answered this — zonedParts has always carried minutes and minutesSinceLocalMidnight is what finally uses them. ENFORCEMENT IS DECIDED BY THE ROUTE, NOT BY A CONFIG, and that is a bug caught before it shipped: a gate that only opens once the user's local time has passed can, on a job driven ONCE A DAY, never open at all — 06:17 UTC is 07:17 in London, which is before a 09:00 preference today, tomorrow and forever, so those users would have stopped receiving the brief entirely. So /api/jobs/daily passes enforceSendTime: false and behaves exactly as it always has, and the new /api/jobs/tick (same CRON_SECRET bearer, fails closed) passes true. That route was called /api/jobs/digest when this entry was written and is /api/jobs/tick as of the same day, because it stopped being the brief's route: it now also drives the pre-event nudge sweep (entry below), and an operator adding one cron line who reads "digest" would take it for a duplicate of the email /api/jobs/daily already sends, skip it, and lose every nudge on the instance with nothing failing and nothing logged. EMAIL_JOBS_HOURLY, MORNING_REMINDER_HOURLY, MORNING_REMINDER_LOCAL_HOUR and isHourGateEnabled() are deleted — the endpoint that ran is a fact, where the env var was a thing an operator had to get right and mostly did not. THE CRON EXISTS AS OF 2026-09-04, AND THE ROUTE IT NAMES MATTERS. apps/web/vercel.json was deliberately unchanged while this ran on Vercel Hobby, where a sub-daily cron entry breaks the deploy, so digestHour/digestMinute were a stored preference with no effect and the brief landed on the daily run. The account is now on Pro and {"path": "/api/jobs/tick", "schedule": "*/15 * * * *"} was added ALONGSIDE the untouched {"path": "/api/jobs/daily", "schedule": "17 6 * * *"}. Pointing the DAILY entry at */15 instead would have been the wrong fix — it drives eleven once-a-day jobs and would re-send transactional email — which is exactly why the send-time gate is enforced per ROUTE and not globally. It now fires; that is not the same as proven, see the closing line of this entry. A self-hosted instance has no Vercel cron and still schedules the route itself. CHANNELS: digestChannels sends the brief to email, to the linked WhatsApp/Telegram chat, or both — both default ON, a change in what an existing user receives, but a channel with no linked identity is skipped whatever the preference says, so it only reaches people already using the bot. The chat rendering is the "what's due today" slice (follow-ups + important dates, capped at DIGEST_CHAT_MAX_ITEMS with a +N more and a link to /app); reach-outs, confirmations, pending and LinkedIn stay email-only, because chat is glanceable and a message that needs scrolling is one nobody reads. It carries names and counts only — never the follow-up's action text or a date's label, both being user-authored prose about a third party on a payload that leaves for Meta. Idempotency is PER CHANNEL (daily_brief and daily_brief_chat), which is load-bearing rather than tidy: one shared record forces an all-or-nothing choice where either a WhatsApp outage costs the user tomorrow's email, or every WhatsApp failure re-sends the email that already arrived. The activation nudge stays email-only — a chat message to somebody with no contacts is a different product decision. THE 24-HOUR WINDOW is the constraint that shaped the outbound half. Every message Dhaga had ever sent was a REPLY inside a webhook, so Meta's rule that free-form text is only deliverable within 24h of the user's own last message had never come up — and a 09:00 digest is outside it by definition. MessagingClient gains an OPTIONAL sendTemplate (the pattern verifyWebhookChallenge? already set for a Meta-only capability, so Telegram implements nothing and keeps using sendText), messaging_identities gains last_inbound_at (appended DDL, ADD COLUMN IF NOT EXISTS), and sendScheduledChatMessage picks text-or-template per chat. A NULL last_inbound_at reads as closed: a template that was not needed still arrives, whereas free-form text that was not allowed is dropped by Meta with nothing to show for it, so unknown degrades toward the message that lands. A failed free-form send on a template-capable channel retries ONCE as a template rather than pattern-matching an error code Meta is free to renumber. Telegram works today; WhatsApp digests need an approved UTILITY template (WHATSAPP_TEMPLATE_DIGEST, WHATSAPP_TEMPLATE_LANG, read at send time so a resubmission costs an env edit not a deploy) — until then they degrade to free-form and are dropped by Meta for anyone who has not written in within 24h. Settings card at /app/settings#suggestions, below the time-zone card it points at. Tests: lib/__tests__/digest-schedule.test.ts (the half-hour zones, the >= semantics, and the case proving the gate would never fire on a once-daily cron), plus daily-brief/index.test.ts for per-channel marking under a chat outage and both routes on one local day sending exactly once. NOT verified against a real WhatsApp or Telegram message, and no Meta template has been approved.

  • The chat brief reads as a morning, not a report (2026-09-06, lib/jobs/daily-brief/greeting.ts, lib/jobs/daily-brief/sections/today/, lib/jobs/daily-brief/chat.ts, utils/constants/digest.ts) — the WhatsApp/Telegram brief was a set of independent blocks with no greeting, no sense of the day's order and no calendar section at all, so nothing in it knew when the day started (docs/AGENTIC_MESSAGING.md §8B). It now opens "Good morning" plus their own first name read from me_profile — the one name in this system that belongs to the recipient rather than to a third party, so saying it out loud on a channel we do not own discloses nothing — with the time of day computed in the user's own IANA zone (the same prefs.timezone the sweep already resolved, not a second source of truth), and no name at all when the profile is absent or blank: a guessed name is worse than none. A new Today section then reads the connected calendar and says when the first commitment is and how many follow. It is gated on morning_reminder_enabled, the same opt-in the follow-ups section uses: every section that describes the reader's contacts sits behind a stored preference that defaults to OFF, and digestChannels is not one of those — it defaults ON and only decides where a brief goes. (sections/linkedin.ts has no toggle by design — clicking "Get contacts from LinkedIn" is its opt-in and it is about the reader's own pending export — so a user with every category off can still receive a daily email during that window, though never a chat message.) Without the gate a user who had deliberately switched every category off would have started receiving a daily message with no setting anywhere that stopped it. It carries a TIME and a COUNT and never an event title — this text leaves our infrastructure for Meta's or Telegram's, and a meeting name can be far more revealing about a third party than the fact a meeting exists; the email keeps the detail. The mirror filter is the load-bearing part: syncFollowUpToCalendars copies every follow-up onto the connected calendar, so a naive read sees each one twice and the summary would announce the same commitment in two consecutive sentences — the exact bug the pre-event nudges shipped, so the section excludes Dhaga's own copies via mirroredEventKeys()/mirrorKey() and a test pins it. Silence still stays silent: greetChatBrief takes a string, not a string | null, so a greeting is structurally a prefix to a brief that exists and can never be the reason one is sent. Chat order (DIGEST_CHAT_SECTION_ORDER) is the day's shape first, then what is due; the email keeps its urgency order so the subject line is unchanged. Which day it is about is computed, not assumed (lib/jobs/daily-brief/send-window.ts): /api/jobs/daily is a single cron at a fixed UTC minute with enforceSendTime: false, so one run serves every zone — 06:17Z is 07:17 in London, 11:47 in Delhi, 16:17 in Sydney and 23:17 the previous evening in Los Angeles. Reading the greeting and the window off now therefore said "Good evening" in the Americas and looked for commitments in a 43-minute remnant that is empty almost every night, so the section silently never appeared for a whole hemisphere. The rule now: evening or later, the brief is about tomorrow — the whole next local day, headed Tomorrow rather than lying with Today; otherwise it is the rest of today from now. The greeting says when the message is being READ; the heading says what it is ABOUT — two different facts, which is why "Good evening" over a Tomorrow heading is honest rather than a contradiction, and why greeting.ts reads ctx.now and nothing else. The first cut tried to make the greeting sound like the hour the user chose and produced words true of neither fact: London with digestHour 20 was greeted "Good evening" at 07:17 BST under a Today heading; Los Angeles with digestHour 14 got "Good afternoon" at 23:17 on a Thursday night under a Tomorrow one; and with no calendar section at all — an empty or unconnected calendar, the ordinary case the fixtures could not catch because they always stubbed an event — Los Angeles got "Good morning" at 23:17 over a list of that Thursday's follow-ups, with no Tomorrow heading anywhere in the message. digestHour cannot answer that question: it is the hour they wish it arrived, and the daily cron delivers at a fixed instant regardless (DIGEST_HOUR_MIN/MAX are 0 and 23, and every hour between is offered). The old fixtures hid this — every brief spec swept at 20:00Z against UTC+14, the one instant that looks right without any of this. Scope discipline matches the nudge sweep — readExternalEvents is handed runScoped and the mirror receipts are read in their own short scope after the provider call, so no tenant connection is ever held across a Google/Microsoft round-trip. Pending: no live WhatsApp send has been read on a phone.

  • A capture that names nobody is kept, not thrown away — unfiled notes (2026-09-06, lib/db/ddl/unfiled-notes.ts, lib/repo/notes/unfiled/, lib/actions/notes/unfiled.ts, lib/messaging/process-session/apply/unfiled.ts, docs/AGENTIC_MESSAGING.md §7) — "call the electrician Tuesday" belongs to no contact and no company, so rung 2 of the unclear ladder wrote the reminders and threw the sentence away: the user's own words survived only in the settings-page capture log, against the message they arrived on. They are real rows now — a notes row with all four owner columns NULL and an explicit user_id — listed on /app/lists (they shipped under the plan board's General scope, beside the contact-less reminders they came with, and moved off it — a note that named nobody has no date). A notes row, not a new table, and the deciding reason is that filing must not change the note's id: the capture log links by note id, so a copy-then-delete would break exactly the link the feature exists to restore (it also inherits search_tsv, the tombstone, the messaging re-drive's source_item_id idempotency and deleteNote's already-owner-agnostic cascade for free). "Exactly one owner" gains a deliberate third state, not a hole: assertSingleOwner is unchanged and still refuses an owner-less row; a sibling assertUnfiledOwner admits zero owners only together with a user id, and is a separate function precisely so none of its ~20 existing call sites can reach that state by passing nothing. notes.user_id is declared in CORE, not left to packages/ee's per-table loop — the writer names the column, so a core-only self-host (where that loop never runs) would otherwise 500 every rung-2 capture on "column user_id does not exist"; notes therefore joins CORE_DECLARED_USER_ID_TABLES, without which the erasure scope probe inverts and every core-only self-host reads as tenanted and skips its whole graph (the follow_up_occurrence_overrides bug again). No EE change was needed: notes was already in TENANT_TABLES with an isolation spec. No facts and no embedding, deliberately — both tables are keyed to a subject and there is none; an unverified fact about nobody is not a fact — so filing is what backfills them, and it reuses the inbox card's own second half (finishNoteAnswer) rather than forking it, which is what also retires the capture-time reminders the note already wrote. Without that retirement the user holds both renderings of every sentence — "check with him for his feedback" beside the resolved one — the duplicate retire-capture-tasks.ts exists to close. The spec's "not searchable until filed" is REVERSED and no longer said anywhere: the owner decided against semantic search for these rows, and keyword search never needed an owner (notes.search_tsv is a generated column) — what was missing was a branch, since every notes branch of the combined query INNER JOINs contacts. They surface as a record hit, the same shape tasksBranch already uses for the contact-less follow-ups they complement. Deletion is its own action because nothing cascades to them — every note cascade in the app is keyed to an owner — and account erasure removes them by that same user_id. Fixed on the way: /app/notes/[id] used to notFound() on a note with no owner, which is the route the capture log links to, so a user clicking through to their own words got a 404. Superseded in part: answering "not about a person" no longer loses the words on a card raised by messaging, because a parked question now writes the note before it asks — see the entry below (§7.8). It still loses them on an inline quick-add card, whose applier chain still carries no acting user.

  • A parked "who is this about?" keeps its words while it waits (2026-09-07, lib/messaging/process-session/apply/park/, apply/unfiled.ts, lib/repo/notes/unfiled/adopt.ts, lib/repo/confirmations/apply/note-subject.ts, lib/repo/confirmations/create/note-subject.ts, packages/core/src/schemas/confirmations.ts, docs/AGENTIC_MESSAGING.md §7.8) — unfiled notes closed rung 2 of the unclear ladder; this closes rung 3, the one that actually asks. A forwarded message naming somebody we could not place raised a card and wrote no note at all — the words lived only inside confirmations.payload.apply.noteBody, so a capture the owner had dictated was visible nowhere until they answered it. Their requirement was the opposite: "till unsure it's in personal reminders, as soon as I answer, the note goes under the right person", and only the second half was true. parkNoteSubject now writes the unfiled row first and the card carries its id. The note is the truth; the card points at it — answering moves that row (fileUnfiledNote for a person, a new fileUnfiledNoteOnCompany for a company) and never creates one, keeping the same id, body and source_item_id so the capture log's link keeps resolving and the messaging re-drive stays idempotent. The duplicate is designed out, not guarded against: the card cannot be created without already knowing which row it points at, so "create a note on resolve" is unreachable for any card raised this way — the same shape as the "one message, one place" regression, where two paths independently wrote one outcome. There is now exactly one writer of an unfiled capture note, shared by both rungs (apply/unfiled.ts, moved up out of apply/unclear/), and its source_item_id key changed from seqs[0] to min(seqs) so a re-driven batch cannot key off a re-ordered plan and write a second row. A MEETING park deliberately writes no row: its answer writes a shared group note and there is no unfiled→group filing primitive to move an existing one with, so a row there would leave the user holding both. Dismissing keeps the note — it still drops the reminders, but "stop asking me who this was about" is not "throw my words away", and this is strictly better than before, when dismissing lost the words outright. No backfill and none needed: noteId is optional, and a card raised before this takes the old create-on-resolve path unchanged. No organization_id stamp, matching addUnfiledNote's existing contract — the row is the capturing user's own, written under the batch's own withCaptureDb scope; the tenant predicate is user_id OR organization_id and the unfiled list applies no workspace filter, so it reads in either workspace, while the stamp would be irreversible and would put the note out of step with its own card, which insertConfirmation does not stamp either. The "Saved to …" reply line is untouched. Audited, and intended: the note now shows earlier in the plan board's unfiled section, in keyword search, in Home's Notes tile and sparkline, in year-in-review and in the "wrote a note" onboarding step — and nothing double-counts it, because no surface adds the inbox count to the unfiled count and countUnfiledNotes has no production caller. Pending: ParkedTarget now carries the noteId but nothing consumes it — routing a chat answer straight onto the row is the follow-up.

  • Chat transcripts are actually deleted at the end of their retention (2026-09-06, app/api/jobs/daily/route.ts, lib/repo/messaging/conversation/retention-scope.ts) — sweepExpiredConversations shipped with PR #361 and had zero call sites, so the agentic bot's per-chat memory accumulated past its retention window forever while the read path merely ignored expired rows — and a row that is ignored is still a row holding the user's sentences. It is now registered in the daily cron as conversationRetention, early in the run rather than in the network-bound tail, because an unkept retention promise is the failure here that gets worse the longer it goes unnoticed. It runs under a cross-tenant RLS bypass (the same transaction-local app.bypass_rls the nudge claim uses, wrapped in its own named helper): the table carries FORCE ROW LEVEL SECURITY, a cron has no signed-in user, and on the ordinary connection the delete would have matched zero rows and reported a clean run forever. The admitted uses of that bypass are now an enumerated list, kept in agreement across packages/ee/src/tenant/nudge-claim.ts (authoritative) and the core-side gate type — a third one needs an argument of the same kind, not a quiet extra caller. The delete is batched (AGENT_CONVERSATION_SWEEP_BATCH, 500 rows per statement, each its own transaction, counted with rowsAffected rather than .returning()): with no caller until now, the first run on any instance meets the entire accumulated backlog, and one unbounded DELETE over that is a long transaction against a table the inbound webhook writes to on every message — a statement or pooler timeout would roll it back identically every night, leaving the promise unkept while the job logged a recurring failure. Pending: not exercised against a real hosted database.

  • "Where is my reminder to renew the licence?" — the agent said it did not exist; it was due three minutes ago (2026-09-08, apps/web/src/lib/ai/agent/observe/due/, apps/web/src/lib/ai/agent/clock.ts, apps/web/src/lib/ai/agent/actions/dispatch.ts, apps/web/src/lib/repo/tasks/queries.ts, packages/core/src/schemas/agent-turn/, packages/core/src/llm/prompts/agent-turn/, docs/AGENTIC_MESSAGING.md §6.9) — a general, contact-less follow-up, due today and a few minutes past its time. The bot answered that it could not see it "anywhere in your next 90 days of due items", that "searching by name, nickname, tag and note text turns up no reminder record at all", and that it might be among "72 open reminders that carry no due date". Four separate defects had to line up to produce one confident falsehood about the user's own data, and all four are fixed here. 1. The window was head-sliced. listTasks orders most overdue first and observeDue took the first 20, so an item due TODAY sits at the BACK of an overdue backlog and reached the model only on an account with fewer than twenty older overdue items — this one had seventy. The window is partitioned now: the most RECENT overdue (a tail slice, because the oldest of a long backlog is the least likely thing anyone is asking about) plus the soonest upcoming, with AGENT_MIN_RENDERED_OVERDUE_DUE a floor rather than a quota so whichever side is short gives its room to the other. 2. Nothing could find a reminder by its own wording. Every retrieval field resolves to a (contact_id, text) subquery, so a follow-up belonging to nobody is not representable in a candidate list at all — and retrieval's title is a CONTACT's job title, not a reminder's. The searches the model reported running could never have returned a reminder, whatever it typed. read_due now takes an optional term, matched against the generated follow_ups.search_tsv with plainto_tsquery (AND semantics, deliberately not the recall-first OR that repo/search/tokenize.ts builds for hybrid search: naming one row is the opposite problem from ranking many), with no horizon and no render cap — if the user names a reminder, absence from a twenty-row window is not an answer. It is a second MODE of read_due rather than a new retrieval field, because findCandidates returns contact-keyed candidates and a general reminder has no contact to be keyed on; it costs the turn schema one property, measured back to 15 of the 32-property compiled-grammar ceiling. 3. The truncation warning counted nothing. It fired correctly — "this list is capped and PARTIAL" — and the model overrode it, because one line below it the undated tail named its bucket and counted it exactly. The only QUANTIFIED uncertainty in the observation was about undated rows, so the model hedged about those (the 72, a pure red herring: the reminder was dated) while flatly asserting the dated one did not exist. It now reads "showing N of M open dated reminders", forbids the inference in words, and names the move that settles it. 4. The loop never knew what day it was, and saw every date in UTC. There was not one timezone reference anywhere under lib/ai/agent, and the agent turn was the only volatile prompt in the product that reasons about time and carried no todayLine(). The user's zone is read once per run (agent/clock.ts, degrading to SchedulePrefs.timezone's own UTC default if that read fails, so a pool blip costs a date and never the answer), the calendar day derived from it goes in the VOLATILE half of the prompt where it cannot break the cached system prefix, and a TIMED follow-up is rendered on the user's clock face — 09:35 in a zone five and a half hours ahead of UTC had been reaching the model as 04:05. A DAY-ONLY follow-up is deliberately left on the UTC slice: it is a calendar day pinned to UTC midnight by every writer, so converting it would move it a day BACKWARDS for everyone behind UTC. Tests: due.test.ts grew from 3 cases to 13, all driving the whole loop and asserting on the NEXT PROMPT, plus repo/tasks/queries.term.test.ts against a real PGlite database for the wording lookup. Mutation-verified twice: restoring the head-slice fails exactly the two partition cases, and restoring the UTC rendering fails exactly the timed-clock case while the day-only guard keeps passing. Pending: still no run against a real graph with a real key.

  • The agent stopped telling people "nothing is undated" while their undated reminders piled up (2026-09-07, apps/web/src/lib/repo/tasks/queries.ts, apps/web/src/lib/ai/agent/actions/dispatch.ts, apps/web/src/lib/ai/agent/observe/records.ts, apps/web/src/utils/constants/agent/rendering.ts) — a review finding on the loop, never fixed until now. The read_due action fetches ONE CAPPED PAGE of the open follow-up list (AGENT_MAX_FETCHED_DUE, five times what it renders), and listTasks sorts dated rows first — so on any account with more open dated follow-ups than that cap, the page comes back holding zero undated rows, and the undated count, derived from that page with a filter, came out 0. The model was then told in as many words that nothing was undated and answered the user with it: a confident falsehood about the user’s own reminders, which is the exact failure the loop was built to stop. The count is now its own count(*) (countUndatedOpenTasks, same workspace swap and same open predicate as the list it accompanies, run sequentially inside the SAME withUserDb scope so it is never a second tenant checkout), and observeDue takes it as an argument rather than deriving it — the signature is the guard, and its doc comment names the defect. The observation also now says the list is PARTIAL whenever the fetch hit its cap or rows were dropped at the render limit, because a cap the model cannot see reads to it as a complete answer; it stays silent when neither happened, since a warning printed unconditionally teaches the model to ignore it. Tests: a new due.test.ts drives the whole loop and asserts on the NEXT PROMPT (three cases — the full dated page with 11 undated rows behind it, the PARTIAL notice, and the account that is genuinely complete), mutation-verified: restoring the tasks.filter(dueDate === null) derivation fails the first case and only that one. Pending: still no run against a real graph with a real key — the doubled model drives every case here, as with the rest of the loop.

  • The agentic loop remembers what the chat already said (2026-09-07, apps/web/src/lib/ai/agent/history.ts, packages/core/src/llm/prompts/agent-turn/, apps/web/src/lib/messaging/agent-filing/state.ts, apps/web/src/lib/ai/search/answer/chat-memory.ts, docs/AGENTIC_MESSAGING.md §6.3) — messaging_conversations.turns had been written on every inbound message since PR #361 and read by nobody: the filing path loaded the whole conversation, kept two scalars off it (the open authorisation, and whether the last agent turn was an ask) and dropped the array, so a twenty-turn transcript accumulated and expired without ever reaching a prompt. §6.3 "memory is evidence, not a rule" described behaviour the code did not have, and two doc comments asserted the transcript "is read IN FULL into a prompt on every inbound message" — which was false. It is true now: AgentLoopInput.history carries the stored turns, both chat entry points populate it (the filing path from the read it was already doing; the ask path from the provider + externalUserId that inbound/ask.ts has held all along and passed only the bare question down), and the command palette — which has no conversation row — passes nothing and renders no memory block at all. It goes in the USER half of the prompt, never the system half: the system string is the request's one cache_control breakpoint, so it reads at ~0.1x input price on every turn of a loop that may run ten of them, and a per-chat prefix in there would miss the cache on every message of the highest-volume path in the product to save nothing. What DID go in the cached half is the static instruction that this is evidence and not a rule — a record of what was said, about a graph that may have changed since, which a fresh lookup beats on a contradiction — and the two blocks are labelled apart ("Earlier in this chat" vs "What you have seen so far") because a model that confuses them files onto an id merged away last week and reports it as something it just looked up. Bounded twice, because a turn COUNT is not a bound when one forwarded article is a single turn: AGENT_TRANSCRIPT_TURNS (20) is re-applied by the reader rather than trusted from the writer, and AGENT_HISTORY_CHARS (3,000, each turn clipped to 400) caps the rendered block, trimmed newest-first because a correction is about the last thing that happened — worst case ~780 input tokens a turn, ~7,800 an inbound message at the cap of 10 (about $0.023), against a typical exchange that costs a fraction of it. Retention is inherited, not re-checked: readConversation already returns null for an expired row, so the 14-day boundary bounds what can be remembered and not merely what is stored, and a second expiry check would be a second place to get it wrong. Tests: 6 loop cases (a prior turn reaching the prompt, the turn bound, the character budget dropping the oldest while clipping rather than dropping the long one, the block staying under budget however many turns are stored, the two blocks ordered and labelled apart, and a caller with no chat rendering no heading), 3 on the filing state (the turns survive the function that used to drop them; a missing or unreadable row remembers nothing), and the expiry case in agent-conversation.test.ts now asserts the rendered history is empty rather than only that the read is null. Two asks in a row now see each other (2026-09-07, apps/web/src/lib/messaging/agent-filing/identity-gate.ts, apps/web/src/lib/ai/search/answer/chat-memory.ts, apps/web/src/lib/repo/messaging/conversation/, §6.3/§6.4): the ask path appended nothing because one signal was doing two jobs — the filing gate read turns.at(-1), so an unrelated question landing last closed an identity question nobody had answered, after which "no, a new person" was refused forever. AgentTurn.surface ("filing" or "ask") now discriminates the two writers, deliberately not folded into action, which is the loop's closed decision vocabulary and the very field the gate reads; answeringIdentityQuestion() selects the last filing turn before position is consulted at all. parseTurn reads the field back — it rebuilds turns field by field, so a field it does not read is silently dead — and defaults an unmarked turn to "filing", which is what every pre-existing row is. rememberAskTurns then appends the ask exchange with no action, no turnsSpent and no pending, on success only; the command palette, which has no conversation row, still writes nothing. No bound moved: both surfaces share the same 20 turns and 3,000 characters, so the worst case per prompt is unchanged at ~780 input tokens a turn — what is new is that the window fills on any mix of exchanges, and a chat that only ever asks questions now builds a history it previously did not (up to ~+$0.023 worst case on such a question). Trimming stays newest-first and the gate reads the stored array rather than the rendered block, so a long Ask session can crowd out context but can never close the gate; where the shared window does evict a filing turn the gate reads false, which re-asks — the safe direction. expires_at still rolls forward on every append and messaging_conversations is still in ACCOUNT_OWNED_TABLES. Tests: 5 gate cases (open across an unrelated ask exchange — the regression this whole change exists to avoid; closed once filing itself moves on; closed for an ask-only chat; closed when the last filing action was not ask), 3 ask-path cases (the second question sees the first, the turns are stamped ask and carry no action, the palette writes nothing and still answers), and 2 in agent-conversation.test.ts (the surface survives the jsonb round trip, and the character budget holds with both surfaces writing).

  • The agentic loop — Dhaga can look something up, read what came back, and try again (2026-09-06, packages/core/src/schemas/agent-turn/, packages/core/src/llm/prompts/agent-turn/, apps/web/src/lib/ai/agent/) — until now every answer was a single forward pass: the model guessed once from a candidate list assembled before it was asked, and never saw the result of anything it decided. Phase 2 of docs/AGENTIC_MESSAGING.md replaces that with a bounded loop over a closed action vocabulary — search, read_contact, read_notes, read_due, answer, ask — read-only in this change, so the whole security story is checkable by eye: the executor imports four repository functions, all of them queries, and a write would need a new member in the vocabulary and a new case in the executor, in two packages. There is no provider tool-calling in the gateway (by design — packages/core/src/llm/types.ts), so a turn is one extract against a nine-property schema, wire-measured against the 32-property compiled-grammar ceiling that has taken inbound messaging down twice (budget.test.ts starts from outputFormat, not from the Zod schema, because the batch plan's own budget test passed throughout the second outage while measuring a schema the code never sent). A correction to the design doc came out of building it: §4.1 claimed the model "physically cannot" name an action outside the list, and the SDK's zodOutputFormat in fact rewrites every enum into type: "string" with the members appended to the property's description — so the vocabulary travels as a strong instruction, not as a grammar, and the boundary is the refusal (parseAgentTurn, plus the executor's dispatch), which is now tested by mutation rather than assumed. There is deliberately no matching ladder in code — no "try exact, then prefix, then phonetic": the tool exposes the seven modes and the model composes its own clauses, because a hand-tuned sequence fits one graph at one size and the project has already paid for that once (a fuzzy-name gate that worked at 750 contacts stopped working at 23,670). What the model gets instead is provenance — which field matched, by which rule, on which characters, whether those characters stand as a word or sit inside a longer one, plus recency, note count and tags — rendered in the order retrieval returned it, with nothing re-sorted, and with the per-clause counts including the zeroes, which is what lets an answer say "nothing carries that as a tag and two people carry it in a note" instead of going quiet. Bounds: agentTurnCap() (10, env-tunable, clamped to 25) and the shared invocation clock (lib/messaging/invocation-budget, a new invocationTimeLeftMs on the same store — never a second clock) are the two terminators; the gate is withinDollarCeiling and never assertAiBudget, whose first act is to spend a token from the shared burst bucket and which could therefore refuse the very question it protects (the message_intent precedent). The loop always ends in a reply — the cap, the clock, a dead provider, a terminator the model left blank, all compose a reply in code from the deterministic step labels, because a silent exit is the failure users report as "the bot is dumb". Wired to Ask Dhaga, both the streaming palette path and the non-streaming one the chat webhook uses, behind DHAGA_ASK_AGENT and off by default: the loop changes what a question costs (up to ten model calls, not two) and how it feels (the answer arrives whole, after the steps, rather than streaming token by token), and neither is worth discovering in production on somebody else's question. The WhatsApp/Telegram filing path is untouched — that needs write actions (phase 3). Tests: 12 vitest cases across loop.test.ts (every stop condition, each asserting both that it stopped for the right reason and that it still said something) and strategy.test.ts (the result of turn N reaching turn N+1's prompt, a second and different search actually running, the zero counts surviving, an unknown action refused without touching the repository), plus 5 in the core budget test. Four mutations verified to fail exactly the right cases: widening the turn cap, bypassing parseAgentTurn, dropping the invocation-clock check, and filtering the zero clause reports out of the observation. Pending: nothing persists turnsSpent yet (the loop returns it; the palette has no audit row to carry it, and the chat path that does is phase 3), no new credit key was invented (Ask Dhaga's turns land on the search action it already opens), the flagged path sends no receipts (they are built from the ranked hit list this path deliberately does not produce), its steps arrive together after the work rather than live, and it has not been run against a real graph with a real key — every case here drives a doubled model.

  • "As soon as I answer, the note goes under the right person" — a free-text answer now MOVES the parked note (2026-09-07, apps/web/src/lib/messaging/pending-question/agent-answer/, lib/messaging/pending-question/answer.ts, lib/repo/confirmations/parked-note.ts, lib/ai/agent/types/write.ts, lib/ai/agent/write/index.ts, utils/constants/messaging/agent-filing.ts) — the second half of phase 3, behind the same DHAGA_AGENT_FILING flag because it is the same decision one message later (docs/AGENTIC_MESSAGING.md §6.6, §9 phase 3b). The bot asks "who is this about?" and prints a numbered list; the sender replies in words — "no it is for <name> <surname> sir" — and until now that answer reached nothing. matchPendingAnswer accepts only a bare index or a string byte-equal to a label we printed, so a sentence missed; the miss then expired the chat's whole queue, destroying the state before anything else could consult it; and the sentence fell through to capture as a second note about nobody, beside the one it was trying to place. The right person was often not among the options at all, because the deterministic planner could not resolve them either. A non-matching reply now goes to the loop, which is handed the parked row (payload.apply.noteId) rather than a fresh park — so file_note_on_contact re-points that row: same id, same body, same source_item_id, and the card is then closed through its own applier, which adopts the same row idempotently (adoptUnfiledNote treats "already on this owner" as success). There is no second definition of what an answer writes, so a duplicate note has nowhere to come from, and a test counts the rows rather than the calls. The model composes its own search — no ladder, no name comparison anywhere on this path — and the one thing code decides is a second, independent create rail: mayCreateContact is FALSE until we have asked, because on an answer an unfindable name is a spelling to check, not a person to invent. Sundeep/Sandeep and Sukhwinder/Sukhvinder are as often two people as one, and the existing sawCandidates gate cannot cover it — a search that finds nobody sets that flag FALSE and would wave the create through on exactly the reply that most needs a question. The pending question also survives a miss now, on its own six-hour clock, which is what makes it resolvable at all. Everything the loop cannot do degrades to the behaviour that shipped before it — the flag off, no provider configured (the free tier is zero cloud AI), a spent budget, the turn cap, a card with no row to move, and a model that says this was not an answer: all of them expire the queue and fall through to capture, so an open question can still never eat an unrelated forward. The vocabulary, the turn schema and the grammar budget are untouched — 11 of the 32 properties, unchanged. Jev gate (2026-09-23, lib/messaging/pending-question/agent-answer/jev-gate.ts, utils/constants/jev/pending-answer.ts): before the loop runs, Jev asks whether the reply answers "who was that note about?"; at p(yes) ≤ 0.1 (PENDING_ANSWER_JEV_NOT_ANSWER_MAX, compared directly) the loop is skipped and the reply is handled exactly as the loop's own non-answer, storing the same turn. It runs inside the caller's batch_plan action after the dollar ceiling, on a 1s share of the webhook clock; no key, a failure or an unsure answer run the loop unchanged. Eval (synthetic, two runs): 210 real answers, 0 false skips; 53 of 64 non-answers skipped.

  • Silent duplicate contacts stop — the loop decides who a forwarded note is about (2026-09-06, apps/web/src/lib/messaging/agent-filing/, lib/messaging/process-session/apply/identity/, lib/messaging/process-session/apply/person.ts, apply/agent-verdict.ts, apply/people.ts, utils/constants/messaging/agent-filing.ts) — phase 3 of docs/AGENTIC_MESSAGING.md, behind DHAGA_AGENT_FILING, off by default. The bug: routeIdentity filters the names SQL found through the predicates that decide a write, and when every candidate is rejected the pool empties, routeNoteCapture answers confirm_create, and the apply step minted a brand-new contact with no question asked. <First> sir against a stored <First> <Surname> is exactly that shape — the honorific is deliberately never normalised on a write path, because relaxing that merges two humans and merge hard-deletes a row — so a second row for the same person appeared silently and was found months later with one history split in half. Only that one branch moves: attach and confirm_ambiguous are already decided by code and stay decided by code, and no predicate was relaxed. The order is the design, and step one is the acceptance criterion: the note is written unfiled (owned directly by the user, visible on the plan board's General scope) before the model is consulted, so an unanswered question, an expired conversation, a dead provider, a crashed turn or the dollar ceiling each cost an unfiled note rather than a lost one; the loop is then handed that same row to move, never asked to create one. The chat's memory is read for the open Tier-B authorisation and for whether this message answers a question we asked (answeringIdentityQuestion, set by code from the stored turn's action — never by the model, which must not be able to vote itself permission to mint a duplicate), and the exchange is written back with turnsSpent, which closes phase 2's dangling audit gap. Both replies are composed in code, not left to the model: a question always carries all three ways out (one of these people / a new person / "I'll do it myself"), and a file always names the person and the words that undo it — the model is also told to do both in its prompt, and a turn that forgets one produces a reply that reads fine and silently loses the property. The whole loop runs inside one batch_plan AI action, so a ten-turn decision is billed as one forwarded batch rather than ten, and no credit key was invented. The capture log reuses the existing verdicts (created / attached / unclear / directive) rather than adding one the closing summary would not recognise. Nothing throws past the boundary — this is a webhook, and a failure leaves the parked note in place and still answers. Tests: 9 vitest cases in lib/messaging/agent-filing/filing.test.ts, including the flag-off case that pins the diff as inert (no model call, no parked note, no chat memory) and the crash case that proves the words survive it. Pending, and stated plainly: the person path carries no capture-time reminders to move — PlannedPerson has no followUps field at all (the batch grammar ran out of budget), so a person's reminders are still extracted only after the note lands on a contact, and generalTaskIds is always empty from this caller; answering an identity question does not yet move the original note, because the answer arrives as a fresh batch that parks a note of its own and nothing routes it back to the earlier row; the loop is given no transcript, so §6.3's "memory is evidence" is not yet true on this path; and it has not been run against a real graph with a real key.

  • The batch planner is shown the contact a message means, through an honorific, a glued verb, a nickname or a surname (2026-09-24, lib/messaging/process-session/names/, lib/repo/edge-suggestions/{batch-candidates,name-variant,name-search}.ts, utils/constants/name-guess.ts, packages/core/src/llm/prompts/batch-plan.ts) — guessNames also looks for a run's core without leading titles, chat verbs and time words or trailing respect words ("Dr Daniel Okafor", "Call Daniel Okafor", "Kal Farhan", "Sharma Ji"), KEEPING the run as written, since a contact may be stored as "Ankit Sir" and honorifics are never stripped from an identity. findBatchCandidates now searches nicknames too (the prompt shows them), matches a single word inside a stored name (the surname-only mention), and ranks exact > prefix > whole-word before the 24-row cap instead of cutting an unordered set. findRelationshipCandidates searches a titled subject's core as well ("Dr. Okoro"). Recall eval on synthetic mentions (dup-lookup-recall.eval.test.ts): honorific 1→4 of 4, verb 0→4, nickname 1→4, surname 0→4. Identity is unchanged: only an exact name or nickname match files without asking. The agent filing path is untouched — its model-written retrieval already has nickname, word and trigram modes.

  • An outage no longer reports itself as budget exhaustion (2026-09-07, apps/web/src/lib/ai/agent/types/loop.ts, lib/ai/agent/loop.ts, lib/ai/agent/result.ts, lib/ai/agent/replies.ts, lib/messaging/agent-filing/reply.ts, utils/constants/agent/replies.ts, utils/constants/agent/filing.ts) — when the model call threw, the loop caught it and exited turn_cap: AgentStopReason had no member for a provider failure and finish() was typed to reject one, so the only exit available was a lie. A provider that died on turn 1 told the user "I ran out of steps before I could look anything up. Please ask me again." — one turn of ten had been spent, the cap was not what stopped it, and the advice sends them straight back into the same dead provider. On WhatsApp and Telegram it was worse, because composeAgentReply special-cases only asked and everything else fell through to "I couldn't work out who that was about" — our outage blamed on the sender's message, on the one surface where that sentence is the entire signal they get. And it poisoned the audit trail: turns_spent is the only figure stored, so an outage and a genuine ten-turn burn were the same record, and the stored turn action said "answer" — an outage recorded as having answered. provider_failed is now its own stop reason with its own copy on both paths: the loop's reply names the fault as ours and says not to retry immediately (the exact inverse of the advice turn_cap's words carried), and the filing reply leads with the note being saved and unfiled, because a sender told only that we failed reasonably concludes their message is gone. It is deliberately distinct from turn_timeout — a slow provider may answer a narrower question, a throwing one will not — and a run that searched before the provider died still hands over what it found. Persisted without a schema change, and deliberately NOT on action: the stored turn gains a stop field, because action is rendered back into the next prompt as the move the agent made (#400's history renderer) and every value it can hold therefore reads to the model as something it may do — an outage is not an action. parseAgentTurn would refuse the invented name, so the cost is a wasted turn rather than a breach, but a wasted turn on a path that only runs when something is already broken. A killed run now names no action, which is simply true, and carries its reason where the renderer does not look. A new messaging_conversations.stop_reason column would have needed an appended DDL chunk wired into ddl/index.ts — the failure mode that 500'd production once — to store what the existing column can already carry, and messaging_sessions.error is unreachable from here because the filing path deliberately never throws past its own boundary. Tests: 4 new vitest cases, since the failed branch had no coverage at all — a throwing provider reports provider_failed rather than turn_cap, a mid-run death still reports what it found, the sender is told the provider failed and the note is safe rather than that we could not work out who they meant, and the persisted record separates an outage from a cap burn. Also found here, and fixed in the entry below: the empty-terminator exit — a model that answers with no words — reported turn_cap after one turn, which is the same lie; it needed a reason of its own, because folding it into provider_failed would attach the wrong advice.

  • A blank answer no longer reports itself as budget exhaustion either (2026-09-08, apps/web/src/lib/ai/agent/types/loop.ts, lib/ai/agent/loop.ts, lib/ai/agent/result.ts, lib/ai/agent/replies.ts, lib/messaging/agent-filing/reply.ts, utils/constants/agent/replies.ts, utils/constants/agent/filing.ts) — the twin the entry above named and left standing. A model may end a turn with answer or ask and leave reply empty or null, because the schema permits it (reply is .nullable()), so the provider call SUCCEEDED — what came back is a verdict with no words in it. That exit also returned turn_cap, and it is the same lie in the same shape: it fires on turn 1 of 10, so it asserted a spent budget that was not spent, told the user "I ran out of steps before I could look anything up. Please ask me again.", and sent them off to narrow a question that was never the problem. It also hid a one-off malformed response inside the count of genuine ten-turn burns — turns_spent is the only figure the audit stores — and on WhatsApp and Telegram it fell through to composeAgentReply's catch-all, "I couldn't work out who that was about", blaming the sender's message for a fault that never reached it. empty_reply is now its own stop reason, and deliberately NOT folded into provider_failed, because the advice inverts: nothing threw, so a hollow turn is a one-off that sending again immediately will usually clear, where an outage's reply has to say to WAIT. It carries its own copy on both paths — AGENT_EMPTY_REPLY_NOTHING_TRIED / _PREFIX on the loop path, which still hands over what was found when the hollow turn arrived after real work, and AGENT_EMPTY_REPLY_UNFILED_REPLY on the filing path, leading with the note being saved and unfiled. No schema change and no DDL chunk: AgentTurn.stop is a free string that parseTurn reads back with asString, so the reason persists exactly as provider_failed does, and the run again names no action — nothing was chosen. Tests: three new vitest cases and one rewritten — the case that used to LOCK IN turn_cap now asserts empty_reply and a turn count of one, the composed reply is proven to be neither the cap's words nor the outage's wait-a-few-minutes advice, the sender is told the answer came back empty and the note is safe rather than that we could not work out who they meant, and the invariant that a stop reason is never stored as an action now runs over every reason rather than one example.

  • The four follow-ups the 2026-09-09 capture incidents named: a batch gets its own clock, a stale one is found in minutes, the chat's filing promise is real, and an operator finally has a signal (2026-09-09, apps/web/src/app/api/jobs/messaging/flush/route.ts, app/api/jobs/tick/jobs.ts, app/api/messaging/[provider]/webhook/delivery.ts, lib/jobs/messaging-flush/{handoff,drain,claim,config}.ts, lib/messaging/agent-filing/ask.ts, lib/messaging/process-session/apply/park/raise.ts, lib/repo/messaging/capture-health/, utils/constants/agent/filing/unfiled/, packages/core/src/llm/prompts/agent-turn/system.ts) — PR #447 stopped nine forwarded cards becoming nine unfiled notes; these are the five things that investigation said were still wrong, four of them built. (1) The batch walk no longer finishes inside the webhook's clock. There is no general job queue in this repo — only extraction_jobs — but messaging_sessions already IS a durable one: a batch is claimed by a single conditional UPDATE to processing, items carry processed_at so a re-drive resumes rather than repeats, and the flush sweep already handles both the never-closed and the stranded arms. A second queue table beside that would be two rows that can disagree about whose batch it is. So the sessions table stays the queue and the webhook pokes a job route to run it now: POST /api/jobs/messaging/flush gained a targeted mode (maxDuration = 300) that registers one already-claimed batch in after() and answers 202 immediately — a genuinely fresh invocation, because after() cannot escape maxDuration and only a new request starts a new clock. The trade-off is paid in full and stated: a self-invoked HTTPS call fails in ways an inline call cannot, and the caller already holds the claim, so a double run is a real double write rather than a race one side loses. handOffSession therefore has three outcomes shaped entirely around never processing twice — a non-2xx answer (or no CRON_SECRET, or no origin) walks it inline, exactly as before; a 2xx never touches it again; and a timeout or dropped connection also never touches it, logs loudly, and leaves it to the stalled sweep. That last case costs up to MESSAGING_PROCESSING_STALL_MINUTES of latency, and is only safe because the route answers 2xx after the work is registered — every rejection sits before the after(), and a malformed body returns 400 rather than letting the caller believe a batch was taken that nothing ran. Origin is VERCEL_URL then NEXT_PUBLIC_SITE_URL, never SITE_URL, which defaults to the production domain and would have a preview hand its batch to production code against the shared database. (2) The idle sweep moved onto the quarter-hourly tick, capped at ten claims a run and ordered last, so a slow sweep costs only that route its response; the cap bounds claims, not processed batches, because a claimed-but-unreached batch is stuck for an hour where one left open is retried by the very next tick. The nightly run stays uncapped — on a one-cron Hobby instance a cap would meter a backlog out at N a day rather than defer it. MESSAGING_SESSION_IDLE_MINUTES was deliberately NOT changed, but its docblock's justification ("the only guaranteed scheduler is the once-a-day cron") was false the moment this landed and is rewritten: the 30-hour session that prompted this was the idle window plus the cron wait, and only the second half is now gone. (3) The chat's filing promise is real. Three of the five unfiled exits raise a note_subject card over the row parkForAgent already wrote and queue the chat's copy, so a bare name reaches answerWithAgent and MOVES that row instead of becoming a second note; every other piece of that path already existed and was simply unreachable without a card. The rule for which three is answering needs the model: the exits where the provider is alive get a question, while provider_failed and refused raise nothing, because when the answer path declines it expires the queue and the message falls through to CAPTURE — an invitation we cannot honour turns a bare name into a fresh note about nobody, which is the defect being closed. No exit becomes ask, and that is the load-bearing decision: ask is the single key that opens mayCreateContact, so stamping it would let a bare name the graph does not hold mint a contact on the first reply, on exits where the model frequently never looked. Whether the next message is an ANSWER is the pending-question row's job; whether it may CREATE stays the transcript's. (4) An operator can finally see capture failing. /app/admin/capture-health counts failed batches grouped by reason ("3 failures" is noise, "3 × plan_truncated" is a diagnosis), batches that completed having filed NOTHING — the signal that would have caught the nine-card incident, which left status = done and looked fine — plus anything stuck in processing or still open past its window, with the oldest age rather than an arbitrary staleness multiple. It reads instance-wide by the per-tenant fan-out every nightly job already uses, importing tenant-sweep rather than copying it; openAdminConnection (EE-internal) and nudgeClaimDb (whose contract admits only statements that cannot name a tenant) were both considered and rejected. It emits counts and codes only — even the stored error string is narrowed to the known vocabulary rather than passed through, because this screen reads every tenant's rows and the day a bug writes a body into that column is the day it would show an operator a stranger's words. (5) NOT built, deliberately: the prompt guidance for a card naming somebody nobody matches shipped, bounded so rail 2 survives — a complete name (given plus surname, no honorific) that matched nobody is a create; one word, or anything with an honorific, must search the bare given name too and read a hit as evidence rather than identity. Measured while writing it and left standing: on <First> sir against a stored <First> <Surname> the loop returns file_note_on_contact rather than asking, with the new guidance stripped out as well — so §6.2 of docs/AGENTIC_MESSAGING.md and the model's actual behaviour disagree, and that wants a decision rather than a quiet edit. Also open: the asked exit leaves its parked note behind in the same way (3) fixed, by a different mechanism.

  • Nine forwarded cards, nine unfiled notes, and one sentence said eight times (2026-09-09, apps/web/src/lib/messaging/agent-filing/create-fallback.ts, lib/messaging/agent-filing/reply/, lib/messaging/process-session/apply/identity/create.ts, lib/messaging/process-session/derive/index.ts, lib/messaging/process-session/summary.ts, utils/constants/agent/filing/, utils/constants/messaging/batch-replies/agent-reply-lines.ts, utils/constants/messaging/config/limits.ts) — a customer forwarded nine business cards, replied DONE, and got one message repeating "I couldn't work out who that was about" eight times, with not one contact created. The model was never asked: ai_actions shows nine sequential card_scan calls spending 46s of the 65s usable invocation (90s budget − 25s reserve), one batch_plan, and then eight "filing decisions" in 0.7 seconds with no ai_actions rows at all — every planned person hit the pre-first-turn budget check in lib/ai/agent/loop.ts and returned invocation_budget with turnsSpent 0. Four defects, one symptom. (1) The flag had replaced a deterministic create with a failable judgement. These were nine brand-new people, so the route was confirm_create, which before DHAGA_AGENT_FILING fell straight through to createContact — needing no model and no clock. Now createWhenLoopNeverRan puts that back when the loop provably never ran, under five conditions, and the fifth is the whole safety argument: pool.length === 0 is vacuous here, because routeNoteCapture only says confirm_create at a candidate count of zero, so it is true on every call including <First> sir against a stored <First> <Surname> — where the query DID find them and the write predicates emptied the pool. The name query is therefore re-asked, and a create happens only when it finds nobody at all; the create itself is lifted into apply/identity/create.ts so both callers share one writer rather than two that can drift. (2) The clock-out borrowed the wrong sentence. provider_failed and empty_reply already had honest strings; invocation_budget, turn_timeout and action_timeout fell through to the catch-all and blamed the sender's message for our clock, and refused (no provider, or over the dollar ceiling) discarded the loop's own accurate reply. They now share AGENT_OUT_OF_TIME_UNFILED_REPLY — one string because the news and the ADVICE are identical, while turn_cap deliberately stays on the old wording since there the model really did look with every turn it had — and refusedUnfiledReply wraps the refusal's own words in the two sentences this surface owes: note safety first, the working filing route last. Every non-answer exit now stamps no action and carries its reason in stop. (3) buildBatchSummary spread agentReplies raw — the one line type in that function that can repeat a byte-identical string, while every sibling (needsInputLine, selfRemindersLine, unaccountedLine) already counts and the unreadable-media notices already dedupe through a Set under the comment "five unreadable photos say it once, not five times". repeatedAgentReplyLine collapses identical replies onto one line with a count, not a bare Set: a fault says nothing by repeating, but eight notes waiting for a name is the work still owed, and dropping the number would hide it. Distinct replies all survive, in first-occurrence order. (4) "Tell me the name and I'll file it" was an unbacked promise — the fallback raises no pending question and stamps turnAction: "answer", so the next message is read as a fresh capture and can mint a contact named after the person with a junk note on them, while the parked note stays unreachable from chat (move_note refuses an owner-less note). All four unfiled replies now point at /app/lists#unfiled-notes (/app/plan#unfiled-notes when this shipped) and its "File this note" button (labelled "File on a person" at the time, before it gained a company and a group as destinations), composed from UNFILED_NOTES_HREF rather than a hardcoded path. And the cause underneath: deriveBatch walked items serially, so nine ~5s vision calls were 46s; it now runs a bounded worker-cursor walk at MESSAGING_DERIVE_CONCURRENCY = 3 — sized to the max-3 tenant pool, safe only because every AI path underneath releases its short DB scope around the model call rather than holding one across it — writing each result into its own slot so completion order can never become arrival order (~16s). Raising maxDuration was evaluated and rejected: nothing in the tree pins the deployment's real ceiling, and invocation-budget.ts argues that a budget larger than the true kill point is worse than none; the same constant is also the claim-reclaim window, so raising it triples the dead window for a delivery killed mid-after(). Tests: the peak-concurrency assertion is toBe(3), not ≤, because ≤ passes just as happily against the serial walk it replaces; the summary tests assert both that eight identical replies say it once and that the count still says eight; the fallback tests cover a non-empty pool, a spent turn, UNNAMED_CONTACT_NAME and a throwing adopt, each of which must fail if its guard is dropped. Investigation: docs/debugging/whatsapp-repeated-unfiled-reply-2026-09-09.md.

  • A forwarded contact card keeps its phone number when the agent files it (2026-09-23, apps/web/src/lib/ai/agent/write/file/index.ts, lib/ai/agent/types/write-card.ts, lib/messaging/agent-filing/decide.ts) — with DHAGA_AGENT_FILING on, the loop was handed only the card's name and create_contact_and_file minted the person blank, so a vCard's phone survived only as a fact in the note. The parsed card now rides on AgentWriteContext.card: a create writes it via createContactProfile (under the agent's name), a file onto an existing contact merges it add-only via augmentContact. Telegram cards share the path. Covered by lib/ai/agent/write-card.test.ts + two cases in agent-filing/filing.test.ts. Follow-up (same day): both answering paths now re-derive the card from the note being filed (lib/messaging/agent-filing/note-card.ts, via the note's source_item_id and the capture log's outcome.noteId — no migration), and a card merged onto the wrong existing contact follows its note on move_note: the merge's exact additions ride the turn as cardMerges and are taken back off the old contact, the card is merged add-only onto the new one (lib/ai/agent/write/move-card.ts, lib/repo/contacts/write/augment/additions.ts). Limits: the receipt ages out with the transcript; a filled title/company is not taken back (it wrote a positions row); a scanned card PHOTO cannot be re-derived.

  • A voice note that was read perfectly, reported as unreadable — the batch plan was running out of output tokens (2026-09-09, packages/core/src/llm/anthropic-client/parse-failure.ts, anthropic-client/sync.ts, apps/web/src/lib/ai/batch-plan.ts, utils/constants/batch-plan.ts, utils/constants/messaging/outcomes.ts, utils/constants/messaging/batch-replies/) — a user sent a 59-second voice note and the capture log said "The AI couldn't read this batch". Transcription had worked fine; the transcript was sitting on the item payload. The batch died in the PLANNER, and the stored detail was almost content-free: BatchPlanUnavailableError | <frames> <- Error | <frames> — a plain Error, no message, no HTTP status. No ai_actions row existed for that plan call, and since planWithAi records only after extract() returns, that alone proves extract threw. The only plain Error that fits is runExtract's parsed_output == null throw: an SDK error would have carried status (which errorSummary prints), a DB error a code, and output-format.ts's three throws all fire synchronously before the HTTP call — microseconds, not the ~25s observed. planMessagingBatch passed no maxTokens, so the widest structured output in the product had to fit inside sync.ts's 2048 default. Measured batch_plan rows on the instance: 1652, 2028, 1233 — 2028 is 99% of the ceiling, so this was not one unlucky batch but a wall the feature had been running at for weeks. And the reason a single 533-character transcript could reach it: the extract tier is claude-sonnet-5 and the request sends no thinking field, so adaptive thinking runs by default and its tokens count against max_tokens — 2048 was being shared between reasoning and the JSON, and the JSON is what got cut off. The ceiling is now BATCH_PLAN_MAX_OUTPUT_TOKENS = 16384 — Anthropic's own guidance for a non-streaming request, and well under the 128K claude-sonnet-5 actually allows; materially higher would require .stream(), which runExtract's messages.parse() is not. A ceiling is a cap, not a target, which is what makes raising it close to free: the model writes the plan the batch needs and stops, so an ordinary two-person batch costs the same seconds at 16384 as at 2048, and the only runs whose wall-clock changes are the ones that would otherwise have been truncated — where spending the time is exactly what we want. The residual risk is stated rather than hidden (Rule 12): this call runs in a background after() on a maxDuration = 90 webhook (less the 25s reserve) and on a 60s flush route, sharing that budget with the derive pass, and 2048 took ~25s — so a generation that genuinely ran to this ceiling would exceed the invocation and be KILLED, which is worse than truncating, because the batch then sits in processing until the stall sweep an hour later instead of failing loudly in seconds. That is not mitigated in this change; the mitigation is to bound the plan call by what the invocation has left (invocationTimeLeftMs) so running out of clock fails fast and diagnosably, and the durable fix is to move the batch walk off the webhook onto a job with its own clock. The diagnosability half is the durable one. stop_reason was the single fact that identified this failure, and it was being thrown away: bugMessage keeps error.message only for TypeError/ReferenceError, deliberately, for PII. The throw is now a typed StructuredOutputError carrying the stop reason as its code — a closed provider vocabulary (max_tokens, refusal, end_turn) that is data-free by construction, and exactly what errorCode already documents itself as reading, so logging/index.ts needed no change at all; the stored detail now reads … <- StructuredOutputError | max_tokens | <frames>. planMessagingBatch classifies it into its own plan_truncated reason, and the sender is told something true and actionable — we read your message, we ran out of room writing the plan down, send fewer at once — instead of that we could not read it. Both render sites were updated, and only one of them could ever have caught a miss: MESSAGING_BATCH_FAILURE_LABELS is a Record<MessagingBatchFailure, string>, so the compiler demanded the new key, while BATCH_FAILURE_REPLIES is a Record<string, string> that would have silently fallen back — which is why outcomes.test.ts now asserts that every code in the vocabulary renders as itself in both surfaces. No migration was needed: messaging_sessions.error is plain text with no CHECK constraint. Two options considered and rejected, with reasons. Splitting an over-large batch across plan calls destroys the one property whole-batch planning exists for — "Create a new contact" only means anything beside the message before it — so a directive could land in one call and its subject in another, which is the exact mis-attribution the per-message walk caused and this design replaced; a correct split would also have to be strictly sequential, costing N× latency against the clock that already binds, and would break the one-batch-one-metered-action invariant. There is no evidence size is the live problem anyway: the failing batch was ONE transcript, and the ceiling was eaten by thinking, not by fifty items. plan_truncated is what will finally make that measurable, and the cheaper first move if it does fire on large batches is to cap the planner's item count below MAX_SESSION_ITEMS, where the overflow already stays unprocessed for the next sweep. Retrying once at a higher ceiling is killed by the same clock — a second full generation on top of the first exceeds the function and strands the batch — and it double-bills the most expensive path while hiding the signal just created. If a retry is ever wanted the right lever is less thinking, not more room (effort: "low"/"medium"), and it should be measured on the FIRST call before being adopted. Tests: the code survives errorSummary — the test that would have saved the database dig — truncation is distinguished from refusal and end_turn, an unrelated error that happens to carry a code does not fool the predicate, and the ceiling is asserted to be actually passed, because a regression there is a silent deletion.

  • "No, make a new contact" is now a thing a sender can actually say (2026-09-10, packages/core/src/schemas/message-intent/correction.ts, packages/core/src/llm/prompts/message-intent.ts, packages/core/src/parse/heuristic-contact.ts, apps/web/src/lib/messaging/correction/create/, correction/apply.ts, correction/parked.ts, correction/replies/created.ts) — when the bot filed a note on the wrong person and the sender said so, naming somebody the graph does not hold, the correction was refused and they were sent to the app to do it by hand — the one thing chat capture exists to avoid. Worse, creating was unrepresentable: messageCorrectionSchema carried only same_person | different_person, wrongName and rightName, so "no, create a new contact" arrived as one of the two existing kinds with rightName: null and hit missingRightNameReply() — refused for not repeating a name the sender had just been shown. A third CorrectionKind, new_person, and deliberately an ENUM MEMBER rather than a createNew flag: a member costs nothing against the 32-property grammar ceiling (§4.2) where a field costs one of the 32, and it is the better modelling anyway, because a kind is a VERDICT the classifier must commit to while a flag beside different_person would be two fields able to disagree. It is NOT inferred from different_person + "nobody by that name", which is the tempting one-line version: reading an instruction out of an ABSENCE is the same reasoning error correction/replies/nobody.ts warns about — "the name came out of a sentence a model parsed, and minting a contact row on that evidence is a guess that leaves a stranger in the user's graph" — and that absence is itself evidence the model may have mis-picked which words were a name. The inferred case still refuses, permanently, and a test pins it. THE NAME COMES FROM THE BATCH'S OWN ATTRIBUTION when the sender types none. A typed rightName wins; otherwise chooseName takes ContactTarget.contactName on the filed path or ParkedTarget.subjectName on the parked one — read off the ORIGINAL message by the pass that had the whole thing, then quoted back to a sender who did not dispute it, which is strictly stronger evidence than a name re-extracted from a correction sentence. It is also what makes the nameless instruction ("that's a different Ankit, make a new contact") work at all — the exact case the old schema could not express. A typed name that fails the guard does not silently fall back to the batch's: substituting a different name than the one they wrote is invisible to them, so it refuses and quotes their string back. The old rationale is answered in three legs, not waved away. It is an INSTRUCTION gated on a distinct verdict, not an inference. It is REVERSIBLE — rename or delete — unlike the merge the same file already permits, which hard-deletes a row and takes its id, share links and bookmarked /app/people/<id> with it. And it only ever creates a destination for words the sender already sent: enforced, not asserted, by refusing when the disputed contact carries no note (nothingToPlaceReply) and, on the parked path, by reading pendingParkedNote BEFORE creating so a card answered in the app never leaves an orphan contact behind. A row minted here is always the owner of an existing note, never a bare name. Guards reuse rather than reinvent: isNameLike (parse/heuristic-contact.ts) is the repo's only name-shaped-vs-sentence test — capture/match.ts and quick-add hold only IDENTITY predicates, which cannot answer this — plus a CORRECTION_NEW_CONTACT_NAME_MAX of 80 (isNameLike bounds words, not characters) and the UNNAMED_CONTACT_NAME rejection create-fallback.ts already makes. Ambiguity deliberately does NOT block a create — several people of one name make a merge or a move undecidable because one row must be picked, while a create picks none; one confident match that is not the disputed contact DOES block it as a duplicate, and one that IS the disputed contact proceeds, which is the namesake case. THE EMPTIED CONTACT IS NOT DELETED, and that is a decision rather than an omission: merge's warrant ("undo what this batch just made") does not transfer, because merge deletes the source as part of a write that PRESERVES its content, where a cleanup delete after a move preserves nothing and destroys whatever the move did not carry — a card-scanned contact holds emails, phones, title and company on the contact row itself, none of which is a note. Proving "empty" would mean proving no facts, edges, embeddings, follow-ups, tags, group memberships or card images across the same twelve-table repoint, and a wrong answer is a silent hard delete of the user's own data; the codebase has already made this call twice explicitly (moveBatchNotes, movedNotesReply). The cost is one empty contact the reply names and the user removes in a tap. A LIMIT FOUND WHILE BUILDING IT, and the reply changed rather than the promise being left false: filedReply's "Wrong person? Reply 'not X'" CANNOT be kept for a contact created this way. Both halves of the chat correction path read the capture log — it feeds recentNames, so the classifier only sees a name as disputable if the batch audit holds it, and resolveTarget bounds itself to the same rows. A correction writes to the graph and never to that audit, so not <newname> would classify as capture, or at best answer unknownTargetReply. The reply keeps the shape and points at the surface that works. Making the chat undo real would mean writing a capture-log row from a correction, which falsifies that table's meaning as the record of what a BATCH did — a decision, not something to slip in. Also open: a contact created here is stamped ContactSource "messaging" rather than whatsapp/telegram, because applyMessageCorrection is not handed the provider and threading it down is a caller-signature change through inbound/route.ts. Tests: nine cases through the real inbound path plus five name-guard cases and two schema cases, every one proved able to fail by mutation in two rounds with the reverts verified — dropping the batch-name fallback, removing the isNameLike gate, removing the empty-note guard, routing different_person's no-match to create, removing the pendingParkedNote guard, removing the namesake exception, removing the length cap, and blanking the general branch's rightName each turned a specific named test red. The merge gate and the rename path are untouched and still green.

  • A note can be filed on a COMPANY or a GROUP, not only on a person (2026-09-10, apps/web/src/lib/repo/notes/unfiled/group.ts, unfiled/adopt.ts, apps/web/src/lib/actions/notes/unfiled/file/, apps/web/src/components/app/GroupPicker.tsx, apps/web/src/components/app/notes/UnfiledNotes/FileNoteButton/, apps/web/src/components/app/confirmations/NoteSubjectCard/PersonlessAnswers.tsx, apps/web/src/lib/repo/confirmations/apply/note-subject-group.ts, apps/web/src/lib/messaging/process-session/apply/unclear/group.ts, packages/core/src/schemas/batch-plan/unclear.ts, packages/core/src/llm/prompts/batch-plan.ts) — notes has had four owner columns for as long as groups have existed and assertSingleOwner names all four, but the two surfaces where a user says who a capture was about could only ever answer "a person". The unfiled-notes list offered "File on a person" and nothing else, so a capture about a firm, or one belonging with a standing meeting, had to be filed onto whoever happened to be in it. The inbox card was one step better and still gated: its company answer appeared only when the note had written reminders (right when both non-person answers were about where the REMINDERS went, wrong the moment one of them started writing the note), and its group answers appeared only when the CAPTURE PATH had read the message as a meeting and worked out which groups those PEOPLE form — so "keep this with my Tuesday sync", on a note that named nobody, had no answer at all. THE REPO HALF IS A NEW SIBLING, NOT A WIDENED fileUnfiledNote. fileUnfiledNoteOnGroup moves the row — same id, same body, same source_item_id, so the capture log's link keeps resolving and the messaging re-drive stays idempotent — where createSharedNote, the only previous way onto a group, would have written a SECOND copy of words that are already a note and left the first one unfiled. Its follow-up write is the one real divergence and it is an addition, never a swap: follow_ups permits a contact and a group at once, and a reminder the capture attributed to whoever owes the work stays on them with the group added beside it, where the person and company paths (whose columns are alternatives) must refuse an owned row instead. No note_contacts rows, deliberately: that table is a claim about who WAS in the room and these notes named nobody, so seeding it from the group's current members would assert a conversation that may never have happened — §4's "adding somebody to a group never backfills old notes", read in the other direction. What each owner COSTS is different, and the three are separate functions so it cannot be flattened: a person gets extraction + the gated retirement + a vector + a calendar re-sync; a group gets the shared-note extraction + the same retirement through the card's own finishGroupNoteAnswer, but no vector (embeddings carries only contact_id/company_id — the stated §9 limit); a company gets a vector and no extraction, matching the decision the inbox card's company answer already made and for its reason (nothing re-derives the sentence, so retiring the capture rows would dismiss the user's ONLY reminder). A REAL BUG FOUND ON THE WAY: applyNoteSubjectGroup ignored payload.apply.noteId and called createSharedNote unconditionally, so answering "it was a meeting" on a card raised by messaging wrote a duplicate note and left the parked row in the unfiled list — the exact duplicate the park was designed to make impossible. It adopts now, like the person and company answers. THE CHAT HALF IS A RUNG, NOT A ROUTED DIRECTIVE. "Add this to the <name> meeting group that I already have" was read as a directive — correctly, by the prompt's own actor test, since "file it elsewhere" is an instruction to Dhaga — and directives are reported, never executed, so the sender got their words saved and a line saying the instruction had not been acted on while the group sat in their account untouched. It is also, plainly, what the note is ABOUT, which is what unclear.subjectName asks for; a claimant of its own would 400 the capture path against the compiled-grammar ceiling. So the field's vocabulary gains a fourth reading and the ladder gains rung 1b, behind companies so nothing that files correctly today moves. The model never picks the row: it copies the name the message used, and resolveGroupSubject matches it case-insensitively, exactly, in code over listGroups — already workspace-scoped and already archive-filtered, so it cannot drift from what /app/groups shows. Match only, never create (a group is a lens the user built; minting one off a transcribed voice note puts a row in /app/groups they never asked for), and two groups sharing a name is not a match — nothing on this rung may pick between them, and there is no confirmation step here to catch a wrong guess, so it falls through to the question at the bottom of the ladder. Cost of the rung, stated: every unclear note with a subject name that is not a company now reads the group list — one small aggregate query, after a targeted company lookup missed — which is why ten messaging cases had to start doubling @/lib/repo/groups. Tests: three repo cases against a real Postgres (the row moves and keeps its id; the group is ADDED to a reminder that already names somebody; both races refuse), three inbound cases through the real messaging path (files on the group and says so instead of reporting an instruction; asks rather than inventing; refuses an ambiguous name), and one component case pinning that the owner KIND reaches the action — sending the id alone is how a group id would be read as a contact id. Not built, deliberately: a company note still gets no extraction on either surface (if one is ever added, both surfaces need the retirement at the same time), and there is no agent-turn action for filing onto a group — the filing loop only ever runs for a PlannedPerson, so a new verb there would not have reached this case at all.

  • Unfiled notes got their own page: /app/lists (2026-09-10, apps/web/src/app/app/lists/, app/app/plan/{page.tsx,load.ts,types.ts}, components/app/calendar/index.tsx, utils/constants/unfiled-notes.ts, utils/constants/app/nav/links.ts, utils/constants/landing/preview-routes.ts, utils/search-hit-href.ts, docs/AGENTIC_MESSAGING.md §7.7) — the unfiled list shipped at the top of /app/plan, on the argument that contact-less follow-ups are already that board's General scope so the other half of the same capture belonged beside them. It was the wrong home and this moves it. /app/plan is everything you owe and everything that is booked — dated work — and a capture that named nobody is neither: it has no due date, no status and no completion, which is exactly why it was never plumbed through follow-up-scope.ts, TaskRow or PlanList in the first place. Pinning a permanent list above a calendar made it read as overdue work, and it pushed the grid down for the account most likely to be holding one. Lists is a first-class rail entry, beside Calendar (APP_NAV_LINKS), so the mobile You sheet renders it for free; the phone bar stays a fixed five-slot grid, since a sixth item silently displaces a daily surface (the Groups argument, unchanged). Every link moved with one edit, and that is the whole payoff of the constant that already existed: UNFILED_NOTES_HREF is composed, never written out, so the note permalink's redirect (app/app/notes/[id]), the search-hit href for unfiledNote, and — the user-visible half — the WhatsApp/Telegram replies that quote the URL (FILE_IT_YOURSELF, ANSWER_HERE) all re-pointed together. A hand-written path in any one of them would have been a link a sender is actively told to open, landing nowhere. The #unfiled-notes anchor is kept even though the section is currently the whole page, so a link that names the list it means keeps landing on it once something sits above it. Deliberately NOT built (rule 2): no lists table, no list CRUD, no note-to-list assignment, and no disabled or "coming soon" widget for any of it — the page is a plain vertical stack whose only member is the unfiled section, which is the shape a second list drops into with nothing in the file to restructure. The page owns its empty state, and it is a swap rather than a second one: UnfiledNotesSection returns null for an account that never left a note unfiled (right on the board, where it had to cost the surrounding content nothing; wrong here, where it would leave a heading over blank space), so the page renders its own state when the read comes back empty instead of stacking two "nothing here" cards the moment someone files their last note and the page revalidates. The section keeps its own empty state for the interaction it was written for. loadPlanPage loses a read — one fewer serial round-trip on a page whose entire design is having fewer — and the unfiled node prop comes off PlanBoard. Tests: nav.test.ts pins Lists into the rail immediately after Calendar and out of the overflow, with the reason recorded (a page chat replies link to must not be demoted to More), and re-asserts the five-slot phone bar; preview-views.test.ts already demanded route copy for every href the landing mock's nav can emit, which is why /app/lists gains a LANDING_PREVIEW_ROUTE_COPY entry rather than rendering the anonymous placeholder that registry exists to forbid.

  • Tier C — the agent recognises what it cannot do from a chat, and refuses well (2026-09-07, packages/core/src/schemas/agent-turn/vocabulary.ts, apps/web/src/utils/constants/agent/app-only.ts, apps/web/src/lib/ai/agent/app-only.ts) — phase 6 of docs/AGENTIC_MESSAGING.md, and the owner's line is the specification: "refusing well is a feature; a vague 'I can't do that' is the failure." Six things stay app-only — deleting a contact, merging two long-standing people, anything in bulk, export, moving a contact back out of a workspace, and sharing — and the agent now names what was asked for, says what it would cost, and links the page that does it. One new action, explain_app_only, carrying a topic from a closed list of six, and the shape is the safety argument: what the model names is a subject to explain, never an act to perform, so there is no delete_contact for a prompt injection to aim at. It is absent from AGENT_WRITE_ACTIONS, so isWriteAction is false, the write dispatch is unreachable, and ./app-only.ts imports a string composer and no repository at all — a stronger claim than a gate that decides not to write. The refusal is composed in code, never by the model (the same rule as the filing replies): the model classifies, the app writes the words, so "names the contact, explains the cascade, links a real page" is a property of the build rather than of the model having listened. Deletion carries the owner's own acceptance criterion from §8.2 — the contact by name, and that it takes their notes, the facts drawn from them, their connections and their embeddings with it — and the name comes from what the run was actually shown (the loop's source log), not from a fresh read and not from the model, so a hallucinated id names nobody and falls back to the unnamed wording. Every URL was checked against a real page.tsx and a test resolves each one against the App Router tree on disk, because a refusal that links a 404 is worse than one that links nothing: /app/settings/export, /app/people/[id]/merge, /app/org/settings and /app/share all read like they should exist and none of them do. The real ones are /app/people (the merge picker, the bulk bar and all four export downloads live in its header), /app/people/duplicates, /app/people/<id> (Forget, Share, and per-note workspace visibility) and /app/org. A refusal with no topic is not allowed to happen — the loop sends the model back to name one rather than emitting a subject-less "that has to be done in the app", which is the failure and not the feature. The grammar budget went 11 → 12 of 32 properties (§4.2): six topics cost ONE property because the SDK flattens every enum to a string with its members in the description, which is also why the topic boundary is enforced by parseAgentTurn and not assumed from the type. Tier D is untouched and still unnameable — billing, auth, admin and messaging a third party remain absent from the vocabulary entirely, which is the distinction this phase does not blur. Tests: 6 vitest cases in lib/ai/agent/app-only.test.ts, all six topics driven with a full write context in hand so what is proved is the vocabulary rather than the read-only rail, plus the live-route resolver and its own negative control. Not built, and stated plainly: this is recognition and copy only — nothing new can be done from chat; the agent has to choose the action for the refusal to happen at all, so a Tier C ask it misreads still lands on answer; and it has not been run against a real graph with a real key (the live grammar test skips without one).

  • Tier B — "yes" now means something: merge, delete and move, each described from the row before it happens (2026-09-07, packages/core/src/schemas/agent-turn/, apps/web/src/lib/ai/agent/write/, lib/repo/messaging/conversation/write.ts) — phase 4 of docs/AGENTIC_MESSAGING.md. The confirm-then-act round trip shipped with phase 3 carrying one act (rename_contact); this adds the three of §3's five that belong in chat — merge_created_contact (fold away a duplicate this chat itself minted), delete_record (one note or one reminder) and move_note ("no, that was for someone else", after the note is already filed). Three new actions cost ZERO grammar properties and their arguments cost two — action is one enum property however many members it holds, exactly as Tier C's six topics demonstrated, so the price of an act is only ever what it must be told: recordId (a note or a reminder — one field, because which table an id lives in is a question code answers by looking) and mergeSourceId. Those two are deliberately not one field: one names a row the user may delete, the other names a person who will be, and a single string spanning both consequences is where a dispatch bug becomes a deleted contact. 12 → 14 of the 32-property ceiling, asserted with toBe against the artefact outputFormat actually sends. The merge gate is read from stored state, never from the model. Merge hard-deletes the source row and takes its id, its share links and any bookmarked /app/people/<id> with it, so — with contact deletion app-only — it is the last irreversible act in chat and the only one that keeps a gate on top of its confirmation: the source must be a contact this conversation created, and the ids are stamped on the agent's own stored turn from write receipts. "I made them a moment ago" is a sentence, and the person typing the messages can make the model write it. It is also not called merge_contacts — that name stays a Tier C topic and unnameable as an act, so the gate is not the only thing between a prompt injection and a deleted contact of any age. A confirmed act cannot be replayed, and check-then-clear could not deliver that: consuming the authorisation is one UPDATE matched on the action and the whole argument object, returning whether it changed a row, so a redelivered webhook or a double-tapped "yes" finds nothing to claim — and every act claims before it writes, so a failed claim cannot leave a write behind. "Name it back exactly" is a gate, not a nicety: the question is composed from the row the executor just read — a note's date and its opening words, a reminder's own text, both people in a merge, and what else goes (a note's tombstone takes its facts, reminders and graph connections; a dismissed reminder comes back from the plan board). Two things had to change around it. read_notes and read_due rendered no ids at all, so recordId had no reachable argument and neither act was possible — both now render id= in the shape search already used. And the reply composer had to stop sending Tier-B receipts through filedReply, which said "Filed this on ‹name›" about a deletion, and stop sending an open authorisation through the identity ways-out, which offered "say new person and I'll create them" under "shall I delete this note?"; the confirmation turn is also stamped confirm rather than ask, or the next message's "yes" would have opened the gate on minting a duplicate. move_note reuses moveNoteToContact whole — it re-points the note's receipt cascade and then rewrites graph edges and embeddings onto the new person, proved against a real database in lib/__tests__/move-note.test.ts — and is Tier B rather than Tier A precisely because of that: a wrong move is a silent corruption of the graph, not a visible mistake. Tests: 18 vitest cases in lib/ai/agent/confirm.test.ts, each failing by mutation — the gate refusing a long-standing contact without even asking, the gate reading state rather than the model's claim, a replayed "yes" acting once, an expired authorisation writing nothing, deletion quoting the exact row, the move going through the cascade rather than a bare update, and the read-only rail covering all three. Not built, and stated plainly: the remaining two Tier-B acts — writing to a connected calendar and capturing into an organization workspace — are still ahead, the org one deliberately its own change because it is a one-way trigger with no move back in the app either; nothing here is behind a new flag (these ride DHAGA_AGENT_FILING); delete_record cannot see a done or already-dismissed follow-up, which is the right default but not a decision anyone made deliberately; and it has not been run against a real graph with a real key.

  • Tier B, finished: a reminder can go on the connected calendar — and the agent still cannot put a contact into a workspace (2026-09-07, packages/core/src/schemas/agent-turn/vocabulary/, apps/web/src/lib/ai/agent/write/calendar.ts, lib/repo/calendar/write-out/index.ts, utils/constants/agent/calendar.ts) — phase 8 of docs/AGENTIC_MESSAGING.md, closing §3's Tier B with one act built and one deliberately refused. "Write to a connected calendar" is not a capability this product has, and building one would have re-opened a bug it already fixed. Everything Dhaga writes to a calendar is a follow-up mirror, and the only thing marking such an event as ours is a calendar_event_links row keyed on (connection, provider event id) — nothing is stamped on the event itself, no extendedProperties and no sentinel in the description — which both read paths that could double-count one (the nudge planner and the morning brief's Today section) filter on and on nothing else. A generic "create an event" act has no follow-up to key that row on (calendar_event_links.follow_up_id is NOT NULL), so its event would come back the next morning as an independent meeting and nudge the user about their own reminder: the exact duplicate loop mirroredEventKeys() was introduced to close. So the act is put_reminder_on_calendar — it takes a reminder id the loop was already shown and calls syncFollowUpToCalendars unchanged. It does not avoid the loop by being careful; it avoids it by not writing a second writer. It costs ZERO grammar properties: still 14 of 32, asserted with toBe against the artefact outputFormat actually sends, because its whole argument is the recordId that delete_record already carries — a when, a title and a calendarId are the price of the act that cannot be built. Nobody but the owner is touched, and it is checkable rather than asserted: the write event has four fields plus a description, the Google body sends summary/description/start/end, and no sendUpdates or sendNotifications parameter exists anywhere in the provider — with no attendees there is nobody for Google to notify. It lands on the secondary calendar Dhaga creates and owns, never the primary, unless the user picked one themselves; the confirmation states the exact title Google will show, the date to the minute, which calendar, and that nobody is invited — because "put it on my calendar" is the request most likely to be meant as "invite them", which is Tier D and never happens. The scope check runs before the question, off the same write plan the write itself uses, so a connection never upgraded past free/busy — or one whose write-out the user switched off — is a clean refusal linking Settings rather than a "yes" that then fails on a grant nobody asked for. Org capture was refused, and the design doc corrected rather than implemented. The capability §3 asked for already exists and is already safe: the workspace is parsed from an @slug prefix in deterministic code before the model runs, proved with a member-row read, and carried into the loop as a value that holds its own membership proof — the model is never told a workspace exists. An agent act could only ever override that decision from words in a message, which is the name match §3 itself forbids, and the mistake would be permanent: the one-way trigger raises on org→NULL and org→other-org across twenty tables, app.bypass_rls does not bypass a trigger, and there is no page, action or route anywhere that takes a contact back out of a workspace. A confirmation cannot buy back what the user has no way to undo. Pinned by four plausible act names refused at the parse, leaving move_out_of_organization — a refusal's subject — as the only organization word in the vocabulary. Tests: 11 vitest cases in lib/ai/agent/calendar.test.ts, each failing by mutation — nothing written without a confirmation, a replayed "yes" writing one event rather than two (the claim clears before the write, so losing it means stop, not retry), the event's every word coming from the write path rather than from the model, no writable calendar refusing before asking, an undated reminder refused rather than having its event deleted, a note id refused, and Ask Dhaga unable to reach a calendar at all. Nothing in the suite touches a real calendar: the write-out repository is doubled, so no provider is registered and no fetch is reachable. Not done: it has not been run against a real Google account; the four reasons for "nowhere to write" collapse into one refusal, so a user whose only problem is the toggle reads the same sentence as one who never connected anything; and the vocabulary became a directory under the 150-line rule (nameable.ts for what may be named, tiers.ts for what each name costs), with one test file still covering both because an act nameable but missing from the write list is a Tier-A grant by omission.

  • "Your meeting starts in 10 minutes", "remind me in 16 mins", and "remind me again in 10" (2026-09-01, lib/jobs/nudges/, lib/messaging/reminders/, lib/repo/scheduled-sends/, utils/constants/nudges.ts, app/api/jobs/tick/) — Dhaga knew when things were due and said nothing until the next morning's brief. Three pieces now: a queue, a sweep that fires it, and two chat intents that fill it. A QUEUE, NOT A SCAN, and the reason is in the existing code: follow_ups has no index on due_date, getDueFollowUpRemindersForUser loads every task in the account and filters in JS, and connected-calendar events are persisted nowhere at all — so "select everything due in the next ten minutes across all tenants" had nothing to stand on. scheduled_sends is materialised per tenant over a 3-hour look-ahead and the delivery sweep is then ONE indexed global query whose cost is O(rows due), not O(tenants). Materialising is idempotent on a unique (user_id, kind, ref_id, fire_at), so refilling the same window twice queues once. A second index was added for the materialiser itself (follow_ups_timed_due_idx ON follow_ups (user_id, due_date) WHERE status='open' AND due_has_time) — the queue removed the JS scan from delivery but not from the pass that fills it, and that pass runs per tenant every few minutes. THE LEAD TIME IS A THREE-RUNG LADDER (resolveLeadMinutes): a per-item override, then the calendar event's own setting, then the user's default (10). The bottom two rungs now fire; the top one still cannot — schedule_prefs.nudgeLeadMinutes gained its Settings control on 2026-09-06 (components/app/settings/NudgeLeadSetting/, setNudgeLeadMinutesAction), a select of eight offered lead times plus whatever is already in force, read-then-patched onto the same blob the digest schedule and time zone live in. Before it, the value was stored, parsed, clamped and read by the planner and rendered by nothing, so every user sat on the fixed ten minutes unless their calendar overrode it. itemMinutes is still passed null at every call site and there is no column to hold it, so the per-item override remains a shape rather than a feature. The middle rung is the point — if someone already set "remind me 30 minutes before" in Google, asking them to say it again in Dhaga is the integration failing at its job. It required real work: CalendarEvent.reminderMinutes: number[] | null is new, Google's EVENT_FIELDS mask did not request reminders at all (so Google trimmed them from every payload), and only method: "popup" counts — an email override is Google mailing them, and firing a WhatsApp ping off it would honour a preference about a different channel. An array because Google models a LIST of overrides and Microsoft (isReminderOn/reminderMinutesBeforeStart) models exactly one; a scalar would be lossy for Google. null means "inherits the calendar's default", which is deliberately not resolved: that value lives on the calendarList resource, and threading per-calendar defaults through the gateway to learn something the user's own setting already answers is not worth the concept. "REMIND ME IN 16 MINS" BYPASSES THE BATCH, and that is about latency, not meaning. It is ordinary capture on its face — a self-directed follow-up with a due time, which the batch planner already models well — but capture is BATCHED and the idle window is 24 hours, so a sixteen-minute reminder first read tomorrow is not late, it is broken. So reminder and snooze joined the intent classifier from #310 and divert around the batch exactly as question does. A duration past QUICK_REMINDER_MAX_MINUTES (a week) falls back to capture on purpose: that is a commitment, not a timer, and the planner dates it so it reaches the plan board, the brief and the connected calendar, where a queue row appears on none of them. fireAt for a quick reminder equals the due instant EXACTLY — lead time is zero, because "remind me in 16 minutes" means ping me then, not ten minutes before then; a test pins it, and a lead reaching it would fire every user's timer early. The floor was a promise the delivery cadence could not keep until 2026-09-04's follow-up change: QUICK_REMINDER_MIN_MINUTES is 1, but the sweep ran quarter-hourly, so "remind me in 2 minutes" arrived on the next quarter-hour — up to ~13–14 minutes late, with nothing failing and nothing logged to say so. The per-minute /api/jobs/deliver route makes it honest to the minute. NUDGE_GRACE_MINUTES stays 30 regardless: it is sized against the reader, not the cadence. SNOOZE NEEDS NO CONVERSATIONAL STATE: the sweep already records sent_at, so the most recent sent row inside SNOOZE_WINDOW_MINUTES IS the referent — the same window-not-state-machine shape corrections use. nextMorning is a flag rather than a minute count because "tomorrow" is not a duration: it resolves against the sender's zone AND their own chosen digest time, neither of which the model is told, so a model asked for minutes would invent an answer wrong for most of the world. Resolved with instantFromZonedWallClock over zonedDayRange's boundary, never +24h (a DST day is 23 or 25 hours); pinned in Asia/Kolkata, where a UTC shortcut misses by 11½ hours. A CROSS-TENANT LEAK WAS CLOSED ON THE WAY. dueScheduledSends must run un-scoped — it has to find due rows before it knows whose they are — so without a WITH CHECK policy one tenant could queue a row naming another tenant's follow_up id, and resolving it on the un-scoped connection already in hand (the obvious optimisation, in the one code path where nobody is thinking about tenancy) would have delivered a stranger's private task text to their WhatsApp. The policy makes that row unwritable; an RLS isolation case proves it denies. Outside WhatsApp's 24-hour window a nudge cannot say what it is about: template parameters are submitted to Meta and rendered in their systems, and a meeting title is third-party contact data, so the nudge template takes no parameters and says only that something is coming up. That is a real limitation, not an oversight — naming the thing would mean putting contacts' details through Meta's pipeline. Telegram has no such rule. The grace window is a DROP, not a catch-up: a nudge forty minutes late is a confusing message about a meeting that already started, so an outage loses what it missed rather than firing a morning's backlog at once. A follow-up completed, deleted or moved earlier sends nothing; one pushed BACK is dropped too, bounded by the user's own lead (exact, since a follow-up has no per-item override) — otherwise a rescheduled task announces "in 305 minutes". "Moved earlier" is judged against created_at, not the clock alone (2026-09-08): a snooze queues a SECOND row for a task whose due has already passed and deliberately leaves due_date where it was, so every snooze of an already-late reminder looked exactly like a task moved earlier and was dropped — after the bot had answered "I'll remind you again at 09:37" out loud, with sent_at stamped so nothing retried. A row queued BEFORE its due still drops; a row queued after its due can only be a snooze, so it sends. Known and accepted: a task re-dated to an instant before its own row was queued now sends "starting now" rather than dropping. /api/jobs/digest was renamed /api/jobs/tick in the same change: it now runs the digest and the nudges, and an operator adding one cron line must not read "digest" and fail to realise their meeting reminders depend on it. IT FIRES AS OF 2026-09-04 — apps/web/vercel.json was deliberately unchanged while this ran on Vercel Hobby, where a sub-daily entry breaks the deploy, so nothing here fired at all; the account is now on Pro and {"path":"/api/jobs/tick","schedule":"*/15 * * * *"} was added there, next to the daily "17 6 * * *" entry, which stays once-a-day because it drives eleven nightly jobs and speeding it up would re-send transactional email. A THIRD cron followed on the same day — {"path":"/api/jobs/deliver","schedule":"* * * * *"} — because */15 made "remind me in 2 minutes" land up to 13 minutes late: the quarter-hourly tick also runs the O(tenants) digest sweep and materialiseNudges with a calendar OAuth round-trip per tenant, which at every minute would be ~92,000 Google calls a day, whereas delivery alone is one indexed O(rows due) query that returns nothing most minutes. /api/jobs/tick still calls delivery too, so an operator who adds only the */15 line gets nudges, just late. Firing is not verification — see the closing line. A self-hosted instance drives the route itself. Core-only self-hosts get no nudges: every one rides a linked chat keyed on a user id, and hostedTenantIds() returns null there. Tests: lib/jobs/nudges/{plan,sweep}.test.ts, messaging-cases/chat-reminders.test.ts, packages/core/src/calendar/__tests__/reminders.test.ts. Not verified against a real WhatsApp or Telegram message, and no Meta template is approved.

  • Pre-event nudges — "your meeting starts in 10 minutes" (2026-09-01, lib/jobs/nudges/, lib/repo/scheduled-sends/, lib/db/ddl/scheduled-sends.ts, utils/constants/nudges.ts, utils/constants/messaging/nudge-replies.ts, app/api/jobs/tick/) — built, typecheck/unit level only: no nudge has ever reached a real WhatsApp or Telegram chat. Nothing drove the sweep anywhere until the */15 cron landed on 2026-09-04, and delivery only became minute-accurate when /api/jobs/deliver was added on * * * * * beside it; both fire now, and neither has been checked against a real chat. The minute-resolution half of "remind me", which every reminder surface before it could only answer in DAYS. The sweep is two halves that never share a loop, and that split is the whole design: materialise.ts is O(TENANTS) and expensive per tenant — a settings read, an indexed read of timed follow-ups on a partial index built for exactly that predicate (ddl/follow-up-nudges.ts), and an OAuth round-trip to Google or Microsoft through readExternalEvents, which is unavoidable because connected calendar events are persisted nowhere in this schema, so there is no table a "meetings starting soon" query could read; deliver.ts is O(ROWS DUE) — ONE indexed, deliberately un-scoped claiming read (claimDueScheduledSends) across every tenant at once, grouped by user_id, with the sender re-entering that scope before it resolves or marks anything. Fused into one per-tenant loop, delivery would inherit materialise's cost and a ten-thousand-user instance would do ten thousand users' work every fifteen minutes to send four messages; the queue table between them is what buys the split. Delivery runs even if materialise throws, because a Google outage must not also stop nudges queued ten minutes ago — those are promises already made. The queue is idempotent on (owner, kind, ref_id, fire_at), so overlapping ticks, a platform retry or a mid-run redeploy re-plan a window and write nothing. Two overlapping DELIVERY runs are a different problem, and stamping sent_at was never the answer to it — that happens after the send, so both runners would already have messaged the user. The due read is therefore a CLAIM: claimDueScheduledSends (lib/repo/scheduled-sends/claim.ts) stamps a new claimed_at column in the same UPDATE … RETURNING that reads the rows, FOR UPDATE SKIP LOCKED on the bounded inner select, so a concurrent run sees none of them. claimed_at is the LOCK and sent_at stays the RECEIPT — stamped only after a successful send or a "nothing to say" drop, so a failed send is retried rather than recorded as delivered. A failed send releases its claim explicitly; a run that is killed never does, so the claim expires after NUDGE_CLAIM_LEASE_MINUTES (5). The lead time is a three-rung ladder, explicit beating inherited (resolveLeadMinutes): a per-item override, then the calendar event's OWN reminder setting from Google or Microsoft — a user who already said "30 minutes before" in their calendar should not have to say it again here, or the integration has failed at its job — then schedule_prefs.nudgeLeadMinutes, default 10; zero is a real answer at every rung ("starting now"), so each is tested for null, not for falsiness. A late nudge is DROPPED, never caught up: NUDGE_GRACE_MINUTES (30) bounds the due read and anything older is silently abandoned, because a forty-minute-late ping is a confusing message about a meeting that already started, and firing a whole morning's backlog on recovery from an outage is the fastest way to get the bot muted — which costs the user every future nudge too. New WhatsApp template kind nudge (WHATSAPP_TEMPLATE_NUDGE) for chats outside Meta's 24-hour window, and its approved body must carry no {{n}} placeholders at all — the sweep sends params: [] on purpose, because the only things a nudge could name are a meeting title or a follow-up's action, i.e. third-party contact data, and template parameters are submitted to Meta and rendered in their systems; the templated nudge therefore says only that something is coming up and links into the app, with the deployment's own URL baked into the approved body (wording to submit: nudgeTemplateFallbackText). Inside the window, or on Telegram, the free-form nudge names the thing and offers a snooze. TWO THINGS THIS DOES NOT DO, and they decide whether anyone sees a nudge at all (Rule 12). (1) Only a cron drives these routes, and until 2026-09-04 there was none. They were deliberately absent from apps/web/vercel.json because Vercel Hobby permits one cron a day — and triggers even that one only within a ±59 minute window of the schedule — while a sub-daily entry fails the deploy outright; the account is now on Vercel Pro, whose minimum interval is one minute and whose crons fire with per-minute accuracy, and that file now carries THREE entries: the untouched daily "17 6 * * *", /api/jobs/tick on */15 * * * *, and /api/jobs/deliver on * * * * *. No other caller exists — not the nightly aggregator, not the messaging webhook, not the app — so anywhere those crons are absent, which includes every self-host, no nudge is ever sent, however the lead time is set. A self-host that schedules only the tick gets its nudges up to ~14 minutes late rather than never, because the tick still delivers as well as materialises — that overlap is deliberate, and it is safe because of the claim above. (2) A core-only self-host (no packages/ee) gets no nudges either. Every nudge goes over a chat keyed on a user id, and that mode has no tenant row to name — the same call daily-brief/sweep/target.ts already makes for the chat channel, where a null id skips chat delivery rather than inferring "the instance's only user". Materialise reports skipped: "self_host" and delivery finds nothing routable. Covered by lib/jobs/nudges/sweep.test.ts and plan.test.ts (the lead-time ladder, the provider override, the grace drop, and re-running a window queueing nothing twice) Correction (2026-09-04): on a hosted deployment the sweep read ZERO rows and no nudge could ever have been delivered, cron or no cron. scheduled_sends is in TENANT_TABLES, so packages/ee gives it ALTER TABLE … FORCE ROW LEVEL SECURITY — and FORCE binds the table OWNER too, not merely ordinary roles. The claim above is deliberately un-scoped (it must find due rows before it knows whose they are), so with no app.current_user_id set the policy's USING matched nothing: the UPDATE … RETURNING stamped no rows, returned none, reported due: 0, and never errored. This is the same class of bug as the 2026-07-30 digest correction below — an un-scoped read under RLS, silent because an empty result and "nothing was owed" are indistinguishable — and it worked on a core-only self-host precisely because no policy exists there. The fix is the transaction-local app.bypass_rls the policies have always accepted and nothing outside the DDL had ever set: TenantGate.nudgeClaimDb() (lib/hosted/gate/tenant-types.ts → packages/ee/src/tenant/nudge-claim.ts, on the same openAdminConnection the admin panel and the Stripe webhook use), reached through lib/repo/scheduled-sends/delivery-scope.ts and returning null on core-only, where the plain global pool is already correct. It covers exactly two statements — the claim, and marking or un-claiming those same rows (settle.ts) — and never the send path, which still re-enters the row owner's own scope via withUserDb for the follow-up wording and the schedule preferences. The marking had to move with it: a tenant-scoped UPDATE of a kind='follow_up' row whose task was completed or deleted fails the table's own WITH CHECK with a 42501, which is the commonest row the sender touches, and the abort would have left genuinely delivered nudges unmarked and re-sent once the claim lease expired. The cross-tenant WRITE guard is untouched — it constrains tenant writes on tenant-scoped connections, and both the RLS isolation case and a new non-superuser PGlite suite still assert the deny. Proved automatically: packages/ee/src/db/__tests__/scheduled-sends-policy.test.ts replays the shipped policy into a PGlite running as a NON-SUPERUSER role (the only way to observe FORCE at all) and asserts both directions — un-scoped reads and claims find nothing, the same statements under bypass find every tenant's due rows; packages/ee/src/tenant/__tests__/nudge-claim.test.ts pins the GUC as transaction-local (is_local = true, inside one BEGIN…COMMIT, no session SET, no RESET ALL), which is what stops it riding a pooled connection into the next tenant's request; apps/web/src/lib/repo/scheduled-sends/delivery-scope.test.ts pins that both statements actually ask the gate, and that the core-only fallback still works. Still not verified end to end, and this changes nothing about that: no nudge has ever reached a real WhatsApp or Telegram chat, and no Meta template is approved. The hosted path has never been run against a live Postgres with RLS on — PGlite is real Postgres, but Supabase's pooler is not in any of these tests.

  • A pre-event nudge for a user with no linked chat is no longer retried every minute and then lost (2026-10-06) — no linked WhatsApp/Telegram was reported exactly like a send failure, so the row was released and retried every tick for the whole 30-minute grace window, then silently abandoned: the user heard of a timed follow-up (which needs no calendar) by no channel at all. sendScheduledChatMessage now reports noChat (no identity, or only identities for a provider this deployment lacks) apart from blocked and from a failed send. A noChat nudge is emailed only when the user opted into the morning follow-up reminder email (morning_reminder_enabled, a missing row reads OFF) and mail is configured; otherwise it settles as undeliverable (marked, counted apart, never retried; the in-app bell and Plan page still show it). A chat that merely failed is unchanged (retried, never swapped to email); plan/cap blocked still emails. Claim, sent_at receipt and release semantics are unchanged, so a nudge is never sent twice. Open product decision: whether a user with no chat and no opt-in should get a first-time nudge email at all (today: no).

  • Proactive chat is a Power feature, and its WhatsApp cost is measured and bounded (2026-09-04, lib/messaging/outbound/{gate,budget,cap-notice}.ts, lib/messaging/cost/, lib/db/ddl/whatsapp-send-costs.ts, utils/constants/messaging/whatsapp-pricing/, utils/constants/plans/features/) — built, typecheck/unit level only: no gated or capped message has ever gone to a real chat. Two mechanisms, and the whole point of the entry is that they are two. THE POWER GATE is the new proactive_chat plan feature, checked once in sendScheduledChatMessage before identities are even listed. It covers the digest and pre-event nudges on BOTH providers, and gating Telegram — which costs us nothing to send on — is deliberate: the gate is on the FEATURE (Dhaga starting the conversation), not on the delivery cost, and a gate placed on the paid path would let a Pro user link Telegram and take the entire proactive product for free. It sits above the 24-hour window check for the same reason: an in-window WhatsApp nudge is free-form and free, but it is still the Power feature, and gating only the paid path would hand every engaged Pro user free nudges on every day they happened to message the bot. proactive_chat is the first thing separating Power from Pro by capability rather than by volume — the tiers were volume-separated on purpose, and this is the one capability a credit allowance cannot size, because it costs real money per message and per recipient market. A free-form REPLY is never gated and never capped, on any plan; that is multi_device_sync, already sold to Pro, and Meta bills nothing for it. THE COST CONTROLS apply only where Meta actually bills — the template branch, outside the window — and only to users who already passed the gate. utils/constants/messaging/whatsapp-pricing/rates.ts prices a send by the recipient's E.164 calling code (longest-prefix, the same rule rateFor uses for model snapshots), and an unknown market fails towards EXPENSIVE, landing on the dearest rate in the table exactly as MOST_EXPENSIVE_RATE does in lib/ai/cost/rates.ts — under-reporting is what lets an unbudgeted cost run free, and a market that priced at $0 would be invisible until the invoice. Keyed by calling code rather than ISO country because the only identifier we hold is a wa_id, turning that into an ISO country needs libphonenumber, and Meta prices by market anyway (+1 is one rate). THE RATES ARE INDICATIVE, NOT AUTHORITATIVE (Rule 12): they are Meta's published utility figures read 2026-09-04, and an operator must re-check them against their own WhatsApp Manager rate card before reconciling an invoice. Two bounds, bounding different failures: a per-message ceiling (DHAGA_WHATSAPP_MAX_USD_PER_MESSAGE, default $0.10, shipped deliberately ABOVE every rate in the table so it refuses no priced market and exists to catch a future re-price) and a per-user monthly COUNT (DHAGA_WHATSAPP_MONTHLY_PAID_MESSAGES, default 60). A count rather than a dollar budget because it is explainable and does not move under the user when a rate card changes; 60 × the $0.10 ceiling is $6.00 against Power's ~$7.50 standing monthly revenue, and in +91 the same 60 messages cost eight cents. The month is the user's own (schedule_prefs.timezone), not UTC — a UTC reset would cut a user in Auckland off for the last thirteen hours of theirs. STORAGE: a new whatsapp_send_costs table, deliberately NOT ai_actions. The shape fits and the meaning does not: aiCreditsUsedThisMonth counts every row in that table and multiplies by a credit price, costOfAiAction prices from tokens against a MODEL rate (a WhatsApp send has neither), and the master dollar gate is enforced against its sum — so folding messaging spend in would make a user's digests refuse their card scans. The dollar amount is stored, not re-derived, for the reason ai-cost-accounting.ts gives about its DEFAULT 0 columns: re-pricing history silently restates last month's bill the next time Meta moves a rate. Micro-dollars, integer, because rates run from $0.0014 to $0.08. It stores the country code and NEVER the number — the number enters lib/messaging/cost and leaves as a calling code and a price, and nothing downstream is given the chance to log it. TENANCY: per-tenant, the opposite call to whatsapp_templates on the branch below. A template's review state belongs to the deployment's one business account; a cost row belongs to one user — so this IS in packages/ee's TENANT_TABLES (with a row-isolation spec) and IS in ACCOUNT_OWNED_TABLES. An unscoped read is a behavioural profile (how often we message this person, and roughly where they are) rather than an invoice, and an unscoped WRITE is the cheaper attack: rows planted under another tenant's id spend their cap and silently push their messages to email. Core declares user_id itself, like scheduled_sends, because the monthly-count index has to name the column. THE FALLBACK IS ONE PATH FOR BOTH REASONS. sendScheduledChatMessage now returns { delivered, blocked }, and blocked separates a RULE (gate, cap) from a FAILURE (Meta 5xx): a rule is still true in fifteen minutes, so the digest sweep marks the chat record and emails the brief — overriding a switched-off email preference on purpose, because delivering nothing is worse and the settings copy already says "they arrive by email instead" — and the nudge sweep emails the nudge (lib/email/nudge.ts, subject deliberately vague since it lands on a lock screen). A failure marks nothing and the next tick retries the chat, exactly as before. The cap notice is sent once per the user's local month, claimed before it is sent so a concurrent tick cannot double it; a notice per diverted message would be a second unwanted broadcast. UI: greyed, never hidden. digestChannels is { email, chat } where chat already means "whichever provider is linked", so there is ONE control to grey — wrapped in the existing PlanGateNotice (hover, keyboard focus, and a visible pill, the last being the one that works on a phone), with the hidden field still carrying the stored preference so an upgrade restores the channel the user chose. Copy in utils/constants/messaging/plan-gate.ts, naming both channels because a reason mentioning WhatsApp would make a Telegram user's refusal read as a bug. Tests: lib/__tests__/proactive-chat-gate/ — a Pro user gets no proactive Telegram message; an in-window WhatsApp nudge to a Pro user is blocked by the gate and counts against no cap; a Power user over the cap falls back to email and is told once, not per message; an unknown country prices at the expensive default rather than zero; a rejected template is not charged; one tenant's spend cannot cap another's; and a source-level spec that no reply-path module imports the gate, the cost table or the proactive sender. NOT verified against a real WhatsApp message, and no rate has been checked against a live Meta invoice. (The "nothing drives /api/jobs/tick" caveat this entry shipped with was already out of date when it merged: the */15 tick and the per-minute /api/jobs/deliver cron both landed on 2026-09-04, so the gate and the cap do run now — they have simply never run against a real chat.)

  • Dhaga submits its own WhatsApp utility templates for approval (2026-09-04, packages/core/src/messaging/whatsapp-client/provisioning.ts, apps/web/src/lib/jobs/whatsapp-templates/, lib/db/ddl/whatsapp-templates.ts) — built, and no template has been approved yet, so every scheduled WhatsApp send still degrades to free-form and is dropped by Meta for anyone who has not written in within 24 hours. The two entries above both close on "no Meta template is approved"; this is the mechanism that will change that, not the fact that it has. Why it is code and not a shell script: WHATSAPP_ACCESS_TOKEN is marked Sensitive in Vercel and sensitive values are write-only — vercel env pull returns an 11-character placeholder and neither the CLI nor the dashboard can read the value back — so the running server is the only thing that holds the token, and provisioning has to live inside the deployment. That is the security model working, not a workaround. It rides the daily cron rather than the quarter-hourly tick because Meta approval takes days and a 15-minute poll is abuse. The job resolves the WhatsApp Business Account id from the phone-number id (it is not in the environment), lists templates by name, and creates only what is missing. A name present in ANY status — approved, pending, rejected, paused — means "already provisioned", and the only branch that creates anything is the one where the account has nothing by that name: resubmitting an unchanged body nightly is what gets a WhatsApp Business Account flagged and then restricted, so a REJECTED template is recorded and logged and never resubmitted; changing the wording is a human decision. Status is per-INSTANCE in whatsapp_templates (unscoped, deliberately absent from EE's TENANT_TABLES — it describes our Meta account, not a tenant's data), only APPROVED reaches OutboundTemplate.approvedName, and the WHATSAPP_TEMPLATE_* env overrides still win so a hand-approved template needs no deploy. Zero-touch since 2026-10-21 (lib/jobs/whatsapp-templates/{run,specs,environment}.ts, lib/repo/whatsapp-templates/attempt.ts): the nine Pages templates joined the same job and table (kinds pages_*), and the env-gated Pages submitter that required WHATSAPP_BUSINESS_ACCOUNT_ID is deleted (that variable survives only as an optional override). The DATABASE is now the source of truth for what exists — a row for this phone number in any status means provisioned; a name already on the account is adopted. The quarter-hourly tick now SUBMITS whatever has no row, so a deploy's templates go in within ~15 minutes: one read and no network when nothing is missing, otherwise one attempt per 6 hours (or per new deployment / phone number) under a one-row claim that stops two crons double-submitting. Status checks stay on the daily run. Production only (VERCEL_ENV=production, or NODE_ENV=production off Vercel). Still not run on Dhaga Cloud: its whatsapp_templates table is empty because the stored token lacks whatsapp_business_management (lookup failed with Meta code 100); a token with both permissions needs no code change

  • Automatic relationship-decay detection — built read-time (no nightly job needed): a Quiet-ranked reason in Home Moments surfaces contacts with no touch in ~8 months and no cadence set (repo/strength.ts); typecheck/lint/build/tests pass, manual click-through not done

  • Relationship-strength score from own-graph data (interaction recency/frequency, notes, events — no external data) — built: 0–100 recency×frequency score (scoreStrength), ranks the Going-quiet feed strongest-first; tests in strength.test.ts, manual click-through not done

  • Post-event digest email (user-triggered from the event page, template-based)

  • Pre-meeting brief on demand ("Brief me ✦" on the contact page, graph-only)

  • Ask Dhaga about ONE person ("Ask ✦" on the contact page, 2026-08-17) — built, no browser pass. A question box on /app/people/[id] answered strictly from that contact's own notes, facts, meetings and open follow-ups. Deliberately not the global Ask pipeline: with a known contactId there is nothing to plan and nothing to search, so lib/ai/contact-ask/ reads the bundle directly the way lib/ai/brief.ts does — full note bodies instead of four ranked snippets, no lossy filter to leak or drop a record, and one fewer model call (hence contact_ask = 1 credit against search = 2; derivation in packages/core/src/metering/credits/table.ts and BRD §8.3). Non-streaming useActionState + ThreadLoader, matching the page's existing Brief/Draft sections rather than porting the palette's NDJSON stream; the answer renders through the palette's existing AnswerMarkdown (it is Markdown — a whitespace-pre-wrap card would show literal **). Connection discipline follows brief.ts exactly, because holding a tenant connection across a multi-second model call is what took Ask Dhaga down in production (#92, SCALING.md lever 2): one withUserDb for the whole read, a second short one for assertAiBudget, the LLM call with no scope open, a third short one for recordAiAction. Context is budgeted — notes newest-first to CONTACT_ASK_NOTE_CHAR_BUDGET (6,000 chars ≈ 1,500 tokens, max 25 notes), 30 facts, 10+10 meetings, 15 follow-ups — and when the budget cuts the history short the prompt says so, so the model answers "based on your most recent notes" instead of confidently answering from a partial record. No requireFeature gate (the global Ask has none; Brief's pre_meeting_brief gate is a decision about briefs, not a house style), reuses the shared "ai" rate-limit bucket via assertAiBudget. Prompt contract covered by lib/__tests__/contact-ask-prompt.test.ts (the mandated "do not fabricate" sentence, the date in the volatile user prompt and never the cached system prompt, the question last, truncation announced) plus the new case in prompt-dates.test.ts

  • Calendar integration → brief pushed 30 min before the meeting (2026-09-06, lib/jobs/nudges/{brief,label}.ts, lib/jobs/nudges/resolve/, lib/jobs/nudges/deliver-user/, lib/messaging/outbound/{index,deliver}/, utils/constants/nudges/, lib/repo/suggestion-settings/schedule/) — built, typecheck/unit level only, same as every sibling nudge entry: no brief has ever reached a real Telegram chat, and WhatsApp carries none of this at all. Extends the existing pre-event nudge (above) rather than adding a second queue: the SAME meeting row now optionally carries an ENRICHMENT — who you're about to meet, when you last spoke, how many follow-ups are still open — on top of the short "starts in N minutes" text every meeting has always sent. NO LLM: the brief is assembled in code from getContact (name, lastTouchSql) and listOpenFollowUps (a count, never the wording), the same repos the contact page already reads, so it costs nothing and cannot hallucinate about a real person. WHATSAPP IS UNCHANGED, DELIBERATELY. OutboundTemplateKind gained no third value and no template was submitted — a free-form brief outside Meta's 24-hour window would be dropped, and template widening was explicitly out of scope. sendScheduledChatMessage gained an optional richText alongside text; deliverToIdentity (lib/messaging/outbound/deliver/) uses it ONLY when the channel has no sendTemplate method at all (Telegram today), in-window or out, gated or not — a template-capable channel always gets text, unconditionally, which is the one guard the whole feature stands on. Telegram and, as the existing gate-refusal fallback, email are therefore the only channels that ever carry a contact's name. THE CONTACT MATCH RIDES IN label, NEVER refId. label was a bare event-title string; it is now { title, contactIds } JSON, decoded defensively so a plain string — every row a build before this shipped ever wrote — still reads as a title-only meeting. refId, the queue's dedupe key (user_id, kind, ref_id, fire_at), is completely untouched: folding the contact match into it instead would have meant a second materialise pass that resolves one more attendee produces a DIFFERENT ref_id for the same meeting — a second row, a second send, the exact double-nudge the queue exists to prevent. LEAD TIME: a new meetingLeadMinutes (default 30) alongside the existing nudgeLeadMinutes (default 10, unchanged, still follow-ups only) — a deliberate split rather than reuse, because a meeting nudge may now need real reading time before the room and a follow-up nudge never did; applied uniformly to every meeting, brief-eligible or not. Both fields live in the same schedule_prefs blob and the same NudgeLeadSetting card, now two selects saved by one form. PRIVACY: only a relative last-touch time and a follow-up COUNT — never a follow-up's own wording or a note body — the same "names and counts only" rule the daily brief's chat rendering already applies. A contact deleted between materialising and firing is skipped rather than failing the send; a meeting matching nobody still sends today's plain nudge, unchanged. Tests: lib/jobs/nudges/{label,brief}.test.ts, lib/jobs/nudges/plan/meetings-brief.test.ts, lib/jobs/nudges/sweep/brief.test.ts, lib/__tests__/proactive-chat-gate/rich-text.test.ts. Not verified against a real Telegram or email send, and — as with every WhatsApp path above — no Meta template exists for this content and none was submitted.

  • Two-way calendar sync (read + write events, beyond today's free/busy-only connection) — Built 2026-07-29. Capability is derived from the OAuth scope already stored on calendar_connections.scope (packages/core/src/calendar/capability.ts) — no capability column, and it double-gates on the scope granting it and the provider implementing it. The default scope constants are byte-identical to what shipped, so every existing free/busy connection is untouched; broader scopes are requested only via /api/calendar/connect/{provider}?upgrade=1, behind an explicit Settings button. Google adds calendar.readonly + calendar.app.created (the latter confines the app to calendars it created — a much narrower ask at verification); Microsoft goes Calendars.Read → Calendars.ReadWrite, and Calendars.Read is deliberately excluded from the read capability because every pre-M2 connection already holds it and would otherwise silently gain event reads. Two refresh-path regressions were fixed in the process: Microsoft re-sends scopes on refresh (would have narrowed an upgraded connection back to free/busy), and a reconnect whose token response omits scope would have wiped the upgrade. Write-out targets a secondary "Dhaga" calendar only, never the primary, and propagates on create (manual + AI-extracted), update, reschedule, complete and dismiss — complete/dismiss/date-cleared delete the event and drop its link row. It runs in after() in three separated phases (DB → network → DB) so a tenant connection is never held across an HTTP call. calendar_event_links maps follow-up → event because Graph refuses client-chosen event ids; it is in EE TENANT_TABLES. 21 new + 41 existing calendar/follow-up tests green, tsc + eslint clean. Pending: no live OAuth or API round-trip anywhere — no Google/Microsoft credentials in the build environment, so every HTTP path (event list, calendar find/create, event create/patch/delete, the 404/410 fallbacks) is exercised against mocked fetch only, and the consent/upgrade flow is entirely unexercised; no browser render or screenshot; the lib/repo/confirmations/apply.ts "supplement" path now propagates follow-ups to the calendar like every other path (it called applyExtraction and nothing scheduled the write-out); note that applyExtraction writes dueHint and leaves dueDate null, and an undated follow-up produces no event — so neither this path nor note-extraction places one until the user dates it; typedoc content/docs/api/** is stale for the calendar providers; mobile calendar parity is now built (see the M4 entry below), so the parity gap is closed in code though not on hardware; calendar-theme.css is 187 lines, over the 150-line rule (a side-effect-imported CSS theme cannot be split behind an index.ts); opened as PR #135. Scope note: Google's calendar-event scope is sensitive, not restricted — standard verification, no CASA security assessment, no annual third-party audit, no fee (restricted/CASA covers Gmail and Drive)

  • Demo Calendar fixture flipped opt-in (2026-08-13) — packages/core/src/calendar/demo-provider.ts now shows the synthetic provider only on DHAGA_CALENDAR_DEMO === "true" (was opt-out, !== "false"). Its busy blocks are fabricated but flow through the real free/busy path into Home's calendar-backed Find a time actions and the daily brief's suggestion count, so an opt-out default meant a production account could connect it and silently corrupt that advice. apps/web/.env.example and docs/SELF_HOSTING.md document the flip; the example file still ships DHAGA_CALENDAR_DEMO=true because it is a dev template

  • Imported events are clickable; per-event colour (2026-08-13) — built, not browser-verified. ExternalEventDialog.tsx opens read-only detail on an imported event: title, when, location if present, and calendar · account — the only way to tell a work meeting from a personal one on a merged grid. No deep link out to the provider, on purpose: the event carries no htmlLink and the provider URL never crosses the RSC boundary. Attendees are dropped at the repo and forbidden by the props type. Events stay editable: false, startEditable: false; isFollowUpEventProps is the positive gate so only follow-ups re-date on a drag. Colour resolution is two-stage and the precedence is deliberate: Google's per-event colorId (mapped through its fixed 1–11 palette) beats the calendar's colour, and the user's user_color override beats the provider's background_color — a meeting the user tinted in Google keeps that tint here. event-map/colors.ts discriminates on a leading # (provider hex) vs a palette token, and an unknown token resolves to the theme default rather than reaching the DOM as an invalid CSS colour — the one place the "never a raw hex in a component" rule does not apply, because the hex is user/provider DATA

  • Write-target calendar picker + writeAnyCalendar (2026-08-13) — built, consent flow unexercised. Follow-ups can be written into a calendar the USER picks instead of only the app-created "Dhaga" one. Third capability axis writeAnyCalendar on CalendarCapabilities, kept a SEPARATE token list from writeEvents rather than a widening: Google's calendar.events (SENSITIVE, pulls the app into verification) is required for it and is deliberately absent from GOOGLE_WRITE_SCOPE_TOKENS, so a connection that upgraded before this shipped derives writeEvents: true, writeAnyCalendar: false — keeps writing to "Dhaga", needs no reconnect, and unlocks the picker only by re-consenting through the same ?upgrade=1 link. Microsoft derives all three axes from Calendars.ReadWrite because Graph has no app-created-only scope — reporting it false would hide a control while changing nothing about what Dhaga could reach — but with no listCalendars there is nothing to pick, so behaviour is unchanged. connectionCapabilities gates writeAnyCalendar on writeEvents, never on the scope alone. ensureWriteCalendar throws rather than silently find-or-creating when a user-chosen calendar is unreachable: quietly writing someone's follow-ups into a fresh calendar is worse than an error. UI CalendarWriteTarget.tsx — three states, one sentence each, and the ungranted state is a disabled select inside a focusable tooltip trigger plus the same words visibly (hover and focus both miss on a phone), with a "Grant permission" link and no price, because this is a consent boundary and not an entitlement

  • Per-calendar records for a connected account (2026-08-13) — built, no live OAuth round-trip. A calendar_connections row is one per ACCOUNT; until now every read went to Google's primary, so anything on "Work" or a shared calendar was invisible. calendar_calendars (lib/db/schema/calendar-calendars.ts, DDL lib/db/ddl/calendar-calendars.ts) now holds one row per calendar, populated by the new optional CalendarProvider.listCalendars via lib/repo/calendar/enumerate.ts (three phases DB → network → DB, never throws, requires the read capability so a free/busy connection never spends quota on a guaranteed 403). Two columns are the USER's and the provider must never overwrite them: selected (show/hide) and user_color (a palette key overriding the provider hex) — upsertProviderCalendars updates only provider-owned fields, and calendars that vanish from the provider are left in place rather than dropped with the user's settings. Reads visit every selected calendar in one loop over the shared request-scoped connection; one calendar 403-ing no longer condemns the whole connection (needs_reconnect only when every target fails). Settings UI components/app/settings/CalendarConnectionsSetting/{CalendarList,CalendarRow,CalendarColorPicker}.tsx, optimistic toggles. Microsoft does not implement listCalendars, so an Outlook connection has no rows, stays primary-only, and gets no list button — stated in the UI rather than shown as a control that can only no-op

  • Dhaga's own calendar (2026-08-13) — built, no browser pass. local_calendar_events (lib/db/schema/calendar.ts, DDL lib/db/ddl/calendar.ts, enrolled in EE TENANT_TABLES) gives every account a working calendar with nothing connected and no OAuth — which is the honest self-hosting answer to "do I need a Google app to use the calendar?" (no). Repo lib/repo/calendar/local-events.ts (list is an OVERLAP query, starts_at < to AND ends_at > from, so a conference that began last week still draws on this week's grid); actions lib/actions/calendar-events.ts; dialog components/app/calendar/LocalEventDialog/ with title (required), all-day, start/end, optional location + notes, and an optional colour from the 8-token EVENT_COLORS palette (token persisted, never the hex). All-day ends are stored exclusive (pinned by LocalEventDialog/instants.test.ts) and the client posts ISO instants built in the browser. Not draggable — only follow-ups re-date on a drop; a local event is moved in its dialog. Deliberately not wired to free/busy: Home's calendar-backed Find a time actions and the daily brief still read a connected calendar's busy blocks only, so a local event does not make Dhaga think the user is booked; if that is ever wanted it is a new read, not a bug. Known gap: lib/actions/calendar-events.ts revalidates /app, /app/calendar and /app/tasks — the last two are now redirect stubs, so the real /app/plan path is never revalidated

  • Hide an irrelevant connected-calendar event from Home (2026-08-15) — built, no live OAuth round-trip. Home's Moments for today answers "who should I talk to today", and a subscribed team calendar answers something else entirely: on one real account four of the five slots were colleagues' leave and a public holiday, so a shared calendar could crowd out every actual moment. Hiding is the user's veto on a row they did not create and cannot delete. hidden_calendar_events (lib/db/ddl/calendar-hidden-events.ts, lib/db/schema/calendar-hidden-events.ts, repo lib/repo/calendar/hidden-events/, enrolled in EE TENANT_TABLES with an isolation spec) stores one row per hidden event: a connection FK (ON DELETE CASCADE, so disconnecting an account takes its hidings with it) and event_key. The key is the PROVIDER's identity, never ours — byte-for-byte ExternalCalendarEvent.id, i.e. connection + calendar + provider event id — because Dhaga persists no row for a connected-calendar event: /app/plan reads them live and Home reads a settings-row snapshot that is rewritten wholesale every five minutes, so any id we minted (a snapshot position, the render-time event-{index}-{start}, a hash of title+times) would be re-created on the next sync and the hidden event would rise from the dead. It is stored OPAQUE and only ever compared for equality; the connection is resolved server-side by matching the key against the user's own connection rows, which doubles as the authorization check, so a client never supplies a connection id. SnapshotCalendarEvent gained a key field so Home can tell one event from another (a snapshot written before this carries none and offers no hide control until it refreshes, ≤5 min). Filtering happens in buildCalendarMoments before Home's index-based cap, which is what makes hiding backfill rather than blank: hide three and the next three move up. Only the TITLED list is filtered — externalEvents still suppresses the free/busy interval underneath, or hiding "All-hands" would swap it for "Connected calendar · Busy" in the same slot — and busy is untouched, so dayLoad/findOpenSlots still know the user is in that meeting. Reversible and visible, deliberately not another follow_ups.dismissed: the toast carries Undo, and the event stays on /app/plan drawn hollow/dotted in its calendar's own colour (calendar-theme/states.css) and labelled "Hidden" in words, where its details dialog offers Show on Home. Controls: an eye-off button on the moment row (44px until lg) and the toggle in ExternalEventDialog; both share lib/hooks/useHiddenCalendarEvent.ts so the recovery story cannot drift between them. One extra round trip on Home, and only for a connected account — a single-column single-table SELECT awaited sequentially on the connection the render already holds, never folded into loadDashboardData's Promise.all. Scope: one row hides one OCCURRENCE, not a series — both providers expand recurrences before we see them (Google reads with singleEvents=true, Microsoft reads /me/calendarView), so a provider event id names an instance and there is no series id on the normalized CalendarEvent to key on; hiding every Monday standup at once needs that id first, and is additive here (a scope column defaulting to occurrence). Covered by DashboardSection/hidden-moments.test.ts (backfill, un-hide, no "Busy" ghost, availability untouched, and a re-sync that re-reads the same events in a different order), calendar/__tests__/hidden-events.test.ts, and a new case in DashboardSection/calendar.test.ts pinning the skipped query. Pending: no live provider round-trip; free/busy-only connections cannot be hidden at all (a merged interval carries no provider identity, so the control is correctly absent); nothing prunes rows for events since deleted at the provider

  • A connected-calendar event opens its FULL detail (2026-08-18) — built, not browser-verified, and it carries a deliberate reversal of a documented privacy decision. ExternalEventDialog/ (now a directory) adds the event's description, a real Join control, an open where it lives link, and every guest — including people who are not in the graph, each with a one-click Add that opens a PRE-FILLED dialog rather than saving silently (new contact = the shared ContactForm on createContactAction; existing contact = the shared EntityCombobox plus addGuestToContactAction → augmentContact, which APPENDS and never overwrites, so pressing it twice is a no-op). The dialog stays read-only towards the provider: no Save, no Delete, nothing written back to Google or Outlook — a guest becoming a contact is a write to Dhaga's graph, which is the point. The data did not exist and had to be added to the contract. CalendarEvent (packages/core/src/calendar/types/models.ts) gained description, joinUrl and webLink, all required-and-nullable so a provider cannot silently omit one. Google maps description / hangoutLink → first video conferenceData entry point (never a dial-in) / htmlLink; Microsoft maps body / onlineMeeting.joinUrl → legacy onlineMeetingUrl / webLink, with Prefer: outlook.body-content-type="text" added to graphGet so Graph sends plain text. Both masks were widened by exactly those fields — conferenceData(entryPoints(entryPointType,uri)), not the whole object. packages/core/src/calendar/event-detail.ts is the one place a body is reduced: markup out (never dangerouslySetInnerHTML downstream — a stranger's invite must not be markup in our DOM), entities decoded, length capped at 4 000 chars because the board ships a month of events in one payload. THE REVERSAL, stated plainly. ExternalEventDialog.tsx and lib/repo/calendar/events/types.ts both carried long comments explaining that a third party's guest list is their PII and is dropped server-side — not even a "+3 others". The product owner overrode that knowingly, with the trade-off in front of them: a guest list you cannot see is one you cannot act on, and acting on it is the product. Those comments were rewritten, not deleted, so the next reader knows it was a decision. What survived: a guest's name/address still never reaches a log line (mutation() logs the error CLASS only) and still never reaches an LLM, and no attendee becomes a contact without the user submitting a form. ExternalGuest (lib/repo/calendar/attendees/match.ts) is the new shape; matching is unchanged — exact whole-address only, so "not in your graph" still means it. Honest about a missing scope: an event with no description, no join link and no guests renders a line naming BOTH causes ("either it has none, or this account is connected with narrower access"), because the client genuinely cannot tell them apart. A free/busy-only connection is never read for events at all, so it produces no clickable event — the availability-only story belongs on the page, not this dialog. Pending: app/privacy/sections/google.tsx still says an unmatched guest is "never sent to your browser", which is now FALSE and must be rewritten before this ships. No live OAuth round-trip; no notes are shown because no note↔calendar-event relation exists (events.calendar_event_id is still schema-only)

  • An unconfigured provider's connections stop counting as connected (2026-08-16) — the opt-in flip above gated the CONNECT flow and nothing else, so rows already stored kept working: getCalendarProvider(id) resolves by id and never consults isConfigured(). Three production accounts had connected the Demo Calendar while it was opt-out and were still being served its fabricated 09:30 standup — into Home's open-slot and overloaded-day tiles, the daily brief's suggestions, and the "Connect your calendar" setup tick, which is how it surfaced (a user who had never connected Google saw that step ticked green). isUsableConnection (apps/web/src/lib/repo/calendar/access.ts) now gates both read entry points — connectedCalendarRows and hasCalendarConnection, which had additionally counted any row regardless of status, so the checklist could tick off a connection the read path was already skipping. Deliberately not a status write: nothing is mutated, Settings still lists the connection so it can be disconnected, and restoring the configuration restores it. It covers real providers too — pull the Google credentials and those connections go inert rather than presenting as live calendars reporting an empty week. The three production rows were deleted separately with the owner's approval (backup taken; nothing cascaded). Any fixture seeding a demo connection must now set DHAGA_CALENDAR_DEMO=true — calendar-demo-linked-meetings.test.ts did not, and its six failures are what proved the gate bites; covered by calendar-usable-connection.test.ts

  • Setup checklist steps are links (2026-08-16) — every step's label in SetupPanel navigates to that step's destination, not just the step you happen to be up to. Previously a finished step was inert and a step further down could be read but not reached, so the checklist described work it would not take you to. The 44px hit area is bought with an after halo rather than height, because trimming these rows is what stopped an open panel pushing the next section off the first viewport

  • Finished meetings drop off Moments for today (2026-08-17) — the card selects everything overlapping the whole local day, so a 09:00 standup still held one of the five slots at 18:00 and the card described the morning rather than what is next. isMomentOver (DashboardSection/presentation.ts) tests end <= now, never start — the meeting the user is sitting in is the most relevant row there is — and exempts all-day items, which claim no particular hour. It is applied in MomentsTimeline in the SAME expression that already drops hidden rows, i.e. before the index cap, so finished events backfill from later in the day exactly the way hiding does, and "View N more" counts down as the day passes. Filtered on the CLIENT against a live clock, deliberately: the calendar pulse token is content-hashed rather than a timestamp (lib/calendar/pulse.ts), so an open tab whose calendar has not changed never re-renders — a purely server-side cut would freeze at page load, and making the token time-based would force a full RSC refetch on a timer, which is expensive on /app/plan. The new useLiveClock hook (lib/hooks/useLiveClock.ts) is seeded from the server's now so the first client render matches the server's and nothing hydration-mismatches, then ticks every HOME_MOMENTS_TICK_MS (one minute — moment rows are shown to the minute) and again on visibilitychange, because a laptop reopened after lunch has had its interval throttled and must catch up when it is looked at. Covered by four cases in DashboardSection/presentation.test.ts (finished, in-progress, all-day late in the evening, ending exactly now). Pending: no browser pass; buildCalendarMoments's selection window, the pulse token and /app/plan (FullCalendar's own nowIndicator, a different item model) are all untouched

  • Pending confirmations are visible without opening a menu (2026-08-16) — a confirmation GATES a graph write, so a pending one is work stopped, but Home showed nothing and the only badge sat on a row inside the ⋯ dropdown and the You sheet. The count was already read once per /app request in app/app/layout.tsx; countPendingConfirmations is now wrapped in React cache() so Home's strip and the layout's badge dedupe to ONE query — DashboardSection/load.ts and its three-connection ceiling are untouched. PendingConfirmationsStrip renders in both Home layout branches; badges added to the two collapsed nav triggers. The orphaned HomeDashboard.tsx/ConfirmationsPreview.tsx tree was deliberately left unrevived — see docs/UNPLUMBED_FEATURES.md

  • Home actually renders "Due for a check-in" (2026-08-20) — the whole check-in half of Home was on disk and unreachable: HomeDashboard.tsx has zero importers and nothing else mounted the list, so a contact who was due a reach-out showed up in Moments and on /app/plan and nowhere on the Home rail that was supposed to carry it. New components/app/home/HomeRail.tsx composes SetupPanel → NextDaysPanel → DueCheckInsList as one mount point rendered at every width and imported by DashboardSection/index.tsx, so the two layout branches cannot drift into showing different tiles. HomeDashboard.tsx and the rest of that orphaned tile tree stay deliberately unrevived — a decision recorded in the internal build checklist and audited in docs/UNPLUMBED_FEATURES.md; this plumbs the check-ins half only, not the tree. Typecheck/lint/unit clean; no browser pass recorded

  • "Find a time" on a due check-in, on BOTH Home and /app/plan (2026-08-20) — a check-in row could say someone was due and then offer nothing to do about it. components/app/home/DueCheckInActions.tsx wraps the existing MomentActions so the row reuses the actions Moments already had rather than growing a second set, and ReachedOutButton.tsx was deleted (verified gone) because MomentActions already carries "Reached out" — two buttons doing one job is how they drift apart. /app/plan computes its open slots with zero extra reads, through a new shared lib/calendar/open-slots.ts that both surfaces call, so a time offered on Home and a time offered on the plan cannot disagree. Supporting pure helper components/app/home/slots-for-due.ts (+ slots-for-due.test.ts). Typecheck/lint/unit clean; no browser pass, and no run against a live connected calendar

  • FIXED — first login showed suggested people where the calendar should have been (2026-08-20) — on a brand-new connection the free/busy snapshot has not been written yet, and the pulse catch-up budget (3 polls × 2s = 6s) was shorter than a cold refresh takes: open a tenant scope → maybe refresh an OAuth token → listBusy → readExternalEvents → a second scope to store the result. The catch-up gave up before the snapshot landed and the correction was a full 60s away, so the first Home a new user ever saw was the fallback. The catch-up now BACKS OFF — catchUpDelayMs(attempt) returns 2/4/8/16s (lib/calendar/catch-up.ts), covering 30s in four polls instead of 6s in three, and it is a pure function so the schedule is unit-testable without a clock. Deliberately not "poll faster": every stale poll schedules another provider refresh, so widening the window must not multiply the doomed calls a permanently broken grant keeps paying for. The cost is stated rather than hidden — a refresh landing between 2s and 4s is now noticed at 6s instead of 4s, two seconds later in the band that already worked, in exchange for up to 54 seconds earlier in the band that did not. The first-login repro has not been re-run against a live connection, and there is no browser pass

  • FIXED — "your week is clear" for a calendar we never actually read (2026-08-20) — getFreeBusy skips a calendar it cannot reach, so a TOTAL provider failure returned an empty array indistinguishable from a genuinely clear week, and refreshFreeBusySnapshot stored that emptiness as FRESH; Home then told the user their week was clear until the snapshot aged out. There is now readFreeBusy() returning { busy, attempted, failed } (lib/repo/calendar/free-busy.ts, exported from lib/repo/calendar/index.ts), and the refresh writes nothing when every connected calendar failed — a stale-but-true snapshot is better than a fresh lie. A PARTIAL failure still writes, because some real busy blocks beat none. Typecheck/lint/unit clean; no browser pass, and no run against a real provider outage

  • FIXED — "can't add an email to an existing contact" (2026-08-20) — Base UI's combobox echoes the chosen label back through onInputValueChange, and three panels cleared their selection UNCONDITIONALLY in that handler, so picking a contact instantly wiped the selection that had just been made and left the submit button permanently disabled. Affected: the calendar guest dialog's "Existing contact" panel (components/app/calendar/ExternalEventDialog/AddGuestDialog/ExistingContactPanel.tsx), "Add to event" (which also broke Create event), and the bulk "Change relationship" company picker. WarmPathPanel had already solved it locally, which is exactly why it kept recurring elsewhere — the echo comparison now lives once, in components/app/EntityCombobox/selection-echo.ts. Typecheck/lint/unit clean; no browser pass recorded

15. Graph power (v1.3)

  • Warm-path finding (BFS over edges/companies/events — no AI cost)
  • Second-degree suggestions ("Nearby in your network" — ideas.md #1, local-only traversal)
  • Relationship timeline view (captures, events, notes, touches on the contact page)
  • Watch app / widgets

16. Ecosystem (v1.4)

  • Salesforce/HubSpot/Notion export-sync
  • Outbound webhooks (contact.created, followup.created → DHAGA_WEBHOOK_URL)
  • Telegram bot: capture + ?questions from chat (ideas.md #6; owner-only, secret-verified)
  • The WhatsApp/Telegram bot is a conversational, tool-using assistant, not a DONE-and-batch pipeline (2026-09-29, branch feat/messaging-conversational-agent, NOT yet merged, no PR) — code-complete and unit/PG-suite tested on the branch; NOT verified end to end, so left unchecked. Built: the bot no longer waits for DONE. A message opens a burst that closes after a rolling quiet window (15 s of silence, reset by each new message; a typed answer to the bot's own question closes after 5 s; a burst never collects for more than 60 s from its first message), with a read receipt and typing indicator while it waits, and one reply per burst (utils/constants/messaging/turn/clock.ts, lib/messaging/turn/). A lone done still works as process now but only shortens the wait to a 4 s grace. The turn is a tool-using agent (lib/ai/chat-agent/, tools in packages/core/src/schemas/chat-tools/) that reads the chat's last 7 days as context (CHAT_HISTORY_DAYS), acts first and reports what it did, asks only when genuinely ambiguous, and never merges or deletes without confirmation; an ambiguous note is asked in chat AND kept as an in-app Inbox card. Jev gates run first where they can reproduce the whole output; otherwise Claude is unchanged. Credits: 1 (chat_turn) per turn that reaches Claude, however many model calls it takes; a turn Jev or code settles is free; the first photo read in a turn is included and each further photo is its own card_scan credit; batch_plan is RETIRED (kept only so old rows keep their label). It replaces the old flow for everyone, with no feature flag; the DHAGA_MESSAGING_IDLE_MINUTES / DHAGA_MESSAGING_MAX_OPEN_ITEMS settings, the process-session batch walk and the batch-plan and list-referent schemas were deleted. /api/jobs/messaging/flush now runs the chat turn. Pending (do not treat as done): the model choice is provisional — the default profile is Sonnet at low effort (sonnet_low); Haiku with escalation to Sonnet (haiku_escalate) is selectable via DHAGA_CHAT_AGENT_PROFILE, and only a replay eval against a labelled golden set may move the default, and that eval has not been run; no run against a real account has happened (the account harness exists, unrun); the per-turn cost (an estimate of a few cents on Sonnet) is unmeasured; no PR is open. Design and rationale: docs/AGENTIC_MESSAGING.md (top banner). The entry below, and the batch/DONE entries elsewhere in this file, describe the flow this replaces and are kept as history. Status 2026-09-30: built and verified locally (phases 0–6 and 9); the clean replay eval keeps DHAGA_CHAT_AGENT_PROFILE at sonnet_low (74% of the owner's labels with 1 critical error, against 54% and 6 for Haiku with escalation; report docs/evals/chat-agent-2026-09-30.md). New since the first push: the assistant can create a relation type when it invents a relation (nephew/niece/uncle/aunt are built-in roles), and before 04:00 local time the date context says "tomorrow" means the coming daytime. The Jev shadow gates stay OFF (132/3/49 eligible bursts, 0% coverage; the rule needs 150, 0 false settles and 25%). Real-account acceptance run (phase 8), Telegram only, 2026-09-30: 8 of the 10 scripts ran against the owner's own account (7 placeholder-safe scripts plus the reminder; the three ambiguity scripts need real-name copies and were skipped); 13 credits and $0.32. The injection-safety, three-card, merge and reminder behaviours came out as designed. Two defects found and fixed (PR #516): a write that timed out and then committed was left recorded as refused, so an undo that had landed read as failed (the receipt is now corrected to done), and a shared card whose phone or email matched a differently named contact was not asked about (the prompt now says to ask; a prompt change checked by a live replay rerun, not a unit test). The cleanup list (25 rows) is built but NOT applied. Not run: WhatsApp (needs a fresh access token) and the three ambiguity scripts. PR open, not yet merged.
  • [SUPERSEDED 2026-09-29 by the conversational-assistant entry above: the DONE / session-batch / batch-planner flow this entry describes has been removed from the code on this branch. Kept as history.] WhatsApp + Telegram inbound capture (fulfils the WhatsApp-capture item; core — lives in apps/web + packages/core, needs no packages/ee): forward a contact card, raw text, or a photo — one message or many — to the bot; items accumulate in a per-sender session until you reply DONE (or it goes idle), then the whole batch is planned in ONE LLM call that reads every message together and returns who the batch is about and which messages to store on each of them; deterministic code applies that plan, stamps a per-message verdict, and replies a per-person summary ("Created Priya Raman and added 1 note to them"). This replaced a positional walk that read each message alone with a carried "current contact" cursor: no call ever saw two messages at once, so a batch of "Priya Raman is the founder of…" + "Create a new contact" filed the first as an ambiguity (setting no cursor) and turned the second into a contact named "Unnamed contact", while the real note sat unreachable in a confirmation the inbox was coded never to render. Planning the batch as a whole removes that class of failure entirely. Every inbound case is answered, never silently dropped: contact cards go through the structured vCard importer (no AI re-parse); photos take the vision path (card scan → contact, non-card photo → transcribePhotoNote read as a note, caption kept as a note; magic-byte sniffing so Telegram's mime-less photos scan; the photo itself is kept as the visual receipt via saveCardImages, hung off the note so deleting the note hard-deletes the image, behind the same per-user shouldStoreCardPhotos switch as a web card scan — a forwarded photo used to leave only its transcription behind, with no way to check the reading against the original); voice notes are refused with a "coming soon" reply gated on hasTranscription(); unknown chats, empty messages, videos/documents/stickers get an immediate reply; unreadable cards/photos, orphan pins and failed media downloads are listed in the batch summary, and one bad item no longer aborts the batch. A genuinely ambiguous note is never argued about in chat — chat can hold only one open question per sender, so a batch naming three ambiguous people could only ever resolve the first and silently duplicated the rest. It becomes a note_subject confirmation carrying the note body, raised with origin: "messaging" and resolved in the app's Inbox; nothing is written to anybody until the user picks, and every ambiguity gets its own row so a batch can raise several. A parked note no longer parks its deadlines with it (2026-08-10): the same batch-plan call now also returns each unclear note's followUps, and applyUnclear (lib/messaging/process-session/apply/unclear.ts) writes them immediately as general tasks — contact_id/company_id NULL, user_id set explicitly through the tasks repo because a contact-less task has no other tenant anchor (an extraction-born follow-up inherits its tenant from the contact it hangs off; this one would belong to nobody and could never be surfaced), and due_hint resolved by the same deterministic resolveDatePhrase the extraction path uses — null when it resolves to nothing, never a guess. No classifier and no second model call: it rides the plan that already runs. Otherwise “need to check with the plumber today” sat inert in a confirmation card while the day it named went past, and a user who never opened the Inbox simply missed it. The ids ride on the payload (generalTaskIds) so answering the question can re-point them instead of stranding a duplicate beside a re-extracted one; an ambiguous phrase (“this weekend”) deliberately does NOT raise the second follow_up_date confirmation the extraction path raises — the note already carries one unanswered question, so the task takes the primary date and stays editable. Ordering is load-bearing: tasks are written BEFORE the confirmation (their ids have no later write) and never fatally — a task failure is logged PII-free and the ambiguity is still parked, because losing the note is the worse half. The batch reply says the reminders exist (generalTasksLine) rather than reporting only the open question. Covered by lib/__tests__/messaging-cases/general-tasks.test.ts. A note is now the sender's own words, never the model's account of them (2026-08-10): the plan used to carry a body the model composed, and on a long message it composed a short one — asked for prose about a person it kept the clauses that were ABOUT that person and dropped the rest, the sender's own thinking, which the plan shape offered nowhere else to put. A reported capture came back at about a fifth of its length under a plain "✅ Added 1 note" reply: the seq was still accounted for, so nothing anywhere said four fifths of it had gone, and the only way to find out was to read the note back. It looked like a summarisation bug and was not one — the model was filtering to fit the schema. So plannedNoteSchema (packages/core/src/schemas/batch-plan/, split from the single file per the 150-line rule) has no body field at all: a note states only WHICH message seqs it is made of, and assembleNoteBody (lib/messaging/process-session/apply/note-body.ts) joins those messages' own text in send order (sorted in code — the model returns seqs in whatever order it reasoned about them). Copying a message into a note is a deterministic transform and belongs in code (Rule 5); the model keeps only the judgement — whose it is, which messages group into one note, what in them was an instruction. Photos and voice notes ride the same one path, since DerivedItem.text already holds what the vision/transcription pass read off them. Three consequences, each pinned by a test: (1) the one thing that can be REMOVED is what the plan quotes into directives, cut by exact first-occurrence match — a miss keeps the whole message and says so in the reply (keptInstructionLine), because matching loosely would eventually cut a sentence the sender actually wrote, and a stray line they can delete beats a paragraph they never learn about; a message that assembles to nothing was only ever an instruction and writes no note, falling through to the person's own verdict (getting that wrong left such a message with no verdict at all — not stamped, not unaccounted, since the plan had mentioned it — so bare is now computed from the notes actually WRITTEN, not the ones planned). (1b — follow-up fix, same day) a message belongs to at most one note full stop, including two notes on the SAME person, which the cross-person guard below did not see. A single forwarded message that both recorded something and asked for something came back as TWO notes on one contact: the whole message, and the whole message minus its closing line, which the planner had quoted into the second note's directives. Both were written from the same source text, so the contact got the same paragraph twice with one copy a sentence shorter, under a reply saying "Added 2 notes" for one message. mergeOverlappingNotes (apply/merge-notes.ts) folds notes sharing a seq into one — transitively, so a chain that only overlaps pairwise still collapses — with seqs unioned and directives intersected, so a line is cut only where every note that claimed the message agreed it was an instruction. Intersected rather than first-wins deliberately: dropping the later note keeps whatever the FIRST one happened to cut, so had the planner emitted the two the other way round that sentence would have been lost for good, and the order the model emits notes in is not a decision about the user's text. (2) one message belongs to at most one person's notes: while the model wrote each body it could give two people different text off one message, but now the note IS the message, so a seq under two people copies the whole thing onto both. A contested seq is pulled from every person before anything is written (apply/contested.ts — from sourceItemSeqs too, or its photo and its verdict would attribute it by another route) and parked as a note_subject confirmation offering the claimants, stamped unclear with reason: "contested" so the capture log tells a planner conflict from a genuine ambiguity. No winner is picked: plan order is not evidence about who the user meant, and a whole message on the wrong contact is the failure they are least likely to catch. (3) the same rule governs unclear notes, which lost noteBody for the same reason — a parked note is the one the user is least able to check, sitting unread until they open the Inbox. Covered by messaging-cases/verbatim-notes.test.ts, note-directives.test.ts, contested-notes.test.ts, lib/__tests__/note-body.test.ts and lib/__tests__/merge-notes.test.ts. (The messaging_pending_questions table was vestigial until 2026-09-04, when the in-chat answer flow revived it in exactly the shape it was designed for — one open question per chat, expiring, carrying only the pending note and the candidates offered; the Inbox card is still raised for every ambiguity, always.) The origin flag is load-bearing: an inline quick-add note_subject is answered on the spot and stays out of the inbox, and hiding the background-raised ones the same way is exactly what stranded a real user's note where no UI could reach it. A capture that names no person is never saved nameless — namedContact (lib/messaging/ingest-text/) falls back to the classifier's subject, then to the organisation itself (clearing company, or the profile shows it employing itself), then to UNNAMED_CONTACT_NAME; a blank-named contact renders as an empty row that no search can surface again, which is how a forwarded society noticeboard used to land. Text that INSTRUCTS the bot ('create a new contact', 'save this under Acme') is caught by an isInstruction flag folded into the existing captureExtractionSchema output — no extra round-trip, since the capture parse already runs on every text item — and is neither stored as a note nor mined for facts when a contact is already open; it used to sit in the timeline and have its imperative read straight back as a phantom follow-up. With nothing open it still establishes the contact it names, so nothing is dropped. Extraction also carries labelled emails/phones end to end (extractedMethodSchema = { value, label }), so 'Society office – 9999900102' and 'Northgas – 9999900103 (Ravi)' keep whose number is whose instead of arriving as an anonymous list. Built: provider-agnostic gateway (packages/core/src/messaging/ — WhatsApp + Telegram MessagingClients behind a registerMessagingProvider registry), webhooks /api/messaging/{whatsapp,telegram}/webhook (Meta GET verify via WHATSAPP_VERIFY_TOKEN; both POST verify via signature/secret and fail closed when unset), per-user token account-linking (Settings → Messaging; self-host single-owner falls back to DHAGA_OWNER_EMAIL), session batching + DONE delimiter, batch planner + deterministic apply, a capture log (Settings → Messaging) that keyset-paginates every batch ever forwarded and shows what each message became — created / attached / unclear / directive / unreadable / unaccounted, persisted on the row at write time because once the resulting contacts are edited there is no way back to what happened; a plan that omits a message stamps it unaccounted and says so in chat rather than dropping it, and a failed batch stays retryable with a PII-free reason and never silently falls back to the old per-message walk, idle auto-flush (the daily-cron floor everywhere via runMessagingFlush, plus an optional ~15-min POST /api/jobs/messaging/flush worker route — kept off vercel.json so Vercel Hobby's one-cron-a-day limit isn't broken, and still off it after the 2026-09-04 Pro upgrade — the second entry added there is /api/jobs/tick, not this route), and the Settings → Messaging UI. Pending/stubbed: server-side voice-note transcription (pluggable TRANSCRIPTION_PROVIDER gateway shipped but no provider yet — voice notes are refused with "Voice notes aren't supported yet — coming soon!", and the refusal lifts by itself once a provider registers); true contact de-dup/merge across a batch (nothing de-dupes a forwarded card against the graph by its own fields — it promotes a "mentioned" stub, or merges into whoever the planner matched it to, and creates otherwise). A matched person is no longer a dead end (2026-08-09): augmentContact (lib/repo/contacts/write/augment/) writes the plan's contact — and a forwarded card's parsed profile — onto them ADD-ONLY: a phone/email/link/address/important-date/custom-field they lack is appended in SQL (c.phones || …, so a concurrent append can't be lost), one they have is left untouched (deduped on the merge core's entryKey, so "+91 98765 43210" and "098765 43210" are one number; custom fields have no merge-core key and fall back to a case-folded label+value pair), and title/company/location are filled only where empty — a stale card can never overwrite what the user curated. A filled job is a real positions row (2026-08-09), not just the denormalised title/company_id the shortcut used to write: the detail page showed a job the edit form's job list — which reads positions — could not show, so the user could neither confirm nor correct a stale card's employment. Written through insertPositionRows and only for a contact holding NO position, which keeps it add-only and keeps the denorm recompute agreeing with the fill; its source_note_id receipt is null by design (augment runs before the batch's notes exist, and the title usually comes off a card that produced no note — a receipt pointing at an unrelated note would be false provenance and would let deleting that note hard-delete the user's job). The apply step used to return the moment it had the contact id and discard every field the planner had extracted, so "+91 90000 00002 / Phone number of Grace Hopper" was read, badged Instruction, answered "✅ Updated Grace Hopper." and never written. The reply now names what actually landed ("Added a phone number to Grace Hopper"), says so plainly when nothing changed, and the log badges those messages attached. Covered by lib/__tests__/contact-augment/ + messaging-cases/augment.test.ts; .vcf-as-document ingestion; multi-contact association from free text beyond the positional pairing; the batch planning prompt is unmeasured — the batch_plan credit price in packages/core/src/metering/credits.ts is an estimate, not one of the 39 measured calls, and must be re-derived against the live API. Payment-gated since 2026-08-07 (hosted only): linking a new chat is an enforcement point for multi_device_sync (generateMessagingLinkTokenAction, lib/actions/messaging.ts) — a link token is the only way a chat becomes a capture channel, so minting one is the honest definition of "connect another channel". Unlinking and inbound processing of an already-linked chat are deliberately NOT gated: taking away someone's ability to disconnect a channel that reads their messages is a privacy problem rather than a monetisation one, and silently dropping messages a user is still forwarding to a number we told them to use loses their data. Self-hosts resolve to self_hosted and are unaffected. Pinned by lib/__tests__/messaging-link-entitlement.test.ts.
  • LinkedIn QR format support — pure matcher packages/core/src/capture/linkedin-qr.ts (unit-tested, apps/web/src/lib/__tests__/linkedin-qr.test.ts); web: QuickAddForm/PhotoCaptureInput.tsx detects via BarcodeDetector and routes to /app/people/new?linkedin= (prefills the existing manual-add form, no auto-create); mobile: camera-capture-view.tsx scans live via expo-camera, LinkedInQrPrompt hands off to the web form via Linking.openURL (mobile has no manual-add screen of its own to route to — see docs/notes, this is the closest honest equivalent). Typecheck/lint/test all pass; manual browser + device click-through still pending
  • Email/calendar interaction sync (Gmail/Outlook OAuth, explicit opt-in) — the one ToS-clean ambient-capture channel (BRD §6.7)

17. Teams (v2.0 — revenue engine)

Read the status line before the boxes. Organizations are the Team plan, and Team is admin-provisioned only — allowUserToCreateOrganization: false, no processor behind org_subscriptions, and a contact-sales pricing card. It is enterprise-on-request, on the same footing as a self-hosted deployment. There is no org feature flag: entitlement is ordinary plan features (org_workspace, sso, shared_contacts) resolved through hasFeature. No migration exists and none is planned — the DDL chain is idempotent and replayed on cold boot, nothing is backfilled, and there is no share-to-org-contact conversion, so an organization created before 2026-09-05 opens its workspace and correctly finds it empty.

  • Org workspace (T0) — members, invites, roles, seat counter and an org switcher, on the Better Auth organization plugin with its optional teams sub-feature deliberately off. Created by a Dhaga admin and by nobody else, so there is no self-serve org signup
  • SSO (T1) — OIDC and SAML in one plugin, an owner/admin IdP config screen, an SP metadata endpoint for the customer's IdP admin, and a scheduled re-check of DNS TXT domain proofs, which are enforced rather than advisory (an unverified domain claim is an account-takeover primitive). SCIM does not exist and is not offered; provisioning is JIT via SSO. Never validated against a real identity provider
  • Messaging links are per workspace (2026-10-07) — Settings > Messaging lists only the active workspace's links and link code, so a personal WhatsApp/Telegram no longer reads as connected inside an organization. A code created inside an organization links the chat to that organization (membership is re-checked when the code is used); the same number still cannot be linked in two workspaces, and trying to is refused with a clear reply. The "Saved to …" move control is gone. A code sent from an already-linked chat is answered ("already linked to …, disconnect it first") instead of being filed as a note. Leaving or being removed from an organization disconnects your chats linked to it. Scheduled digests and reminders go only to chats linked in your personal workspace, so an organization chat never receives personal content.
  • Org-owned contacts (T4) — COPY, not move — contributing a contact to an organization is a clone: the personal row is never written, the org gets a new row with a new id, and the two diverge from that moment. Move was cancelled because it meant losing a contact you contributed the day you left the employer. Writing to a contact a colleague contributed is not wired — it is read-only today
  • Org contacts show who added them; import receipts stop nagging — the People table gains an "Added by" column and the contact page an "Added by" line in an organization workspace only (a name, never an email). The Publish prompt is gone from import receipts, and only a note's author is offered Publish or Unshare. Each member gets their own colour (the name is always shown). The People list can be filtered by who added a contact.
  • Bulk "Copy to organization…" on the People list. Select people in a personal workspace, copy them into an organization you belong to in one go. It is the same copy as the single-contact one (personal stays, nothing syncs afterwards, notes and facts arrive unshared), run over the selection in slices of 25 — one server action and one transaction each, with membership re-proved per slice, a progress and result screen, and "Retry the rest" after a failure. The cloners are set-based (a slice costs about 30 database statements whether it carries 1 contact or 20, instead of about 28 per person), and a contact you have already copied into that organization is UPDATED, not duplicated: it is matched exactly by contacts.source_contact_id (a partial-unique provenance column, with copied_from_id on notes, facts, positions, follow-ups, files, scans and signals), the org copy's details are refreshed from the personal contact and only what is new is added (new notes arrive unshared; nothing is deleted and no note edited or published in the organization is touched), and a re-copy reports copied / updated / unchanged. Relationships between any two copied contacts are recreated exactly once whichever batch they arrived in. Proven under real row-level security on an embedded Postgres, not just PGlite. Offered only in a personal workspace to someone in at least one organization. Not built: a mobile surface (the mobile app has no organization concept). Status - built: the copy/update path, the bulk and single dialogs, exact provenance, cross-batch relationships, and a real-row-level-security test on an embedded Postgres (a manual suite: CI does not run it yet). Pending / known: the dialogs were driven in a real browser (dark theme, desktop and 375px phone width, local database) but not in light theme, and a run of more than one batch of 25 was not photographed; statement counts were measured on PGlite only, not timed against the hosted database, and nothing has been run against a hosted Postgres; a note deleted in the organization returns on the next re-copy; a re-copy overwrites edits made in the organization to the contact details; copying a group twice makes two groups; organization contacts copied before this feature are not recognised and a re-copy makes a second one (a reviewed, additive-only backfill script is a possible follow-up, not built or approved); the contact search vector is not refreshed on update.
  • Contact-level sharing controls + "who knows whom" across the team (T2) — RETIRED 2026-09-05, not merely switched off. Org-owned contacts plus per-note visibility do the same job without handing a colleague a frozen copy behind a nightly reconciler. The flag is gone as an environment read and no code path can create or surface a share; the tables and their policies deliberately stay, because a share that cannot be deleted would be worse than one that cannot be created
  • Per-seat billing (T3) — dropped, not deferred to a date. Nothing is purchasable: every org is provisioned by a Dhaga admin and the public Team card is contact-sales. Worth revisiting only once the admin path has proven the org model against real customers
  • Three declared activity-log actions have no writer — org.note.delete, org.fact.delete and org.note.read-private are named but never recorded. The log itself now has an owner-only paginated reader at /app/org/activity, and the entity note list is still not workspace-scoped; company lists and keyword and semantic search were scoped in the 2026-09-06 pass, and events, entities, tags, goals and the Me page hold no organization_id at all, so they are the same in either workspace by design rather than by omission

18. Monetization & launch

  • Landing detail preserved on focused routes (2026-08-02) — the conversion-focused home page links directly to five indexable /use-cases/* pages for sales, founders, investors, recruiters and community builders. /features carries the capability overview; /product-tour carries the detailed workflow, sample desktop workspace, Ask examples, graph sandbox, comparison and FAQ. The routes remain discoverable from navigation, the sitemap and generated llms.txt without restoring the home page's eager graph payload.

  • Marketing header knows you are signed in (2026-08-03) — the focused-landing rework dropped the old <Header isSignedIn> prop (it was fed by await getCurrentUser() in page.tsx, which is exactly what used to make / dynamically rendered), so a signed-in visitor was shown "Sign in". Restoring the server read was not an option: /, /features, /pricing, /product-tour and /use-cases/* are all prerendered and must stay that way. Instead, a first-in-this-repo Next 16 Proxy (apps/web/src/proxy.ts, Node runtime, matched to just those marketing paths plus /login) mirrors better-auth's httpOnly session cookie into a non-secret, JS-readable dhaga_signed_in=1 hint using getSessionCookie — cookie presence only, no DB round-trip. The prerendered HTML is byte-identical for every reader and carries both states; an inline script in <Header> (SESSION_HINT_SCRIPT, lib/auth/session-hint.ts) flips data-signed-in while the browser parses the HTML, so the swap lands before the first paint — no flash, no layout shift, no extra request. HeaderAuthActions re-reads the same cookie in a lazy useState so hydration matches the patched DOM and client-side navigations between the marketing routes stay right; MobileNav reads it when the sheet opens. Signed in: Dashboard → /app replaces "Sign in" + "Request early access" on desktop and in the mobile sheet. The hint is a display hint only and is never authorization — a stale one just means /app bounces to /login, and the /login matcher clears it on the navigation that follows sign-out. Covered by lib/auth/__tests__/session-hint.test.ts: the cookie is matched by whole name (a substring check would let evil_dhaga_signed_in=1 show a stranger a Dashboard button), and the inline script and the hydrating component are asserted to reach the same verdict on every input — if they diverged the button would visibly flip after paint. Verified from the build output: the marketing routes still list as prerendered in prerender-manifest.json, and / returns identical HTML with and without a session cookie.

  • Access-request API (/api/access-requests, packages/ee) wired to real storage; landing form posts to it — replaces the old public waitlist, gated behind DHAGA_HOSTED_MODE; code confirmed backed by a real Drizzle/Postgres table (2026-07-07 audit), no stub — only remaining bar is a human clicking through the flow in a browser

  • Verifying your email address approves the account (2026-09-10, lib/auth/config/signup-hooks/approval.ts) — a customer signed up, landed on /pending and reported the product as broken, so the pending-approval queue stopped being the normal first experience. A password signup is approved in better-auth's afterEmailVerification; an OAuth signup arrives already verified and is approved in signup-hooks/on-user-created.ts, because it never passes through that hook and approving only there would have let password signups walk in while Google signups sat on /pending — the same dead end, applied unevenly. The four older routes (an admin approving the request, a processor-confirmed payment, an admin comp plan, a live invitation from a paying organization) still approve, just earlier and independently; a referral code still does not, because it is a bearer string that works for any address its holder types. "Payment skips the queue" is therefore dead as a selling point and must not appear in copy, email or docs. /pending still exists as a recovery surface: an account whose approval was revoked by a refund or chargeback, an account that predates this rule and was never approved (nothing back-fills — the hook fires on a fresh verification, not an old one), and an account whose approval write failed, since every call on this path swallows its error rather than turning a completed signup into an error page. Two caveats stated plainly. approveUser only writes where approved_at IS NULL, which makes re-approval a no-op but does not protect a revoked account — that is safe only because an address is verified once, so enabling better-auth's changeEmail would hand a charged-back account a way back in. And the anti-abuse posture is weaker in principle: the wall is now "can this signup receive mail at the address it typed", a human decision left the path entirely, and docs/BOT_SIGNUP_RISK.md §7 carries the full accounting rather than a summary — including the fact that every account in the observed 2026-08-31 bot wave was email_verified = false and would not have been approved under this rule either

  • Suspend and restore an account, from the admin panel (2026-09-10, lib/actions/admin/suspension.ts, components/app/admin/AccountAccessCard/, utils/constants/user-suspension.ts) — the reactive lever that replaces the queue removed above. The queue was also where the 2026-08-31 scripted-signup wave was caught by eye (the tell was the name column), and nobody is obliged to open it now; this is the control that answers "we noticed after the fact" rather than one that holds anyone up front. It is not a new mechanism. Suspension sets approved_at back to null — exactly what a refund and a chargeback have always done — and every consequence is the approval gate already reading that column: requireUserIdForPage redirects to /pending, requireUserId refuses every server action. What was missing was an operator control over it, so ApprovalGate gains a third method, revoke; that is a deliberate widening of a contract kept as small as possible, and the reason is that core owns the admin UI while every other revocation caller is EE-side. On the per-user detail page, not as a row button in the users list — it takes away somebody's access to their own data, needs a confirmation and a reason, and has to have room to say that an admin account will not actually be locked out; the list gets a read-only Access badge instead. Audited through the existing mechanism, not a parallel one: assertAdmin writes admin.user.suspend / admin.user.restore to admin_access_log before the change lands and throws if it can't, so an unrecorded lockout cannot happen; restore is logged as loudly as suspension, because an operator quietly undoing their own lockout is exactly what an audit trail is for. The optional reason is a KEY, never prose — a new nullable admin_access_log.reason column holding one of five closed-set values (suspected-automated-signup, abuse-report, payment-dispute, user-request, other), because that table is identifiers-only by design and a free-text box in an operator log is the shortest path in this codebase to a third party's name; an unrecognised value is dropped to null rather than stored, and never refuses the lockout. Four things stated plainly (Rule 12). (1) It is immediate, and that was checked rather than assumed: session.cookieCache is on with a 60s TTL, but it caches the session, and approval is its own indexed read of user.approved_at on every request — so a suspension lands on the account's next request, not up to a minute later. A page already rendered stays on screen until they navigate; nothing they do goes through. (2) Suspending an admin does nothing, deliberately: isUserApproved lets an account through on is_admin or DHAGA_ADMIN_EMAILS regardless of the column, so nobody can lock the instance's own staff out by revoking the wrong row — the card says so above the button, and the fix is to revoke admin first. (3) Self-suspension is refused and leaves no audit row, since admin.user.suspend in the log means an account was locked out. (4) /pending still sells a plan, and a confirmed payment re-approves the account — real money on a real card, a poor fit for scripted signups and a reasonable appeal path, but it is not a wall, and the confirmation dialog says so. Covered by lib/actions/admin/__tests__/suspension.test.ts (audit-before-mutation ordering, non-admin refused, self-suspension refused with no row, an unrecognised reason dropped), packages/ee/src/approval/__tests__/suspend-and-restore.test.ts (PGlite: the round trip locks out and restores, repeated cycles still restore, an admin is not locked out) and one new case in lib/__tests__/approval-guard.test.ts pinning that the guards ask the gate rather than trusting the cached session

  • Free tier caps live; public pricing was re-cut on 2026-08-19 and now shows Pro at $4.99 / ₹499 monthly or $4/mo billed $48 (₹4,799) yearly, and Power at $8.99 / ₹899 monthly or $7.50/mo billed $89.99 (₹8,999) yearly since a Power-only re-cut on 2026-08-24 (was $15 / ₹1,499 and $12/mo billed $144 / ₹14,399) — Pro yearly saves 20% and Power yearly saves 17% (12 × ₹899 = ₹10,788 against ₹8,999), so never write "yearly saves 20% on both tiers"; utils/constants/pricing/prices.ts (PRICES) holds the only copy. Superseded, never quote as current: Pro $10 / ₹899 monthly and $96 / ₹8,499 yearly, Power $30 / ₹2,599 monthly and $288 / ₹24,999 yearly. There is no intro price — built 2026-08, removed 2026-10-06 on the owner's decision; it never sold. The intro price table (INTRO_PRICES), the Razorpay Offer attached at checkout and its four RAZORPAY_OFFER_* env vars, the admin availability toggle and per-account hold, the 30-day step-up notice email, job and final-month banner, and the intro cadences are all deleted; standing prices are the only prices. The subscriptions.intro_offer_id, intro_price_held_at and intro_price_held_by columns and the billing_settings table stay in the schema unused (never deleted; DDL unchanged). Read-only production counts on 2026-10-06 showed zero subscriptions with an intro cadence or intro_offer_id and zero payments rows ever, which is why the four RAZORPAY_PLAN_*_INTRO_* plan ids are no longer recognised (inert in Vercel; they can be removed). The founding plan id and the _LEGACY ids stay recognised for renewals. The three runbook/design docs for it were deleted with it. The USD figures are marketing equivalents only — the Stripe account is not approved, so no Stripe price exists for any of them and Razorpay (INR) is the only live processor; /pricing keeps its INR/USD toggle, defaulted from x-vercel-ip-country via the existing preferredProcessor() signal and remembered per visitor, display only, not a second charging currency (the non-charging currency is labelled an approximate conversion, and the schema.org Offer always advertises INR). Power is LAUNCHED on the public surfaces (2026-08-23) — this line used to say its card was comingSoon: true with a waitlist CTA and that it stayed out of purchasable structured data, so its Razorpay Plans existed "ahead of the sell". That gap is closed: the card carries a buy CTA to /signup with no badge, and /pricing's schema.org offers now publish the standing prices, derived from BILLING_TIERS instead of a hardcoded Pro so a future tier cannot be sold on the page and forgotten in the markup. It was a copy and structured-data change only, and that is the point: there was never a server-side gate — parsePlanSelection already accepted power, BILLING_TIERS was already ["pro","power"], and the in-app plan pickers on /app/settings and /pending had been selling Power the whole time, so the only thing "coming soon" ever gated was the marketing copy. The comingSoon flag is deleted from PricingPlan (types/landing/index.ts) because its one reader — the queue-skip footnote in PricingPlanCard — now applies to every paid card (that footnote is itself obsolete since 2026-09-10: paying no longer skips a queue, so nothing on a pricing card may claim it does); the unrelated comingSoon in the voice/dictation gate is a different field and untouched. The inference-dollar ceiling rule was REVERSED the same day, and this line used to assert the opposite (the gate is §10): the ceiling now follows what a customer is actually paying (introductory ceilings existed from 2026-08-19 until the price was removed 2026-10-06), and the multiplier moved 2.0 → 3.0 so that the cheapest thing we sell still clears the measured heavy-user month. Founding Pro is retired (2026-08-19): packages/ee/src/billing/founding/**, the checkout seat claim, FoundingOffer/getFoundingOffer, FoundingSeatCard, FoundingAside, the admin seat counters and PRO_FOUNDING_PRICE are deleted, and no surface sells it. Three things survive on purpose. (1) The founding_yearly BillingCadence string and its CADENCE_LABEL entry — a Razorpay Plan is immutable, so an existing subscriber's row carries that string forever and the plan status line renders CADENCE_LABEL[current.cadence]; deleting the union member would crash the settings page for exactly those customers. (2) The founding_seats table and its DDL (packages/ee/src/db/tables-ddl/billing.ts) — no destructive migration, it is the only sale record for a live founding subscriber, and it stays in the account-deletion table list. (3) Its reverse-lookup-only entry in RAZORPAY_PLAN_ENV (packages/ee/src/billing/catalog/plan-env/tables.ts) — isSellableCadence refuses it, but without the entry a renewal webhook for that immutable plan id would resolve no tier, grant nothing, and silently drop a paying customer to free. LEGACY_FOUNDING_PRO_YEARLY_INR = 6999 is likewise kept in ai-budget/plan-revenue.ts rather than falling back to standing yearly because founding is the more expensive plan (₹583/mo against standing yearly's ₹400/mo) — a silent fallback would quietly shrink the ceiling of the customers who paid earliest and most. BRD §11 Q6 keeps the founding-price reasoning as history: a retired offer's reasoning is why the next one is shaped the way it is. Plan ids (updated 2026-10-06): the four introductory Razorpay Plans no longer have any role (see above). DISCHARGED 2026-08-20 — this used to be the operationally urgent unchecked item, and it is kept as history rather than deleted. The four standing Plans still had to be re-created at the new standing prices, or the first standing checkout — and, once the term shipped, every later renewal — would have charged the old amount against the new display. All four now exist live at the new prices (Pro ₹499/mo and ₹4,799/yr, Power ₹1,499/mo and ₹14,399/yr) and the four standing environment variables are repointed on Production and Preview, both read back 2026-08-20; the plan ids themselves stay in the internal Razorpay runbook rather than here. Superseded for Power on 2026-08-24: its standing price is now ₹899 / ₹8,999, so the two Power standing environment variables are repointed at the pair of plans created 2026-08-19 as the introductory ones, and the ₹1,499 / ₹14,399 ids join the _LEGACY reverse-lookup set. Pro's two standing plans are unchanged. Razorpay Plans cannot be edited or deleted — a price change is always a new Plan object and a repointed env var, never a dashboard edit — so a repricing is only finished once every configured plan id has been read back and its amount compared against the price table, which is what was done. The four superseded ids are preserved in _LEGACY environment variables (RAZORPAY_LEGACY_PLAN_ENV, packages/ee/src/billing/catalog/plan-env/tables.ts) for renewal reverse-lookup only — the same hazard founding_yearly is kept for: without the entry a renewal webhook carrying an immutable old plan id would resolve no tier, grant nothing, and silently drop a paying customer to free. Also still unchecked: Stripe Checkout + webhook exist only for the earlier annual configuration (§19) — new live Price setup, Power entitlements and a test-mode purchase are all still needed before anything can sell in USD.

  • INR charge disclosed up front to non-India visitors (2026-10-05) — every plan is billed in INR via Razorpay (Stripe is unapproved), but /pricing opens in USD outside India. An always-visible line now says every plan is billed in Indian rupees, US dollar prices are approximate and your bank may add foreign-exchange fees, under the currency toggle and above the comparison table; each plan card states the exact INR amount charged (read from the standing price tables); the in-app plan picker carries the same line for a buyer whose region prefers dollars. India visitors see no extra line. Plan-change captions no longer assert a ₹5 mandate debit for everyone (a small refundable verification charge may apply — ₹5 was only observed on a domestic authorisation), and the checkout-blocked toast no longer points at a Stripe option that cannot sell. Not built: USD/AED plans, tax invoices, a failed-payment email, a renewal reminder.

  • Legal pages reachable from the footer (2026-08-08) — /terms, /refunds and /contact now exist alongside /privacy, all four share components/legal/LegalPage, are in MARKETING_SITEMAP_ROUTES, and are linked from a new Legal column in the landing footer (before this, /privacy was in the sitemap and linked from nowhere, and the other three did not exist). Unchecked deliberately: the entity name, address, phone and support email are TODO placeholders in utils/constants/legal.ts, the copy has not been reviewed by a lawyer, and /refunds promises a 7-day window that nothing enforces in code — a refund is issued by hand in the processor dashboard and still does not revoke access (see docs/FOLLOW_UPS.md). Razorpay live-mode onboarding checks exactly these pages

  • Enterprise self-hosting docs, for a deployment Dhaga provisions on request — Self-hosting (no packages/ee needed)

  • docker compose up — multi-stage Dockerfile (node:22-slim, standalone output via DHAGA_STANDALONE=1, non-root, 494MB) + compose.yml (pgvector/pgvector:pg16 db with healthcheck) verified end-to-end 2026-07-16: Postgres-backed and zero-config PGlite boots both serve with clean first-boot DDL self-heal (the earlier single-stage image was never actually runnable — missing workspace manifest)

  • Roadmap doc — docs/ROADMAP.md written 2026-07-07; still internal-only, and whether any of it becomes an outward-facing roadmap is the owner's call

  • Add licensed/model-released portraits before any future photo-based marketing demo — legacy files in public/avatars/ are intentionally unreferenced; current landing and signed-in surfaces use initials. Do not mark complete until provenance and any required model releases are recorded.

  • Docs/nav/SEO overhaul (2026-07-23): Resources discoverability nav + two-track /docs hub segregation (product vs guides), blog categories + frontmatter, full SEO suite (sitemap/robots/llms.txt/RSS/OG/JSON-LD), CLAUDE.md Rule 14 (subagent orchestration)

  • Indexable /pricing route (2026-07-29) — pricing was only an in-page landing anchor, so there was nothing to rank for pricing queries. apps/web/src/app/pricing/ now renders the three plans (via a PricingPlanCard shared with the landing section, so the two surfaces can't drift), a full plan-comparison table (PLAN_COMPARISON_ROWS), and a pricing-only FAQ (PRICING_FAQ_ITEMS, sharing the AI-pricing/annual/shutdown answers with the landing FAQ by reference). Page-level JSON-LD is SoftwareApplication + offers (derived from PRICING_PLANS) and FAQPage; Organization/WebSite stay in the root layout. Linked from the landing pricing section and the footer, and added to MARKETING_SITEMAP_ROUTES. Landing header/footer anchors are now root-relative (/#faq) so the shared chrome works on a standalone route. tsc + eslint clean; in-browser pass pending

  • Pricing explained in activities, not credits (2026-07-31) — the page sold Pro and Annual as "no monthly cap", which stops being true the moment plan-cap enforcement is switched on, so the copy had to move first. /pricing and the landing pricing section now translate the allowance into things a layman does: Free = 10 credits ≈ 10 card scans or 5 scans + 5 notes; Pro/Annual = 300 ≈ 300 scans, 150 scans + 150 notes, 15 deep-research runs, or ~100 new people a month captured and asked about. Every number is arithmetic over AI_ACTION_CREDITS (scan/quick-add/note/draft = 1, Ask Dhaga/brief = 2, deep research = 20, watchlist scan = 0), mirrored for the marketing bundle in landing/pricing/credits.ts. A new plan-card suits line says who each plan is for; the FAQ gains "What happens when I run out of credits?" and admits plainly that a conference week (150 badges scanned + noted) spends a whole Pro month. Run-out behaviour is described from code, not marketing: notes still save (mapExtractionError blocks, doesn't fail), Ask Dhaga falls back to keyword matches (aiFailureResult), reset is the 1st (monthStart()), no roll-over and no overage. Both landed the same day: PLAN_AI_CREDITS_PER_MONTH.free is 10 (FREE_TIER_AI_CREDITS_PER_MONTH derives from it) and plan-cap enforcement ships on, so the copy describes the shipped behaviour.

  • SEO content cluster — 21 "Guides" posts (11 pillars + 10 "Dhaga vs X" comparisons), past the 15 (10 + 5) this line was written against; personal-CRM / networking, India + founder slant, high-CTR titles/metas) under a new guides blog category, plus reusable on-brand MDX visual components (Figure/NodeGraph/FlowDiagram/Timeline/FeatureMatrix/StatStrip in apps/web/src/components/blog/visuals, registered in mdx-components.tsx) — written 2026-07-24, builds clean (SSG); pending in-browser visual QA

  • SEO gap pass (2026-10-05) — /trust added to the sitemap (a test now fails if any sitemap route is disallowed by robots.txt); the 153 generated /docs/api/** pages get a per-page description from docsDescription instead of the shared site-wide one; FAQPage JSON-LD on the landing FAQ; BreadcrumbList JSON-LD on docs pages; visible byline and author bio on blog posts (a bio shows only for authors listed in BLOG_AUTHOR_BIOS). FAQ and breadcrumb markup now come from one shared builder, lib/schema-ld.ts. Not done / not verifiable from the repo: Search Console property and sitemap submission (owner action — no verification tag exists yet), NEXT_PUBLIC_SITE_URL set in Vercel Production, and measured LCP / CLS. Organization sameAs is omitted on purpose until verified profile URLs exist.

19. SaaS platform — accounts, multi-tenancy, billing, admin (Dhaga Cloud)

Everything in this section is typecheck/lint/build/vitest-clean (including a build with packages/ee and its exclusive route folders physically removed, confirming the core doesn't need it — as of 2026-07-07 this is enforced on every push by the verify-without-ee CI job, not just a one-off manual check) but not yet manually click-tested in a browser or pushed — the checklist bar for [x] needs both, so these stay unchecked until that happens. A 2026-07-07 code audit confirmed every item below is backed by real, substantive implementation (RLS policies, Stripe integration matching the "4 event types" claim exactly, real notFound() admin gating) — none of this is a stub, the only gap is the manual click-through + push.

  • Real accounts (better-auth email/password) replacing the single shared DHAGA_PASSWORD; /login + /signup — flagged 2026-07-07: this is the same code/checkbox as §2's "Auth: real accounts", not a distinct multi-tenant implementation (DHAGA_PASSWORD doesn't exist anywhere in code, only in this checklist); consider deleting one of the two entries once confirmed there's no separate hosted-mode acceptance bar
  • Signup terms gate stops disabling the way in (2026-08-13) — built, not browser-verified. On hosted mode (DHAGA_HOSTED_MODE === "true") /signup shows a terms checkbox; the social buttons and the submit button used to be disabled until it was ticked, which is the bug — a dead button explains nothing, and "Google" looking broken is indistinguishable from OAuth being down. They now stay enabled and in the tab order, and the click is what triggers the explanation: SocialButtons.signInWith returns on blocked before authClient.signIn.social, and useSignupForm.handleSubmit returns on termsBlocked before any network call, so no OAuth redirect and no signUp.email ever starts without consent. nudgeToTerms() scrolls the checkbox into view, focuses it, and renders TERMS_GATE_NUDGE in a role="status" region referenced by both buttons' aria-describedby, with a two-pulse amber highlight (static ring under prefers-reduced-motion). Self-hosts see no checkbox and no gate. The gate is a UI guard only — beforeUserCreate stamps terms_accepted_at/terms_version on every hosted-mode user regardless of what the client sent, so the form's job is making sure the terms were seen, and the hook's job is writing down that they were; there is no server-side rejection of an unticked signup. Stale comment left behind: TermsAcceptance.tsx still says "The submit button is disabled until this is ticked"
  • Per-user personal access tokens (better-auth apiKey plugin) replacing DHAGA_API_TOKEN, settings UI to create/revoke — flagged 2026-07-07: duplicate of §12's "Per-user API keys", same apiKey plugin/settings UI, not SaaS-specific
  • Multi-tenant data isolation via Postgres Row-Level Security, entirely in packages/ee — zero query-logic changes in apps/web/src/lib/repo/*
  • EE boundary: packages/ee is Dhaga Cloud only — a self-hosted enterprise deployment runs fully without it (see Self-hosting)
  • Early access: public request form → admin approve/reject → gated signup (DHAGA_HOSTED_MODE only; inert and 404s otherwise)
  • Cloudflare Turnstile on account-creating and mail-sending endpoints (2026-09-02, lib/auth/config/plugins.ts, app/api/access-requests/; analysis in the bot-signup risk analysis) — built, not verified against a live wave, and absent entirely unless TURNSTILE_SECRET is set, which is deliberate: mounting the plugin without a secret throws MISSING_SECRET_KEY inside its onRequest hook and would 500 every protected endpoint, so self-hosters and previews get no captcha rather than a broken one. Scripted signups had been filling the access queue; the same addresses also signed up for a sibling product, and both are linked from the same company site, so the seed list is that site — something crawled it, followed the product links and enumerated each signup form. This is spam, not an attack — checked against production logs and against the app's own gating: no intrusion, no data leak; the real costs are sender reputation and review fatigue. better-auth's captcha plugin is widened from its 3-endpoint default to 9, because the default covers only /sign-up/email, /sign-in/email and /request-password-reset while four other endpoints create accounts outright. /api/access-requests needs its own direct siteverify call: the plugin is an onRequest hook on /api/auth and structurally cannot see our own routes, and that route is unauthenticated and sends two emails per accepted call. A captcha guards one door of several — it does not stop a bot that already holds a session, and it is not a substitute for the admin approval gate. Follow-up (#318) escaped the submitted address in the owner notification mail: not exploitable today, since both the intake route's EMAIL_RE and better-auth's own check exclude < and >, but it is defence in depth on a message that goes to the owner. A local .env carrying TURNSTILE_SECRET silently breaks every Playwright run — see the testing guide
  • Admin panel (/app/admin): dashboard, access requests, users, subscriptions — 404s for non-admins
  • First-admin bootstrap via DHAGA_ADMIN_EMAILS (see SELF_HOSTING.md)
  • Stripe billing: Checkout (Pro/Power, monthly or yearly), billing portal, webhook (4 event types), AI-cap bypass for paid users. Admin-comping a user to Free (setSubscriptionForUser, packages/ee/src/admin/subscription-admin/set-subscription.ts) cancels a real Stripe subscription first — outside the advisory-lock transaction, before deleting the row — so a downgrade can't leave Stripe still billing a subscription our DB has forgotten about; the admin-granted:<userId> comp sentinel (null stripeSubscriptionId) skips the cancel call
  • Plan changes for existing subscribers — upgrade, downgrade and cadence switch on both processors (packages/ee/src/billing/plan-change/), never a first-purchase checkout. Checkout is refused outright for an account that already has a live subscription (assertNoExistingSubscription, billing/checkout.ts) — a second charging one would bill the same card twice and neither processor deduplicates for us. Tier dominates cadence when both move (classifyPlanChange): power/yearly → pro/monthly is a downgrade, pro/yearly → power/monthly an upgrade. The two processors then diverge completely, and changePlan returns a discriminated union rather than a void because of it. STRIPE modifies the subscription in place — upgrades immediately with proration_behavior: "create_prorations", downgrades booked for the renewal boundary (Subscription Schedule, two phases, end_behavior: "release") because an immediate one makes Stripe credit the unused difference, a liability against revenue already recognised. RAZORPAY CANNOT MOVE A MANDATE TO ANOTHER PLAN AT ALL — confirmed 2026-08-20 by testing on human-authorised subscriptions on both Indian rails, for a tier change and a cadence change alike, with no timing option permitted. That defect is FIXED, by a redesign rather than a retry (2026-08-21, plan-change/switch/, razorpay/change-checkout.ts, razorpay/book-cancel.ts, razorpay/confirm/pending.ts, razorpay/webhook/pending.ts): a change mints a SECOND Razorpay subscription starting at the current one's live renewal date → the customer authorises its mandate in checkout → only that authorisation books the old subscription to end at the boundary → the new one becomes the account's subscription of record on its first settled charge. Measured facts the design rests on: a future start date is accepted and works on UPI AutoPay, the mandate registers at the new plan's amount, and no invoice is raised until that start date, but authorising takes a small refundable registration debit (₹5 observed on Indian cards; the app's wording asserts no number — RAZORPAY_MANDATE_VERIFICATION_NOTE) — so "nothing is charged today" is FALSE and appears on no surface. An upgrade is NO LONGER granted early (2026-10-06): with RAZORPAY_UPGRADE_TOPUP off (the default) an upgrade is scheduled for the renewal boundary exactly like a downgrade — the change is minted with pending_topup_amount_minor = 0, so nothing is comped, the tier flips when the new subscription is promoted at the boundary, and the copy says so; with the flag on the tier is granted once the top-up is paid. The free-until-first-charge derivation described next survives only for changes minted BEFORE this (NULL top-up), which keep what they were promised. An upgrade only is granted free until that first charge, derived on every read from the row whose entitlement it rides on (switch/comp.ts, gated on tier rank + the authorisation stamp + not-lapsed) and never stored; getPlanSummary().plan reports it and a new comped field says until when, and lib/ai/metering/dollar-cap.ts sizes the inference ceiling off the new cadence so a just-upgraded customer isn't held to the cheaper plan they are leaving. No undo after approval: revertScheduledChange cancels an unapproved pending subscription but refuses once approved (the old mandate is already ending and cannot be resumed), and cancelPlan cancels both subscriptions, pending one first, so a customer told "your plan ends on the 3rd" is not debited for the new plan on the 3rd. An account now holding two subscription objects forced two more rules: every webhook routes on the entity id and skips one belonging to neither (switch/route.ts), because the subscription row is keyed on user alone and would otherwise be repointed at a stranger's; and a failed first charge is an ordinary lapse (switch/fallback.ts), because by then the old mandate is gone and only an entitlement outcome is still available. STATUS, HONESTLY: built and unit-tested; NOBODY HAS RUN IT AGAINST A LIVE RAZORPAY SUBSCRIPTION END TO END. No real authorisation, hand-over, discard, cancel-with-a-change-in-flight or first-charge failure has ever happened on a live mandate — the two-subscription overlap in particular exists only in tests. The pure decision layer is unit-tested (plan-change-decision.test.ts, plan-change-guards.test.ts — founding-plan-change.test.ts went with the retired founding offer (§18)) and reconcilePlanState plus both describe*Plan helpers are tested against STUBBED processor SDKs (reconcile-status-repair.test.ts), but no test is a live round-trip; the Stripe deferred-downgrade schedule has likewise never touched the live Stripe API. Unchecked on the evidence, not just on this section's click-through bar. There is no introductory step-up any more (removed 2026-10-06 along with the intro price). The refused PATCH had exactly two callers — that sweep and the old in-place plan change — and both are gone, so it is no longer made anywhere
  • Prorated upgrade top-up on Razorpay (flag RAZORPAY_UPGRADE_TOPUP, default OFF; packages/ee/src/billing/plan-change/topup/, /api/razorpay/topup, /api/razorpay/topup/verify) — closes the leak in the upgrade comp above: a Pro-yearly customer held Power at Pro's price for up to eleven months because the second subscription only charges at its start_at. With the flag on, an upgrade collects a one-off Razorpay Order for the prorated tier difference (integer paise from Razorpay's own plan amounts, whole days counted up, pure and tested in topup-prorate.test.ts) after the mandate is approved, and the higher tier is granted only once that payment is confirmed — re-read from Razorpay, idempotent across the browser callback, the order.paid webhook and the settings-page reconcile, with the amount shown required to equal the amount charged (a mismatch is a 409 and nothing is created). The ledger records it as payments.kind = plan_topup; a full refund or dispute of the top-up withdraws the upgrade and cancels nothing — the plan, the approved change and the sign-in are untouched (only a full refund or dispute of the subscription payment ends the plan; refund-topup.integration.test.ts pins both). STATUS, HONESTLY: unit- and PGlite-tested against a faked Razorpay; NEVER RUN against the Razorpay sandbox and not browser-verified, which is why the flag is OFF — with it off an upgrade takes effect at the renewal boundary and nothing is granted early (the free early-Power comp was REMOVED 2026-10-06; it returns as a paid grant only when the flag is turned on). What must pass in the sandbox before it is turned on is in docs/RAZORPAY_UPGRADE_TOPUP.md. Known gaps: two Razorpay sheets in a row (mandate, then payment); cancelling after paying ends the paid tier at once and is not auto-refunded; an introductory subscriber is quoted the standing tier gap
  • Cancel at the renewal boundary on both processors, behind a confirmation dialog (Razorpay customers previously had no cancel at all). Stripe is reversible from the same screen — Keep my plan (resumeStripePlan); Razorpay has no resume API, so the dialog says so and restarting there means a new subscription. A booked change is separately reversible with Undo scheduled change (revertScheduledChange) — but on Razorpay only until the customer approves the new mandate, after which it is refused rather than leaving them with nothing after the boundary, and the control is hidden. Cancelling with a change in flight cancels both subscriptions and the dialog says so. Not exercised against either live processor
  • Scheduled changes are visible and reversible before they land — the subscriptions row denormalises cadence, scheduled_plan, scheduled_cadence, scheduled_change_at and synced_at (all nullable, all added idempotently), so settings can render "Power until 3 Mar, then Pro" and offer the undo without a processor round-trip. getPlanSummary is consequently DB-only, and that is a contract — entitlement-no-processor-call.test.ts mocks both describe*Plan helpers and getStripe() to throw and asserts five read paths never reach them. The one deliberate live read is reconcilePlanState, called best-effort by the settings page only. It also write-backs the status, which is what stops the plan-lapse notice being a dead end: a dropped terminal webhook leaves the row stored active, so effectiveStatus correctly withdraws the entitlement and emails "purchase to continue" while activeSubscriptionRef — which reads the column raw, deliberately, because it is the double-billing guard — still calls it live and assertNoExistingSubscription refuses the checkout we just asked for. Opening billing settings asks the processor and repairs the row; both processors resolve their status through the SAME map their webhook uses (STRIPE_STATUS_TO_STORED, RAZORPAY_STATUS_TO_STORED), never a second one. Three exclusions, unit-tested in reconcile-status-repair.test.ts: a processor outage writes nothing (an outage must not be able to downgrade anyone), an unrecognised status is omitted rather than blanked, and an admin_granted row is never restatused — a comp over a real subscription keeps that subscription's id, so it does reach the call, and taking the processor's canceled there would revoke a grant no processor knows about. No background job reconciles drift: between webhooks the stored target can lag (Stripe's customer.subscription.updated carries only a schedule id, so it leaves scheduled_* alone), and a half-written schedule renders no pending line rather than a wrong one
  • "Your plan has ended" notice email (2026-08-10) — built, not verified by a live send. Lapse is authoritative at READ time (packages/ee/src/billing/entitlement.ts: isLapsed / effectiveStatus, 72h grace) and nothing flips a column, which is right for entitlement and silent for the user — a comp expires, a card stops renewing, and the paid features simply stop being there with no message. This is that message. lib/jobs/plan-lapse/ (index.ts the sweep, sweep.ts the one row's send-then-mark) + lib/email/plan-lapsed.ts, wired into /api/jobs/daily under a planLapseNotices key. ONE gate call for the whole instance, deliberately not the hostedTenants() + withUserDb fan-out the daily brief needs: subscriptions is an un-RLS'd control-plane table with one row per user, so a per-tenant loop would be N checkouts from the small tenant pool to answer what one indexed scan answers (packages/ee/src/billing/repo/lapse.ts, exposed on the billing gate with core-only no-op defaults — the sweep returns [], the mark no-ops, so a self-host without packages/ee runs the job and finds nobody). Idempotency is the new subscriptions.lapse_notified_for column, NOT the per-local-day record in lib/jobs/last-run.ts — a digest asks "emailed today?", but a lapse must be announced exactly once and never again, and the column re-arms itself when someone resubscribes (a later current_period_end moves past the stored value), so there is no cleanup job and no DAILY_EMAIL_JOB_KEYS entry. Commit only after delivery: markLapseNotified runs strictly after sendEmail resolves ok, so a Resend outage costs a retry tomorrow rather than a permanently swallowed notice; a null currentPeriodEnd marks with now, which permanently silences a boundary-less cancelled row. Two statuses are never notified — past_due (Stripe/Razorpay run their own dunning; our mail would contradict a processor mail still asking for a card) and incomplete (a checkout whose first charge never settled — the plan never started, so it cannot have ended). The email is transactional: plain emailShell, no opt-out footer, because no Settings toggle silences a billing state change (pinned by lib/__tests__/email-opt-out-footer.test.ts). It names the actual tier, says the account is now on Free and that nothing was deleted, quotes the Free allowance from PLAN_AI_CREDITS_PER_MONTH (never "credits reset at renewal" — the credit window is the UTC calendar month), and always carries the buy CTA to /app/settings#billing; a comped/referral grant gets an admin-contact line as well as the button, never instead of it. Batch fuse PLAN_LAPSE_NOTICE_BATCH_LIMIT (200), oldest-lapse-first. Covered by lib/jobs/plan-lapse/index.test.ts and packages/ee/src/billing/__tests__/lapse-sweep-query.test.ts (the exclusions asserted on compiled SQL, no driver). Pending: no live send, no browser click-through, and the endpoint has never been driven end-to-end against a real processor
  • In-app plan visibility + "your plan has ended" notice (2026-08-10) — built, no visual check performed. The email above was the only place a lapse was ever explained, and an email is the one channel a user may never open. Three surfaces, one signal. The signal, lib/entitlements/lapse.ts: summary.plan is the STORED tier (what they HAD) while summary.status is the EFFECTIVE one (whether it still grants anything), so a stored pro/power that is no longer active is exactly "was paid, now free" — and it is the only pairing that separates that user from one who was never paid, since currentPlan() collapses both to free. Nothing in the UI re-derives lapse from a date: effectiveStatus decides it once, server-side, and getPlanSummary now also carries the two row facts the copy needs (periodEndsAt, adminGranted) — current.renewsAt could not serve, because current is null for exactly the row that needs a date most, an expired admin comp with no processor subscription to describe. (1) The profile dropdown names the plan on every screen (ProfileMenu, first row, links to /app/settings#billing); self_hosted renders NOTHING rather than a meaningless "Free" badge, since billing isn't running there and the owner already has everything. Resolved server-side in the /app layout through the request-cached getCachedPlanSummary that currentPlan itself now reads, so the shell's gated controls re-read it free rather than issuing a second subscription lookup. (2) A dismissible banner (components/app/LapsedPlanBanner) at the top of every /app page: names the tier, says the account is on Free, quotes the Free allowance, and always carries the buy CTA — a comped account gets the admin-contact line as well as the button, same rule as the email. Styled as the calm amber PlanGateNotice pill, not text-destructive: a plan boundary is a state, not a failure. Dismissal persists per user in the settings k/v table (lapsed_plan_notice_dismissed) as the LAPSE'S OWN TOKEN, not a boolean — which is what makes it re-arm: buying again and lapsing again moves the boundary, changes the token, and the notice returns without anything having to reset it. The token is re-derived server-side in dismissLapsedPlanNoticeAction, never posted by the browser, or a client could silence itself permanently. (3) The billing settings card is no longer self-contradictory: a lapsed row still stores pro with a current_period_end, so the card used to read "Pro · canceled · renews 3 Jan" — badge, status word and a renewal date already in the past, three fragments each saying something different. The badge now shows what the account is entitled to NOW (Free), one sentence names the tier that ended and frames the date as when it ENDED, and cancel/resume is hidden (nothing left to cancel). One deliberate divergence from the email: past_due/unpaid DO get the in-app notice, worded "is paused — we couldn't take payment", where the email skips them to avoid contradicting a processor's dunning mail; in-app there is no competing message and the user is watching the features not work. incomplete is excluded on both, for the same reason — the plan never started, so it cannot have ended. Covered by lib/entitlements/lapse.test.ts (12 cases: never-paid, self-host, still-active, never-started, lapsed, payment-failed, expired comp, dismissal, and the two re-arm paths). Known gap, NOT introduced here: for a lapse the processor never confirmed (a dropped customer.subscription.deleted), the stored status is still active, so activeSubscriptionRef returns a ref — PlanPicker renders "Switch to…" change actions and createCheckoutUrl would refuse a fresh checkout. Both paths are wrong for that user and the fix belongs in the EE checkout/change rules, not the UI. Pending: no rendered screenshot at 375px, light or dark
  • Payments ledger — a payments table (packages/ee/src/billing/payments/) recording every confirmed charge, refund, partial refund, dispute and failure from either processor: minor-unit integers, the processor's own occurred_at, and a UNIQUE processor_payment_id the upsert dedupes on, with a setWhere guard so a redelivered capture can never un-refund a row. Written by the Stripe (charge.succeeded / charge.failed) and Razorpay webhooks plus the browser-confirm path; back-filled once from the old subscriptions.razorpay_payment_id scalar behind a marker row. It exists so a refund or chargeback resolves back to an account without depending on Razorpay copying subscription notes onto charge payments — which is not documented behaviour and remains only a fallback. This is also what makes approval honest: access is granted only by a payment the processor has confirmed (never at checkout intent, never by the browser redirect), and not ended early by a cancellation — the term was paid for. A full refund or a dispute ends the PLAN, not the account (next entry). Unit-tested (payments-ddl.test.ts, upsert-idempotency.test.ts, razorpay-webhook-approval.test.ts, webhook-ledger.test.ts); the Stripe webhook dispatch itself has no test and nothing here has run against a live processor
  • A refund or dispute ends the plan, not the account (2026-10-05, Razorpay, packages/ee/src/billing/razorpay/webhook-reversal.ts, payments/repo.ts applyPaymentReversal) — a refund or chargeback used to clear approved_at, sending the customer to /pending with no sign-in and no data export (against "export always works"), while their Razorpay subscription stayed live and the next renewal charged them again (and re-approved them). Now a full refund (refund.created/refund.processed, or part refunds whose running amount_refunded covers the charge) or a dispute (payment.dispute.created/lost) cancels the subscription at Razorpay immediately (cancelSubscription(id, false) — cancel first, then record; an authorised pending plan change is cancelled too), stores the row as canceled so every entitlement read is the free tier, and never touches approved_at. A part refund is goodwill and cancels nothing (only the ledger moves); the small refundable mandate-registration debit has no ledger row and still touches nothing; a refund of an OLD subscription's charge does not downgrade the current plan. A processor cancel failure is logged (ids only) and throws, so the webhook answers non-2xx and Razorpay redelivers; redelivery is idempotent, and a stale part refund can no longer weaken a refunded/disputed ledger row. /refunds copy and the BRD billing section say the same. A refund of a charge that PREDATES the ledger (found only through the row's stored payment id) is tied to its subscription through Razorpay first (payment -> invoice -> subscription, razorpay/legacy-target.ts), so a refund of an OLD payment after a plan change can no longer cancel the new subscription; if that cannot be proven nothing is cancelled, and a failed lookup is retried by redelivery (2026-10-06, untested against live Razorpay: the invoice subscription_id field is read per the documented entity). Tested end to end against in-memory Postgres with only the processor SDK faked (refund-cancels-subscription.integration.test.ts, refund-outcome.test.ts, razorpay-webhook-approval.test.ts). Webhook replay (2026-10-06): upsertSubscription now refuses a write whose current_end is OLDER than the stored current_period_end for the SAME subscription (repo/write/boundary-rank.ts, atomic under the row lock; a cancellation, an equal boundary, a different subscription and an admin-granted row are exempt), so an old signed subscription.charged cannot pull the paid-until date back. Not built: event-id dedupe (needs a new table, an appended DDL chunk — an owner decision). Not done, stated plainly: Stripe's refund path is unchanged and still revokes approval; a dispute later won does not restore the plan; a renewal already in flight when the refund lands still settles once; and nothing here has run against a live Razorpay — the amount_refunded field is read per the documented payment object, not a captured payload.
  • Admin may comp a plan up, and may lower one only as far as the tier the user actually pays for (lowestAdminSettablePlan, packages/ee/src/admin/subscription-admin/downgrade-rule.ts) — a FLOOR, not a yes/no, because a goodwill bump from a paid Pro to Power has to be reversible back to Pro (what they buy) and no further. A user with a real Stripe or Razorpay subscription id whose status is active or past_due floors at their own plan, refused server-side and re-checked inside the advisory-lock transaction, because otherwise our row and the processor would disagree and the card would keep being charged for access we just revoked. A comp is recorded on the row, not inferred afterwards from the admin-granted:<userId> sentinel: comping an existing row PRESERVES its processor ids and sets status='active', so a plan comped to unblock a stuck pro/incomplete checkout used to read back as a paying customer and lock the admin out of their own grant permanently. subscriptions.admin_granted records that an admin put the plan there and admin_granted_over_plan/admin_granted_over_status record what it was written over — the underlay is the floor, and the processor can't be asked for it because the plan path is deliberately DB-only. Every writer that records a processor's view clears the flag on a paying status (compColumnsForProcessorStatus, applied in upsertSubscription, updateSubscriptionStatusByStripeId and patchSubscriptionForUser), so a settled 3DS charge re-locks the row; a non-paying status leaves the comp alone. Setting free revokes the COMP, not necessarily the subscription: a comp over an incomplete checkout restores that checkout (ids kept, nothing cancelled, row not deleted) so the pending payment can still settle, while a pure comp is deleted and an abandoned processor subscription is still cancelled before deletion. admin_granted backfilled once from the old sentinel behind a marker row; the underlay columns are deliberately not backfilled (null = nothing paid underneath = the behaviour those rows already had). The admin plan selector disables exactly the options the server would refuse and states why — UI convenience, not the enforcement point. Unit-tested (plan-change-guards.test.ts, comp-cleared-by-processor.test.ts, admin/subscription-admin/__tests__/); manual click-through pending like the rest of this section
  • Billing UI cleanly absent (not broken) when STRIPE_SECRET_KEY is unset, even in hosted mode
  • Admin AI cost & credit controls (/app/admin/ai-credits, titled "AI cost & credits" since 2026-08-03) — built 2026-07-30, enforcement enabled by default 2026-07-31. Four credit levers, in the order an operator reasons about them: a master switch for plan-cap enforcement (AI_PLAN_CAP_ENFORCEMENT_DEFAULT = true — ships ON, so every user is held to the monthly allowance for their plan and the pricing page's numbers — 300 credits/month on Pro/Annual, 10 on Free — are the behaviour, not a promise. The switch stays, but turning it off is now an escape hatch for a migration or an incident, not a resting state: with it off the allowances are stored and ignored, paid plans fall back to the raw billing entitlement (hasUnlimitedAi) and everyone else falls to the instance default. Promotions and grants keep working either way); a runtime-editable allowance per plan (free/pro/power, defaulting to PLAN_AI_CREDITS_PER_MONTH, and the Free allowance doubles as the instance-wide default, so editing it re-sizes both the free tier and every user no plan governs); an instance-wide promotional month that applies regardless of the switch and self-expires (its window is compared at read time, so no cron and no admin cleanup); and an additive grant ledger for make-goods — the same card pinned to one user on /app/admin/users/[id] (which also shows "+N granted"), and, unscoped on the instance-wide page, a "grant to everyone" card (no free-text user-id field — always broadcasts) plus a searchable, paginated ledger at /app/admin/ai-credits/grants (search matches the recipient's name/email, or the literal word "everyone" for broadcast grants). Precedence, pinned by budget-precedence.test.ts + promotion-and-grants.test.ts + env-seed.test.ts: per-user override → promotion (applies whether or not the switch is on) → plan allowance (only when enforced — it is, by default — and only when a paid plan is in play) → the instance default (instanceDefaultCap(), lib/ai/metering/cap/instance-default.ts: the admin-set Free allowance, else the DHAGA_AI_MONTHLY_CAP seed, else FREE_TIER_AI_CREDITS_PER_MONTH), with active grants added on top (lib/ai/metering/cap/index.ts). DHAGA_AI_MONTHLY_CAP is a seed, not an override: nothing is copied into the DB at boot, env is simply read last, so the moment an admin sets a number — a per-user override, a promotion, a plan allowance, or the Free allowance — the stored number wins and the env var stops mattering, and the admin screen can name where the live number came from. Free users resolve through the instance default rather than the plan ladder, deliberately, so the env var means the same thing on a self-host as on an instance with billing. A grant never edits ai_actions — that table is the only record of what cloud AI actually cost, so "End now" ends a grant without deleting its row and recorded usage always stays intact. Storage is two new instance-wide core tables (ai_budget_settings, ai_credit_grants — lib/db/ddl/ai-budget.ts), deliberately not in the tenant-scoped settings table or in EE's TENANT_TABLES: ai_credit_grants gets a bespoke RLS policy in packages/ee (user_id IS NULL OR user_id = <tenant>) because the generic policy would hide instance-wide grants from everyone, and ai_budget_settings gets none (operator config). Grant writes live in packages/ee/src/admin/ai-grants/ (the paginated, name/email-joined ledger read is list-page.ts); a core-only self-host keeps both tables, gets no admin UI to write them, and resolves the cap from the DHAGA_AI_MONTHLY_CAP seed / the free-tier constant (now 10, not 0) exactly as before. tsc/lint/vitest clean; manual click-through pending, same bar as the rest of this section
  • Admin subscription and credit displays made truthful (2026-10-05) — the dashboard "Active subscriptions" tile and the subscriptions list counted the stored status = 'active', but nothing flips that column at lapse (PR #213 decides at READ time), so expired comps and unrenewed plans stayed in the number. effectivelyActiveSql / lapsedButStoredActiveSql (packages/ee/src/billing/entitlement.ts, beside isLapsed, built from the same PLAN_LAPSE_GRACE_MS) express the same rule in SQL; the tile now shows truly-active with a paid / admin-granted split and a "lapsed, still stored as active" figure, and the list's status filter reads effective status (new lapsed option; a null current_period_end still counts active). billing/__tests__/entitlement-sql.test.ts runs the SQL against effectiveStatus row for row at every boundary. /app/admin/users/[id] no longer prints "unlimited" for any paid plan: it resolves the TARGET's real ceiling through the credit ladder (ceilingForUser, lib/ai/metering/cap) and labels it (Power is 1,000 per month; "Unlimited" only when truly no ceiling). Admin spend-ceiling reports (aiCeilingContextFor) use the effective plan. /app/admin/ai-credits now warns while a paid plan is set to No cap or enforcement is off, and the server refuses either save without the explicit confirmation tick. A target who is only on a team seat reads as their personal plan on the admin page (the org seat is not resolvable from the admin's session). tsc/lint/vitest clean; manual click-through pending.
  • Admin cost visibility for the dollar ceiling (2026-08-03, same page, above the credit levers — the gate itself is §10) — four cards: Cost this month (total / credited / uncredited dollars from real recorded tokens, plus measured $/credit and all-in $/credit shown next to the ~$0.006 blended ceiling the credit table assumed — drift between the two is the signal to re-derive the credit prices — plus a per-feature table of the uncredited spend, which is the only place the 0-credit sweeps are visible at all); Top 10 accounts by AI cost (cost, credits, the dollar ceiling in force, which rung set it, and utilisation %, so "is 300% the right multiplier?" is answered from data — typical Pro inference is ~$1.35 against the $5.74/mo a ₹499 standing Pro subscription earns); a dollar-ceiling enforcement toggle; and a multiplier + floor form with a live per-plan ceiling table (ceilingForPlanRevenue is shared with the gate so the table cannot drift from what is enforced). Server actions in lib/actions/admin/ai-dollar-cap.ts, components in components/app/admin/ai-budget/cost/. tsc/lint/vitest clean; manual click-through pending, same bar as the rest of this section
  • AI cost by provider — Anthropic, OpenAI and Jev shown separately, instance-wide and per account (2026-09-24, /app/admin/ai-costs, components/app/admin/ai-costs/, lib/ai/cost/by-provider.ts, lib/ai/metering/jev-usage.ts, lib/db/ddl/ai-provider-usage.ts, EE admin/spend) — one tile per provider plus the total for this month / last 30 days / all time, and a sortable, paginated per-account table (Anthropic, OpenAI, Jev, total, actions). Data-model change, additive only: ai_actions gained nullable jev_input_tokens / jev_output_tokens, added bare and only then given DEFAULT 0, so every row written before the change stays NULL ("not separable") and every row after is 0 unless it called Jev. Metering now files a Jev call's tokens in those columns and keeps input_tokens / output_tokens for the labelled model only — including folded actions (recordAiActions) and the nightly sector merge, which carries Jev's share in LLMUsage.jevInputTokens. Each cost component goes to the provider that bills it: the labelled model's tokens, cache and Batch discount and its web searches to that model's provider (claude-* Anthropic, gpt-* and the openai transcription label OpenAI); audio minutes to the transcription provider whatever the label; the Jev columns to Jev at $0.042/M, never Batch-discounted. A label no provider prefix matches is shown as Other, priced at the dearest rate, never guessed into a named provider. The dollar ceiling now prices Jev at Jev's rate (it used to price a Jev miss + Claude fallback's Jev tokens at Claude's) through the same costOfAiActionByProvider, so the gate, the AI-credits cost card and this screen cannot disagree. History: rows from before the change are priced exactly as before and attributed to their label's provider; a Jev-only action is still Jev's, but Jev tokens inside an older mixed action are inside the Claude figure and cannot be separated — the screen counts those actions and says so. Aggregated in SQL (one grouped query on the admin bypass connection), gated by requireAdminForPage("admin.ai-costs"). Tests: provider split per component, metering into the new columns, the dollar-gate read, the DDL's NULL-vs-0 behaviour, EE's grouped query (PGlite), the report and the page guard — each mutated once and seen to fail. Not verified in the real admin panel — it needs hosted mode on a real Postgres, and none is available locally; the components were rendered with synthetic data instead
  • [~] RLS on the account / billing tables — built, both flags OFF, NOT enabled anywhere (2026-10-06, docs/RLS_ACCOUNT_BILLING_ROLLOUT.md). Six tables (subscriptions, payments, referral_codes, founding_seats, referrals, access_requests) get an account_isolation policy + FORCE only when DHAGA_RLS_ACCOUNT_BILLING=true; unset, the EE schema text and its fingerprint are byte-identical to before, so a deploy or a preview build (which shares the production DATABASE_URL) changes nothing in any database. Two flags, deliberately separate (env vars are per deployment, so one flag would let the new deployment apply the policies while old flag-off instances still serve): DHAGA_RLS_ACCOUNT_BILLING_ROUTING makes every accessor use bypassDb() (packages/ee/src/db/bypass-db.ts, a transaction-local app.bypass_rls, allow-list pinned by a test) and is turned on first, harmless without policies; flag off it is the plain pool with zero extra statements (test-recorded). DHAGA_RLS_ACCOUNT_BILLING applies the DDL and implies routing; turned on only after the routing deploy has fully replaced the old one. The AI-metering entitlement read lends the request connection only when it is that user's (requestScopedDbForUser), independent of the flags. Rejected after enumerating accessors: Better Auth tables, org control plane incl. org_subscriptions, share_links, the messaging routing tables, geocode_cache, migration bookkeeping, ai_budget_settings. Proven on PGlite as a non-owner role with FORCE (isolation, fail-closed, the real repo functions returning identical results with and without policies, flag-off fingerprint, rollback, mutation checks). Not verified: any real Postgres / Supabase, pooler concurrency; neither flag has ever been switched on. Rollback is a documented statement set; unsetting the flag does not remove applied policies.

20. Considered features backlog (2026-07 review)

New gaps from a July 2026 competitive review. All unbuilt — considered, not committed. Full rationale, dedup against existing scope, and the considered-and-declined items live in BRD §5.4. Gaps already tracked are not re-added here (relationship-strength score → §14; LinkedIn import + job-change detection + keep-in-touch nudges → §4/§14; WhatsApp capture → §16); an external fundraising-discovery suite and iMessage/SMS capture were declined (BRD §5.4).

  • Personal MCP server — built 2026-08-02 (web). It shipped wider than this line used to promise: read and additive write, not read-only — the entry above said read-only because that was the safe assumption before the tool surface was designed; the honest scope is six read tools plus five additive write tools (the fifth, dhaga_create_contacts, added 2026-10-11), still user-scoped. POST/GET/DELETE /api/mcp (Streamable HTTP, Node runtime, maxDuration 60). Library, not hand-rolled JSON-RPC: mcp-handler v2 + @modelcontextprotocol/server v2 serve the 2026-07-28 MCP spec natively with a stateless fallback for 2025-era clients — MCP is a negotiated protocol (the client discovers the tool list and schemas at connect time), so hand-rolling it would have meant owning transport, session and capability negotiation for no gain (docs/LIBRARIES.md §16). Two credentials, both accepted, because the two client families can't share one (lib/mcp/auth.ts): an OAuth 2.1 bearer — this instance is its own authorization server via better-auth's mcp() plugin (lib/auth/config/plugins.ts), whose endpoints ride the existing auth catch-all (/api/auth/mcp/{authorize,token,register}, the last being Dynamic Client Registration) with RFC 8414 + RFC 9728 discovery served from /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource, which is what lets claude.ai / ChatGPT add Dhaga as a one-click connector behind a normal login + consent screen; and the x-api-key personal access token that already exists (Settings → API keys — the same key the mobile app and /api/follow-ups take) for local/stdio clients and self-hosters, e.g. claude mcp add --transport http dhaga https://<host>/api/mcp --header "x-api-key: <token>". Unauthenticated requests get 401 + the WWW-Authenticate challenge pointing at the protected-resource document; a bearer that fails to resolve is a hard 401 rather than a silent fall-through to the PAT branch, so an expired token can't be masked as a missing one. The OAuth issuer is derived from BETTER_AUTH_URL — set it wrong and clients reject the tokens this instance issued. Eleven tools, split read/write so the blast radius of the write half is obvious in review (lib/mcp/tools/{reads,writes}, writes being a directory since 2026-10-11; input schemas in lib/mcp/schemas.ts, envelope in result.ts): reads are dhaga_search (hybrid keyword + semantic over contacts and note text, returning the matched snippets as receipts, capped at 20), dhaga_list_contacts (name/company/tag/starred filters, paginated, pageSize ≤ 100), dhaga_get_contact (details, job history, facts each carrying its sourceNoteId receipt, ≤ 25 recent notes, open follow-ups), dhaga_list_follow_ups (whole network, soonest-due first), dhaga_find_warm_path (≤ 3 intro paths), dhaga_list_upcoming_dates (default 30 days, max 365, resolved in the user's timezone); writes are dhaga_add_note, dhaga_create_contact (only name required), dhaga_create_contacts (up to 25 people in one call), dhaga_create_follow_up (YYYY-MM-DD due date optional) and dhaga_close_follow_up (done/dismissed). No delete, merge, export or admin tool, deliberately, and nothing that overwrites (dhaga_create_contacts creates many people in one call but is still additive; surface.test.ts still forbids any tool name containing bulk) — a confused or prompt-injected client must not be able to trigger the irreversible cascade (contact → notes → facts → edges → embeddings); dhaga_close_follow_up is a status change, not a deletion, which is why it is the one "closing" verb that survived. There is deliberately no AI/"ask" tool, and that is the cost story: the connecting client is already an LLM, so it gets raw retrieval with receipts and reasons itself — every read costs zero AI credits. The one charge is a note: dhaga_add_note (and dhaga_create_contact when given a note) queues the normal background note_extraction job at 1 credit, exactly like writing a note in the app, and mirrors addNoteAction by checking hasMonthlyAiBudget first — out of credits still saves the note and skips only extraction, and says so in the tool result rather than letting the model assume it worked. Every handler resolves the user from the verified token and runs its reads inside a single withUserDb scope, never a fan-out (the tenant pool caps at 3 — the repeat failure mode in this codebase). The route also enforces a per-user burst guard (enforceRateLimit(userId, "mcp"), 60 req/min — new mcp bucket in utils/constants/ratelimit/features.ts) after auth so the bucket keys to a real user, answering 429 with retry-after, plus (2026-10-11) a per-user in-flight cap on tools/call and per-call DB time budgets (see the note at the end of this item): an autonomous client issues tool calls far faster than a person clicks and retries tools it did not like, so this protects the shared tenant pool, not just this endpoint. DDL: better-auth's oidc-provider tables that the mcp plugin reuses (oauth_application, oauth_access_token, oauth_consent) are appended to the existing auto-applied DDL (lib/db/ddl/oidc.ts, concatenated after AUTH_DDL because all three FK to "user"(id)), so the ddl_history hash gate replays them on next boot — no manual migration step. They are global auth tables, not tenant tables, and are correctly absent from EE's TENANT_TABLES. EE boundary: pure core. Nothing under lib/mcp/, /api/mcp or /.well-known/* imports from packages/ee, so nothing is added to the verify-without-ee removal list in .github/workflows/ci.yml — that list is unchanged — and a self-hosted enterprise deployment gets the byte-identical endpoint (see Self-hosting). Tests: 17 vitest cases across three files. lib/mcp/auth.test.ts pins both credential paths and the rule that a presented-but-invalid bearer token is a hard 401 rather than a silent fall-through to x-api-key — the shape that would otherwise keep a client working after the user revoked its OAuth grant. lib/mcp/tools/surface.test.ts snapshots the exact eleven-tool surface, fails if any delete/forget/merge/bulk/export/admin verb ever reaches it, and asserts the read/write readOnlyHint split that clients gate auto-approval on. lib/mcp/tools/scoping.test.ts pins that each call takes its tenant from its own token (including no bleed between back-to-back calls for different users), that a tool whose token carried no user throws rather than running on whatever scope is ambient, the empty-result copy, and the out-of-credits branch saving the note while skipping only extraction. Verified against a running build (next start): both discovery documents serve correctly — protected-resource naming this instance as its own authorization server, and RFC 8414 metadata advertising /api/auth/mcp/{authorize,token,register} with PKCE S256 — and an unauthenticated POST /api/mcp answers 401 carrying the WWW-Authenticate challenge that names the protected-resource URL, which is the whole discovery loop a connector walks. Pending: no client has been connected end-to-end past discovery — no OAuth authorization round trip, no consent screen, no DCR registration, no claude mcp add run against a real client (better-auth requires a verified email before a session, which blocked minting a PAT over HTTP in the smoke environment); the oidc DDL has not been applied against a real Postgres (the concatenation is verified by reading, and next build does not run DDL); and the 2026-07-28 spec deprecates Dynamic Client Registration in favour of Client ID Metadata Documents (CIMD) — better-auth 1.6 implements DCR but does not advertise CIMD, so CIMD support lands here when better-auth ships it; DCR works with the clients shipping today. Payment-gated since 2026-08-07 (hosted only): /api/mcp is part of multi_device_sync, enforced by mcpPlanGateResponse (lib/mcp/auth.ts) on the resolved user, which is the only point both credentials pass through — gating token minting alone left the OAuth connector route (claude.ai, ChatGPT) completely open, since it negotiates its own bearer token and never touches a PAT. It could not live inside verifyMcpToken: withMcpAuth rewrites everything the verifier returns-as-undefined or throws into the same 401 invalid_token, which tells a client to re-authenticate — a loop that can never succeed when the credential was fine. So it sits one step later, in the route's post-auth wrapper, and answers 403 {"error": "plan_required", "error_description": …}. Rate limiting runs first, so a client cannot turn its own refusals into plan-lookup load. Self-hosts resolve to self_hosted and are unaffected. Pinned by lib/mcp/auth-plan-gate.test.ts (both credential branches refused, both let through when paid, self-host exempt, and the code is not invalid_token). 2026-10-11 pool-starvation fix (built on branch fix/mcp-pool-starvation; unit-tested only, NOT exercised against the real Supabase pooler or a deployed build): a customer bulk-pushed contacts one dhaga_create_contact call at a time on 2026-10-10 and exhausted the ~48 shared session-pooler slots. The root cause is code-confirmed, not log-proven (the 21:02-21:05 UTC errors were not traced to it): connect retries amplified saturation (connectWithRetry stacked on a pool whose connect() also retried a local timeout exceeded that had already waited 10s: up to 9 attempts, ~90s, past Vercel's 60s). Now only the instant Supavisor rejection is retried: up to 5 attempts (100/200/400/800ms backoff + jitter), with the total retry wait capped at 1.5s (DB_CONNECT_RETRY_BUDGET_MS), which is what actually stops it (a fifth attempt would need ~1.7s); no stacking. A query serializer (packages/ee/src/db/query-serializer.ts) makes Promise.all over one scoped client run in issue order (callback-form and Submittable queries are chained too; the serializer wraps a callback so an errored pg.Query, which emits no event, still releases the line), fixing Calling client.query() when the client is already executing a query (not applied to the flag-gated bypass-db.ts). /api/mcp now has a per-user in-flight cap on tools/call (writes 1 in flight, reads 6, counted separately; a JSON-RPC array counts every call; over the cap is an immediate 429 + Retry-After: 2; a disconnect frees the slot once the call settles, with a 25s timed hold only as the fallback; a single JSON-RPC batch over the cap alone gets 400 "send them separately"; request bodies over 1 MB get 413 before parsing; in-memory per instance, so N warm instances allow N times the cap), bounded DB time per call (withUserDb budget: transaction-local statement_timeout/lock_timeout/idle_in_transaction_session_timeout plus a client deadline that destroys the pinned connection mid-statement (a socket destroy, not a protocol cancel; a deadline during acquire just releases, one on COMMIT waits a 3s grace; the client is told it "may not have been saved"); gate queries race an 8s deadline and answer 503; tool errors are retryable and carry no exception text), and the new additive dhaga_create_contacts (one scope, a SAVEPOINT per person, per-entry created/failed/not_attempted, stops STARTING people at 9s, the 20s bulk deadline minus the 8s statement timeout minus the 3s commit grace; input caps per entry: name/title/company/location 200 chars, note 20000, emails/phones/links at most 20 each, email/phone 320, link 2048). That the backend query is gone when the client socket is destroyed behind Supavisor is assumed and UNVERIFIED (check pg_stat_activity on staging after a forced abort), with the server-side statement_timeout as the independent guard. On a core-only install (no packages/ee) the single-connection, atomic, destroy-on-deadline behaviour does not apply: the call runs on the shared global connection and a deadline only returns control. Each dhaga_create_contacts person with a note queues one 1-credit note_extraction; the credit check is made once per batch. A person with several slow statements can still cross the 20s hard deadline and roll back the whole batch; if a whole call errors instead of returning per-entry results, run dhaga_search for those people before resending, because there is NO duplicate check. Tests added: packages/ee/src/db/__tests__/{query-serializer,connect-retry,tenant-abort}.test.ts, lib/db/connect-retry.test.ts, lib/db/request-scope/budget.test.ts, lib/mcp/inflight.test.ts, lib/mcp/tools/bounded-and-bulk.test.ts, app/api/mcp/route.test.ts. Detail: docs/SCALING.md. User guide: apps/web/content/docs/guide/mcp.mdx Update 2026-10-11 (surface grew from 11 to 19 tools after dhaga_create_contacts; the "No delete, merge" and "Ten/Eleven tools" sentences above are superseded; the 11-tool text was written before this merge): the owner reversed the earlier no-delete/no-merge decision after customer feedback, and the guards below replaced it. Added dhaga_update_contact (PATCH: name/title/company/location, addTags/removeTags, customFields upserted by label, removeCustomFields), dhaga_merge_contacts (one target plus 1 to 5 sources, irreversible, destructiveHint), dhaga_delete_contact (HARD delete through the full cascade; confirmName must match the current name; refuses the user's last remaining contact and any organization contact; capped at 50 per hour by a soft, in-memory limiter, so the cap is per server instance rather than global) and the lists tools dhaga_list_lists, dhaga_get_list, dhaga_create_list, dhaga_add_list_item, dhaga_update_list_item (code in lib/mcp/tools/{curation,lists}, schemas split into lib/mcp/schemas/). surface.test.ts now snapshots the 18-tool surface and its destructive-verb check was updated to the new allow-list. Known limits: dhaga_list_contacts returns tags but not custom fields (use dhaga_get_contact); emails, phones and links cannot be edited through dhaga_update_contact; dismissed is not settable on list items; export and admin tools still do not exist. Not verified: mocked unit tests only, nothing has been run against a live MCP client or a real database. Follow-up 2026-10-11 (second PR): dhaga_update_contact now edits emails, phones and links through addEmails/removeEmails, addPhones/removePhones, addLinks/removeLinks (no-op safe, existing labels and notes preserved, removals run before adds, phones match on their last 10 digits, the result reports changes); dhaga_list_contacts rows now carry customFields and bare emails/phones/links from the same single query (opt-in withDetails, the People table is unchanged). Still not possible over MCP: relabelling or annotating a contact method, and editing addresses or important dates. Merging contacts now records sync tombstones for the merged-away contacts (in-app dialog and dhaga_merge_contacts alike): a device record that still matches the survivor by email or phone was already re-linked to it, but a name-only duplicate used to be re-imported as the contact the user had just merged. Mocked and PGlite tests only; still nothing run against a live MCP client or real RLS.
  • Duplicate-contact detection & merge (entity resolution) — per-user, distinct from team cross-user dedup — built 2026-07-27 (web). /app/people/duplicates clusters likely duplicates by shared email, shared phone, or the same whole name, in a collapsible section per reason (strongest first, first one open), paged 10 clusters at a time and 6 members per cluster. Name matching was surname-based until 2026-08-22 and that was a bug: it reused computeNameClusters (lib/suggestions/name-clusters.ts), which drops the given name on purpose because it is the community-tag suggester for import ("all these people are Jains"). Read as a duplicate detector it told the user that all forty of their contacts surnamed "Singh" were probably one person. Duplicate detection now owns fullNameKey (lib/suggestions/full-name-key.ts) — every token normalized and sorted so "Singh, Amit" matches "Amit Singh", honorifics stripped, a single-token name refused outright — and name-clusters.ts is left to the tag suggester it was written for. A pair matching on two signals is emitted once, under the strongest. Each group is a tick list, not an all-or-nothing suggestion: exact email/phone clusters arrive fully selected, a name cluster arrives with nothing selected (two people really can share a name), and Merge selected (n) is disabled below 2 — five look-alikes where only three are the same person merge as three. The Merge flow (also on the People bulk bar) picks a surviving contact, resolves conflicting single-value fields (name/nickname/location), unions every multi-value field (emails, phones, links, addresses, important dates, custom fields, tags, positions, notes, facts, relationship edges) onto the survivor, then hard-deletes the rest — one atomic transaction, no undo (same model as "forget this person"). Shared merge types + computeScalarConflicts live in @dhaga/core; no schema/DDL change. Mobile parity pending
  • AI-suggested connections (no AI, deliberately) — built 2026-09-07 (web). "People you may know" block on /app/people/[id], below Relationships. Deterministic (no LLM): shared employer/school (self-join on positions.company_id, classified by the education predicates) and shared city (contacts.location text match), ranked strongest-first (employer > school > city). The shared-event signal was dropped as unsupported — calendar_event_contacts is a disposable per-window cache, not durable attendance history, so it cannot back a "you met at X" suggestion. Accept writes a real edge through the normal relationship-creation path (predicate knows); Dismiss writes only a veto (connection_dismissals, its own table alongside duplicate_dismissals). An existing edge is never re-suggested; a dismissed pair stays dismissed. See docs/checklist.md for the full build note.
  • "Not a duplicate" on both duplicate finders (2026-08-25) — built, no browser pass recorded. /app/people/duplicates and /app/companies/duplicates could only ever be agreed with: a set Dhaga was wrong about — two people who really do share a whole name, a subsidiary that normalizes to its parent — came back on every visit and there was nothing to say back to it. Each suggested set now carries Not a duplicate beside the merge button, and pressing it takes that set off the list. Nothing is deleted and nothing is edited — the contacts and companies are untouched and stay in People/Companies, in search and in every export; only the SUGGESTION is suppressed, the same shape as marking a row "Not a person". The answer is keyed on the exact set of members, never on the signal that gathered them (the shared name/email/phone, or the normalized company name): add someone who matches that same name later and it is a different set, so Dhaga offers the group again — deliberately, so an answer given six months ago can never silently hide a duplicate that is genuinely new. Reversible and visible, deliberately not another follow_ups.dismissed — the same argument as the hidden-calendar-events entry in §14: the success toast carries Undo, and a collapsed Dismissed section at the bottom of each page lists every suppressed set with Restore. Scope, stated rather than implied: one answer covers one set and nothing more — there is no bulk dismiss, no per-signal mute and no settings toggle, because each of those would suppress sets the user has never seen. duplicate_dismissals (lib/db/ddl/duplicate-dismissals.ts, enrolled in EE TENANT_TABLES with an isolation case) stores the cluster's sorted id set as ONE opaque entity_ids string built only by dismissalKey(), plus the reason (email/phone/name) as context, not key — the user never picks a reason. user_id is nullable because a core-only self-host has no session variable to stamp it with, so the uniqueness index coalesces it to '' rather than letting Postgres' DISTINCT-NULLs turn every repeat dismissal into a second row. Two actions (dismissDuplicateSetAction / restoreDuplicateSetAction), never one boolean toggle, so a stale screen or a double-tapped Undo cannot flip the state to whatever the CLIENT believed. Covered by lib/__tests__/duplicate-dismissals/membership-change.test.ts (a dismissed pair comes back ACTIVE the moment a third contact joins it, and the key is order-independent without mutating the caller's array) plus the EE isolation case. Documented in content/docs/guide/merging-and-bulk-actions.mdx (its own "Not a duplicate" section) and content/docs/guide/companies.mdx. Pending: no browser pass; /docs/guide/people-duplicates.png predates the button and still shows the page without it; mobile parity pending
  • Relationship analytics dashboard — network stats (most-connected, longest-known, city clusters, network growth), own-graph only — built 2026-09-07 (web). /app/analytics computes all four stats from the user's own rows, zero LLM, zero external lookup: most-connected (top 10 by edge count), longest-known (top 10 by contacts.created_at, never joined to notes), city clusters (grouped by the raw free-text location string, no geocoding), and network growth (cumulative contacts by month). Ships as its own lib/repo/analytics module rather than widening Wrapped's aggregate — Wrapped's shape (one scalar row per time window, names hidden for a contact-free share card) genuinely differs from this dashboard's (named top-N lists, no window scoping). Workspace-scoped (§9B): every count, rank and city bucket takes the active workspace via the shared workspaceFilter primitive, resolved once and threaded through, so an organization's numbers can never blend into a member's personal dashboard. Two round trips total, same one-getDb() discipline as Wrapped. Real empty state for a brand-new account; no schema change. Full detail in docs/checklist.md §20.
  • Contact/profile completeness scoring + enrichment nudges (enrichment stays user-triggered) — lib/repo/completeness.ts's scoreCompleteness is a pure, read-time function over fields already on the fetched contacts row (same shape as lib/repo/strength.ts's scoreStrength: no job, no stored column, no backfill, no model call). Weights (utils/constants/app/completeness.ts) make reachability dominate: email + phone alone are 40 of the 100 points, more than the entire context group (addresses + important dates + custom fields = 20) combined, so a contact with no way to reach them scores materially lower than one just missing a birthday. ContactInfoCard surfaces one restrained line — "NN% complete · add a phone number" — that renders only while a gap remains; a fully-populated contact shows nothing. Deliberately deferred, as follow-ups: a completeness column on the people list/table, and a Home nudge tile surfacing the least-complete contacts.
  • Map view of contacts' locations ("who's nearby when I travel") — renders locations already on file, no live tracking — built 2026-07-29 (web). /app/map (Map in the desktop rail and phone bottom bar, or click/Enter on the Home globe) loads one full MapPayload from GET /api/map and draws a clustered MapLibre point layer: selecting a city point frames it at zoom 11 without pulling back from a closer zoom, and opens a Sheet of the contacts at that place, each linking to /app/people/[id]. The initial fit remains capped at zoom 9 and pins remain city-grain — a closer user-controlled camera never becomes an address claim. Locations resolve through the shared geocode_cache; because geocoding is deferred and capped at 1 req/sec by provider ToS, a first-ever load can legitimately return no places and the client polls conditionally (ETag) until pendingCount reaches zero, pausing while hidden and stopping at a ceiling. Coverage stays honest: mapped, still being placed, couldn't be placed, and no location are separate counts. Basemap is OpenFreeMap, not mapcn's CARTO default (licensing — see Libraries §15), with visible OSM attribution. The same-origin worker keeps its COEP: credentialless header even though /app is no longer cross-origin isolated (removed 2026-08-25) — inert now, and the guard against a silent re-break if isolation ever returns. City focus is shared with the synthetic landing preview; browser/preview verification is tracked in the Testing guide. The detail panel stopped hiding the thing it describes (2026-09-05) — both the map's and the graph's panels opened as modal sheets, so a backdrop dimmed and blurred the canvas behind them, which is exactly backwards here: the panel is a caption for something still on screen, the pin you tapped or the node you clicked, and the graph panel is worse because its own switches (Collapse group, Show circle on canvas) redraw a canvas you can no longer see. Both are now modal={false} with an opt-in showOverlay={false} on SheetContent, so you can pan, zoom and hover with the panel open. Separately, the map's selected city doubled as its open panel, so clearing the selection re-fitted every pin and re-framed the whole network — closing the list threw the camera back to the world view at the exact moment the reader wanted to look around the city they had just zoomed into. Payload made O(places) for ~5,000-contact workspaces (2026-09-30, branch fix/map-payload-geocoding, unpushed) — GET /api/map used to select every contact and embed {id, name} for each in its place (0.3–0.6 MB at 5,000 contacts, rescanned on every 6s poll). It now groups by distinct location text in SQL (GROUP BY btrim(location) under the same currentWorkspaceFilter, no OR, transaction-local statement_timeout) and merges those few texts through the ONE JS normalizer — a SQL re-implementation of lower()/\s follows the database locale and could disagree with the geocode cache key. Each place carries only contactCount; the names load on demand from the new authenticated, rate-limited GET /api/map/place?key= (capped at 200 with the true total) when the sheet opens, with loading/empty/error+Retry states. The ETag hashes places, unresolved and missingCount plus only "any pending left?", so a pass that changes nothing visible no longer turns polls into full 200s. IndexedDB key bumped to full.v2. Geocoding: thrown lookups (429/403/timeout/5xx) are still never cached as negatives but are remembered in memory with exponential backoff honouring Retry-After (a 429/403 ends the pass and cools the provider down); the pass is bounded by a 35s wall-clock budget (hard cap 60 places) and flushes every 10 answers. Still per-instance: the 1 req/s limiter and the failure cache live in process memory, so N warm serverless instances can each spend 1 req/s against the public Nominatim — no shared limiter exists in the repo to reuse. The unplaceable list is still returned in full (its count must stay true). Covered by map-payload.test.ts, map-places.test.ts, map-geocode.test.ts and core failure-cache.test.ts; next build and the browser sheet are not verified. Postal-code granularity (2026-09-30, branch feat/map-postal-granularity, unpushed) — a bulk import that wrote only the city (Mumbai) in contacts.location plus a structured address with a PIN per contact collapsed thousands of people into one marker. A contact whose PRIMARY address (the first entry of addresses; there is no primary flag) has a postal code and a city is now placed by <postal> · <city> (400069 · Mumbai; the address city, else the first comma part of location; the country is appended to the key only when the address has one and is never guessed), derived in SQL (lib/repo/map/place-text.ts), grouped as before and keyed by the one JS normalizer (postal codes: whitespace stripped, uppercased, 3-10 letters/digits/hyphen containing a digit, else the contact stays at city level). Everyone else keeps the location-text behaviour unchanged. Markers sit at the postcode area centre and the existing cluster layer merges them when zoomed out. Privacy: the public Nominatim service is sent a structured query of postalcode, city and (if present) country ONLY — never the street, building, name, note or any other field; asserted on the outgoing request in map-postal.test.ts. A postcode the provider cannot find falls back to its city marker (cached negative, counts intact, and the city sheet lists those contacts) rather than dropping them. IndexedDB key bumped to full.v3; poll ceiling raised from 20 to 40 (about 150 postcodes at 1 req/sec take several passes). Covered by map-postal.test.ts and core postal-place.test.ts; next build and the browser are not verified. Offline gazetteer tier (2026-09-30, branch feat/postal-gazetteer) — the public provider's 1 req/s meant ~1,000 distinct postcode places took ~17 minutes to fill. resolvePendingPlaces now tries an OFFLINE lookup first (packages/core/src/geocoding/gazetteer, server-only, deliberately not re-exported from the geocoding barrel): places it resolves are written to geocode_cache with provider gazetteer in batches of 500, cost no request, and are NOT bounded by MAP_GEOCODE_MAX_PER_PASS or the 35s budget, so one pass clears them all; only the leftovers go to Nominatim as before. It also runs when no network provider is configured or the provider is cooling down (it sends nothing anywhere). A gazetteer miss means unknown, never "no such place": nothing is cached for it, and only the provider's definitive null is ever a negative (a thrown lookup still is not). Data is GeoNames (CC BY 4.0; credit added to the map attribution and docs/LIBRARIES.md), bundled as static JSON loaded lazily per country: postal codes for IN, US, GB (outward code), CA (first 3 chars), AU, DE, FR (7.4 MB raw total, IN 1.5 MB) plus cities of 15,000+ people worldwide (2.1 MB), regenerated by node scripts/build-gazetteer.mjs. A postal label resolves to the postcode-area centre only when the label's city agrees with the code (a PIN alone is ambiguous across countries), and a bare city resolves only when it is unambiguous (the biggest same-named place is 4x the runner-up, or the label's country settles it). Not built: Singapore (per-building codes, ~120k rows) and the UAE postal data; towns under 15k people, and postal labels whose city text is not the district or one of the first three place names GeoNames lists for that code, fall through to the provider; Indian PIN centres are GeoNames medians and can sit tens of km off a big city's centre, which is fine at the map's city-grain but not an address claim; no shared DB reference table (see the PR for the trade-off). Covered by core gazetteer.test.ts and map-gazetteer.test.ts; next build and the browser are not verified. Places are prewarmed when locations are WRITTEN (2026-09-30, branch feat/geocode-on-write, stacked on the gazetteer PR) — a 5-10k-contact workspace should open the map with geocode_cache already holding every place, not resolve them lazily on first open. prewarmPlaces (lib/repo/map/prewarm.ts) takes the place texts just written, keys them with the map's own placeIdentity (postal places also warm their city fallback), skips keys already cached, resolves the rest from the OFFLINE gazetteer and inserts them in one ON CONFLICT DO NOTHING statement (provider gazetteer; never overwrites a network answer or a cached negative). No network, no rate-limit exposure; a gazetteer miss writes nothing and is left to the map's provider pass. It runs in after() (server actions/routes) or at the tail of a script, never throws into the write, and logs counts and an error class only. Wired: bulk import (importCsvBatchAction, POST /api/import, the org loader script), bulk Set location, contact create and edit, and contact-sync pulls (postal addresses). Deliberately not wired: card-scan/agent-filing/messaging writes and calendar-guest augments (single low-volume contacts; the map's gazetteer tier places them instantly on first open), org copy (the copy carries the text the source row already had warmed), LinkedIn enrichment, and contact merge. Bulk Set location warms the typed location only; a contact whose primary address has a postal code is placed by that address, warmed when the address was written. Backfill for existing data: apps/web/scripts/backfill-map-places/cli.mjs (dry run by default, --write to insert, insert-only, one workspace at a time, --database-url explicit). Covered by map-prewarm.test.ts (key parity with assemblePlaces, JS/SQL placeTextOf parity, skips cached, miss writes nothing, one statement for N, never throws, no text in logs) and the script --self-test on in-memory PGlite. Not verified: the backfill and prewarm have NOT been run against Supabase or any remote database (the owner runs the dry run first); next build and the browser are not verified; the gazetteer covers only IN/US/GB/CA/AU/DE/FR postcodes and 15k+ cities, so other places still resolve through the provider on first open.Server-Timing (2026-09-30, branch perf/map-open-timing)** — GET /api/map now sends a Server-Timing header (also on 304) with auth, arm-timeout, location-groups, geocode-cache, unresolved and etag durations: phase names and milliseconds only, never a place, workspace or contact (the phase type is a closed set; asserted in map-timing.test.ts). It exists so a slow open of a 5-10k-contact workspace can be read off a real response instead of guessed at. The request is 3 statements when everything is placed (set_config(statement_timeout), the grouped read, the geocode-cache lookup) and a 4th only when something is unplaceable; the timeout arm was deliberately NOT folded into another statement, because a set_config inside the same statement would not bound that statement and could not be proven equivalent. Measured only in-process (PGlite, 10,000 contacts / 1,000 postal places, everything cached): about 90-210 ms per fetchMapView, noisy run to run, with the instrumentation inside that noise. Not measured: real round-trip time to the production pooler (each statement pays that RTT, so ~3 RTT plus auth is the floor here), the browser's time to first pin, or the ~1 req/s geocoding of uncached places, which is the dominant cost on a first open and lives outside this route.Cluster bubbles count contacts, not pins (2026-09-30):** MapLibre's point_count is the number of features in a cluster, and since postal grain one pin is a place, so "34" meant 34 places. MapClusterLayer takes an optional weightProperty and the map passes contacts, so the label, colour and size follow the summed contact count. Not browser-verified. Postal places poisoned by the network geocoder (2026-09-30, branch fix/map-postal-city-centre-poison) — on real data ~116 Mumbai PIN places sat on ONE coordinate (the Mumbai centre) and ~22 on the Navi Mumbai centre, so the map showed one giant cluster and each PIN's sheet listed only its own few contacts. Cause: Nominatim answers a postalcode+city query it cannot match with the CITY itself, and geocode_cache stored that as the PIN's coordinate; the offline gazetteer never got to correct those rows because prewarm and backfill skip cached keys. Stopped for the future: NominatimGeocodingClient now refuses a postal answer that is not positively postcode-grain (type/addresstype postcode, or place_rank above 20) and returns null (a definitive "this postcode is unknown"), which is cached as a negative, so placeIdentity shows those contacts at the CITY pin with counts intact. A network THROW is still never cached, a gazetteer miss is still "unknown", and a plain city query is unchanged. A result with no rank information (an old or non-Nominatim server) is refused for a postal request too: a city pin is the safe outcome. Repair for existing rows: apps/web/scripts/repair-postal-map-places/cli.mjs (--database-url required, dry run unless --write, UPDATE-only, never a delete, --self-test on in-memory PGlite; output is host and counts only). Tell: postal-keyed, provider nominatim, resolved, AND either its coordinate equals (3 decimals, ~110 m) its city-only row's coordinate, or 3+ different PINs of that city share the coordinate; repaired only when the gazetteer places that PIN at a DIFFERENT point (provider becomes gazetteer); the UPDATE is guarded by the old provider and coordinate so a row rewritten meanwhile is skipped and a second run writes 0; a row the gazetteer cannot improve is left alone and counted. Coverage note under the map now says pins are postcode-level where a postcode is on file, city otherwise. Covered by map-repair-postal.test.ts, core postal-place.test.ts and the script's --self-test. Not run against Supabase or any remote database (the owner runs the dry run first); next build and the browser are not verified; the place_rank cut-off has not been checked against live Nominatim responses. Known limit: the bundled GeoNames data itself puts many Mumbai PINs on a few shared points (in a spot check six PINs shared one), so repaired PINs can still cluster in small groups; a real fix needs a finer postcode source, and a poisoned row the gazetteer cannot improve stays on the city centre until then. Finer India PIN points (2026-09-30): the gazetteer's India file now takes each PIN's coordinates from the India Post pincode directory (data.gov.in, Government Open Data License - India, attribution added to MAP_REQUIRED_ATTRIBUTION) instead of GeoNames, keeping GeoNames names and every PIN the directory lacks or fails the sanity checks; measured on 123 Mumbai/Navi Mumbai/Thane-area PINs, distinct points went from 38 to 125 and PINs sharing a point with 3+ others from 85 to 0 (India file 1.52 MB to 1.51 MB); rebuild with scripts/build-gazetteer.mjs --postal IN --india-post-offices FILE (data.gov.in blocks scripted downloads). The earlier "Known limit" about shared Mumbai points is resolved for PINs the directory covers; already-cached pins keep their old point until repaired; the directory was obtained from a GitHub mirror, not data.gov.in itself, and the point accuracy was spot-checked against Nominatim for 12 PINs only, and next build and the browser are not verified. Metro-area match (2026-09-30): a postal label whose city is not among the names recorded for its PIN (contacts write the metro name: Vasai-Virar and Kalyan-Dombivli PINs labelled 'Mumbai', Panvel PINs labelled 'Navi Mumbai') is now accepted when the PIN's coordinate lies within GAZETTEER_METRO_RADIUS_KM = 50 km of that city's centre (Vasai-Virar ~35-45 km, Kalyan ~33 km, Panvel ~26 km; another metro is hundreds of km away and a neighbouring city 120 km+), a city that does not resolve stays 'unknown', and a wrong-region PIN labelled with the metro name is still rejected; foldPlaceName already folded 'navi mumbai.' and 'navi-mumbai', now pinned by tests. NOT fixed: a few core PINs (e.g. 400023, 400036) are absent from the bundled India file, so they still miss until the data is regenerated. repair-postal-map-places now also upgrades cached NEGATIVE postal rows (provider nominatim, resolved=false) the gazetteer now places to resolved gazetteer pins (UPDATE-only, guarded by the old provider/resolved/null coordinate, counted separately in the dry run); verified on PGlite and unit tests only, NOT run against any real database. Map filters (#575; unit-tested at the time, browser-checked in the follow-up below): the Map has the Network tab's Filters — same button, docked panel (lg+) / bottom sheet, removable chips and URL-mirrored state (a filtered link works), built by REUSING the graph's filter components behind a small FilterPanelApi interface (components/app/graph/filters/filter-api.ts, use-filter-state.ts, url-sync.ts) rather than copying them. The map has no per-contact data, so a new GET /api/map/contact-places (columnar {keys, ids, place}, workspace-scoped, built with the same placeIdentity + geocode-cache logic as assemblePlaces so keys equal MapPlace.key; unplaced contacts omitted) joins the graph's GET /api/graph/filters attributes to the pins client-side (use-map-filters.ts, logic/filter-places.ts): pins with no matching contact disappear, contactCount becomes the matching count, "N of M people on the map" shows with a Clear action, zero matches shows an empty state, the camera re-fits on filter changes, and the place sheet lists only matching contacts (a filtered pin is listed by POST /api/map/place — see the follow-up below; the unfiltered GET is unchanged). The panel's option counts and "N of M" are narrowed to contacts that are ON the map (logic/restrict-table.ts). The three graph-derived facets — Connections, Event attended, Relationship — are offered too (see the follow-up below). Filters apply only to contacts that are on the map: the coverage line's "still being placed / couldn't be placed / no location" counts stay whole-network and are labelled so. Map filters follow-up (branch feat/map-filters-followups, unpushed; vitest + tsc + eslint green, and driven in a real browser — Chromium over a disposable local PGlite with synthetic contacts and a pre-seeded geocode cache, at 375px and 1440px, light and dark, headless/software-GL): (1) Graph-derived facets on the Map. The panel loads the graph payload lazily — only when it opens or the URL already carries conn/event/rel (a plain tag filter does not) — through the Network tab's own path (use-map-graph.ts: the same IndexedDB slot, GET /api/graph/full with If-None-Match, buildGraphIndexes; no layout) and adds the columns with the same withGraphColumns call before restricting to mapped contacts (logic/map-filter-table.ts), so the facets mean what they mean on the Network tab. While it loads only those three rows show a skeleton (DeferredFacetRow; Retry if it failed) and every other facet works at once; a filter that selects one waits for the graph. Verified: first paint requests only GET /api/map. (2) No flash of unfiltered pins. A filtered link now renders an "Applying filters…" state until the attributes, the contact-to-place table (and the graph, if a graph facet is selected) have landed (logic/filter-phase.ts); the canvas is not mounted until then (verified: it never coexists with the message while the request is held). If an input fails, the map shows everyone WITH an alert (Retry / Clear filters) rather than silently looking filtered; a graph failure only blocks a filter that needs the graph. (3) Place sheet with more matches than the cap. GET /api/map/place?ids= is removed. A filtered pin is POST /api/map/place with {key, ids} where ids is EVERY match at that pin (at most MAP_PLACE_IDS_MAX = 10,000 UUIDs and MAP_PLACE_BODY_MAX_BYTES = 512 KB, else 413; a bad id is 400; same auth, rate-limit and workspace scoping), and the database returns the first 200 BY NAME with a total counted over ids actually at that place. Verified in the browser: 230 matches at one pin list "Lon Person 000"–"199" in order and "Showing the first 200 of 230". Covered by map-place-ids.test.ts and map-place-route.test.ts (300 matches, other-place and other-workspace ids, oversize and malformed bodies). (4) ?city= and the filter URL. Verified in the browser in both orders, on reload, on city change, on clear and on the Network tab's ?focus=; one clobbering found and fixed: the filters are replaceState-only, so Back landed on an entry carrying STALE filters (a reload then switched them) — watchHistoryNavigation now writes the on-screen filters over it (same page only). Covered by url-sync.test.ts. (5) Browser-found defects fixed: the MapLibre attribution lost our skin to MapLibre's own two-class selector (light grey on white-50% in dark mode, about 1.7:1 — a legal credit); the "N of M · Clear" chip sat on top of the attribution, the zoom control and the phone's bottom bar at 375px (frame shortened below sm, chip lifted and shortened); filter chips and Clear had a 42px hit area. Not verified: a real device, Safari/Firefox, a hosted Postgres/RLS run, and a map with pending geocodes (needs the network provider).
  • Personal-life logging modules (optional, off by default): gift tracking, journal/diary + mood, activity log, debt tracking, pets
  • Mail-merge / bulk personalized outreach + public API + Zapier app — extends the existing outbound webhooks (§16)
  • Two-way native phone address-book sync — extends the current one-way expo-contacts import (§12). Built 2026-07-29 (mobile + server), user-triggered only, from the mobile Sync contacts screen (apps/mobile/src/app/sync.tsx → components/contact-sync/, lib/sync/); the web side is the conflict inbox at /app/sync/conflicts. A three-way merge against a per-link base snapshot (packages/core/src/sync/merge/, mergeSyncedContact) derives ownership per field, because no platform (iOS, Android, Google People, MS Graph) exposes per-field modification times — last-write-wins would clobber "added a phone number" with "renamed the contact". Scalars (name/nickname/title/company) go to whichever side moved off the base; multi-values (emails/phones/links/addresses/importantDates) union by entry key, and a removal is honoured only when the other side left that entry untouched. Conflicts are reported, never silently resolved, and a contested field is never pushed outward; on a genuine both-edited scalar the phone's value wins in Dhaga and the losing Dhaga value is persisted to contact_links.conflicts, then reviewed and restored at /app/sync/conflicts. An entry is carried forward until that value is actually recovered — clearing it because a later sync sees no divergence would destroy the record one run after making it, since by then both sides agree. Restoring deliberately does not rewrite the base snapshot, so Dhaga becomes the side that moved and the next sync pushes the restored value back out. Privacy boundary: only the nine vCard-shaped fields round-trip — notes, AI-derived facts, graph edges and signal state are never written to an address book (it syncs onward to laptops/cars/shared devices), and source === "mentioned" stubs are never pushed out. Link state lives in contact_links (lib/db/ddl/sync.ts + schema/sync.ts; EE TENANT_TABLES adds RLS — external ids collide freely across users); a contact missing from the phone only sets state='unlinked', never deletes a person, note or fact. Server: POST /api/sync/contacts (+ /ack), session-or-x-api-key auth, import rate-limit bucket, whole batch on one scoped connection (db-scope.guard.test.ts); chunked at SYNC_MAX_CONTACTS 1000 with observedExternalIds (cap SYNC_MAX_OBSERVED_IDS 25000) on the last chunk only. iOS writes into a cardDAV/exchange container so iOS itself relays the change to iCloud/Google — the only route to iPhone Contacts (Apple ships no server API) and no Google write scope needed. Android limitation (definitive): Container is iOS-only in expo-contacts and the native insert sets no ACCOUNT_TYPE, so contacts Dhaga creates stay device-local — edits to existing contacts still ride the owning account; stated verbatim to the user (ANDROID_ACCOUNT_NOTICE). Unit + PGlite tests green (merge, chunking, field mapping, containers, deletion/identity/company/push-outward); mobile tests wired into CI. Pending: no runtime/device testing at all — the expo-contacts I/O path (device-target.ts, engine/index.ts, the components) has no test that executes it, and it needs a native build, not OTA; the one-connection guard is a source-text assertion, not a real pooled-Postgres run; an emptied address book now DOES sweep: contacts.min(1) is dropped and a new observedEmpty flag carries the positive claim "enumeration ran and found nothing", honoured only when the batch really is empty — full: true with zero contacts and observedExternalIds: [] are both what a FAILED enumeration looks like, so neither may authorise it on its own (sweep-authorisation.test.ts unchanged and still green); google/microsoft are now implemented server-side (below). Two CodeQL high-severity findings from the first CI run are fixed: the multi-value link key stripped trailing slashes with /\/+$/, which backtracks polynomially on a long run of slashes (the same defect fixed earlier in geocoding/nominatim-client, but reachable from a sync payload here because link values are user-controlled), now a linear endsWith/slice loop; and the name+company dedup key tested for content with replace("|", ""), which strips only the FIRST separator — normalizeForMatch keeps punctuation, so a contact named literally "|" was indexed under a content-free key and every other such contact then matched it, merging two unrelated people (now a split-based check, covered by identity.test.ts). Shipped in PR #135 (merged 2026-07-30). The review queue is now screenshotted (/docs/guide/sync-conflicts.png, seeded by scripts/seed-sync-conflicts.mjs — the page renders only links with a non-empty conflicts array, which a real run reaches only when both sides moved the same field). Still unphotographed: the Settings scope-upgrade button and a Dhaga-calendar event both need a live Google/Microsoft OAuth round-trip, and the mobile sync screen needs a device — none available in this environment. User guide: Syncing with your phone
  • Server-side Google / Outlook contact sync — built 2026-07-31. Closes the Android gap: expo-contacts inserts a RawContact with no ACCOUNT_TYPE, so a contact Dhaga creates on Android never leaves the handset. Connecting the account directly writes through the provider's own API instead and Android syncs it down — the reverse of the iOS relay. Reuses the existing engine rather than duplicating it: lib/repo/contact-sync/run plays the role the mobile client plays, calling the same reconcileContacts + acknowledgeWrites, with the provider bound behind a plain ContactSyncTarget so the merge cannot tell a People API account from an iPhone. Providers are a gateway like LLM/search/messaging: ContactSyncProvider (packages/core/src/sync/provider-types.ts) owns the OAuth flow and mints a token-bound target; google-provider/ (People API) and microsoft-provider/ (Graph) ship built-in, seeded lazily like the calendar registry. Capability is derived from the granted scope, never a column. Grants live in a separate contact_connections table, not calendar_connections: contacts and calendar are independent consents, and sharing a row would let a reconnect on one narrow the other's grant; tokens are AES-256-GCM, and the table is in EE TENANT_TABLES (it holds tokens, so unscoped reads would be account takeover, not just a leak). Phase separation is load-bearing — DB → network → DB → network → DB, so a tenant connection is never held across an HTTP call (the pool-exhaustion failure this codebase has shipped repeatedly). Sync now in Contacts & sync (/app/import#accounts), and since 2026-08-15 a background schedule as well: lib/jobs/contact-sync (runContactSyncSweep) rides the daily aggregator /api/jobs/daily, with a standalone /api/jobs/contact-sync (same CRON_SECRET bearer, fails closed) for a Vercel-Pro or system cron that wants it sub-daily — deliberately not a vercel.json entry of its own, which Hobby's one-cron-a-day limit would break; the quarter-hourly entry added there on 2026-09-04 is /api/jobs/tick, and this route is still unscheduled. Four switches per connection: keep in step (on), sync on its own (on by default, added 2026-08-15), two-way sync (off by default, added 2026-08-14) and add Dhaga-only people (off, and now nested under two-way — creating records in an address book is a bigger step than editing the ones already there). The schedule adds timing, not permission: it calls runContactSync(userId, "scheduled"), and the trigger changes exactly one thing — a narrower connection set (run/eligible.ts: auto_sync_enabled on, and not synced within that trigger's floor). Automatic triggers beyond the cron (2026-09-21): the floor is per-trigger — scheduled 6h (the cron, a backstop for accounts nobody opens), app_open 5 min (the open tab, on open/focus and an interval, components/app/sync/useContactSyncOnOpen), local_edit 30s (a contact saved in Dhaga, scheduled from after() so no connection is held across the provider call — lib/contact-sync/write-out.ts), manual none. Both new triggers respect auto_sync_enabled; only the button ignores it. Hooked at the ACTION layer, not the repo writer, because repo/sync/write.ts routes the sync's own PULL through createContactProfile — a repo-layer hook would make every contact pulled from Google schedule another sync. Runs are serialised per connection by contact_connections.sync_claimed_at, without which two tabs could each offer the same unlinked contact before either acked — a duplicate in the user's Google account. Every other gate is shared, so a scheduled run still only pulls unless two_way_sync is separately on. auto_sync_enabled is an opt-out of the SCHEDULE, never an off switch for the connection: the manual path keeps the broader predicate because pressing the button is itself the asking, and both halves are mutation-verified (__tests__/contact-connections-auto-sync.test.ts, lib/jobs/contact-sync/index.test.ts). Rate limiting is last_synced_at — no new table, and deliberately not lib/jobs/last-run.ts, which answers a once-per-local-day question this isn't; the endpoint's period is therefore not the rate. Self-host runs ONE global pass (no tenant gate → withUserDb doesn't scope, so a per-user loop would re-sync every connection once per account); hosted sweeps tenants sequentially via lib/jobs/tenant-sweep with per-tenant error isolation, and the sweep is deliberately NOT wrapped in a tenant scope because runContactSync opens its own between its network phases. Two-way had to become opt-in because the OAuth grant cannot carry that consent: both providers request a write scope at connect time (asking later would cost a second consent screen), so holding one says only what Dhaga may do — contact_connections.two_way_sync is what says the user asked. Both must hold. The gate lives in applyWrites at the point of writing, not only in the UI, and ReconcileOptions.pushOutward stops a write even being derived. The subtle half is the base snapshot: declining to push while still recording the merged value as the base would make the next run read the untouched remote as a fresh remote edit and adopt it — silently reverting the user's own Dhaga edit, two runs later, with nothing reporting a problem. nextBaseSnapshot() therefore keeps the REMOTE's value for fields it did not send, which is lossless and what lets a later switch-on push the whole accumulated divergence. A read-only grant now pulls instead of erroring; turning two-way on without the scope reports SYNC_NEEDS_WRITE_SCOPE once and prompts reconnect rather than failing per record on a provider 403. Both gates mutation-verified (run/two-way.test.ts, __tests__/sync/pull-only.test.ts). Microsoft is deliberately narrower: Graph gives one url slot, one birthday and three fixed address slots, so links/importantDates/addresses are declared unsupported and excluded from the merge via a new ReconcileOptions.unsupportedFields. That is not cosmetic — mapping them anyway would read back short, and on the second run (base snapshot now recording them as synced) the merge would honour the shortfall as a deletion and destroy the user's data; the guard is mutation-verified in unsupported-fields.test.ts. OAuth state is shared with the calendar signer but namespaced contacts: so a calendar-issued state cannot be replayed against the contacts callback. Reuses GOOGLE_CLIENT_ID/SECRET and MICROSOFT_CLIENT_ID/SECRET — no new env vars, but the OAuth app needs the People API / Contacts.ReadWrite enabled and /api/contact-sync/callback/* registered. Google's contacts scope is sensitive, not restricted (standard verification, no CASA, no fee). 33 core + 30 web sync tests green, tsc clean. Pending: no live OAuth or API round trip anywhere — no Google/Microsoft credentials in this environment, so every HTTP path (list/create/patch, pagination, the 401/412 rejections) is unexercised even by a mock, and the consent flow entirely so; no browser render or screenshot of the Settings card; the background sweep has never run against a live provider or a real cron either — its tests stub at the connections + merge seam, so what is proven is which connections a scheduled run selects and that a failing tenant is isolated, not that a scheduled run completes end to end; incremental sync now ships: Google syncToken and Graph /me/contacts/delta, persisted as an opaque contact_connections.sync_cursor. The sweep is the hazard here — an incremental page is NOT the complete address book, so sending it with full: true would unlink nearly every contact. Guarded three ways: mode is a discriminant ("full" | "incremental") that must be spelled at the site that actually enumerated everything, a named authorisesSweep(page) predicate replaces the old hardcoded full: true, and the cursor is withheld when any write failed (an incremental run never re-observes an unchanged contact, so "will retry" would otherwise be a lie). Both providers' deletion tombstones are dropped from incremental pages — Google metadata.deleted / Graph @removed are bare ids with no fields, and passing one through would hand the merge an all-empty contact which it would write back as the user clearing their own name and phones. 410/resyncRequired drops the cursor and re-runs full. Mutation-verified twice; 429/408/5xx and Google's 403 rateLimitExceeded now retry with bounded exponential backoff in one shared core/src/sync/http.ts (Retry-After beats the exponential guess, 3 attempts max, a stated delay over 30s fails fast rather than hanging a serverless invocation, and the error label drops the query string because an incremental URL embeds the syncToken); mobile has no UI for this (it is a web/server surface by nature). Fail-loud pass (2026-08-15) — three compounding silences closed. (1) A revoked grant was never flagged. usableAccessToken sets needs_reconnect only when a token REFRESH fails, and a refresh is only attempted near expiry — so a user who revoked Dhaga in their Google/Microsoft account settings while the stored access token was still inside its ~1h window got 401s that nothing acted on: the run caught a bare Error into result.error, the row stayed status = 'connected', and the settings card went on showing a dead account as healthy. A new isAuthFailure predicate at the packages/core/src/sync/http seam narrows the thrown SyncHttpError, and run/failure.ts reuses the SAME markNeedsReconnect marker rather than inventing a second state (http.ts became a directory, http/{index,classify}.ts, under the 150-line rule). 401 and only 401: Google answers an exhausted quota (RESOURCE_EXHAUSTED) and a disabled People API (SERVICE_DISABLED) with 403 too, and isRateLimited403 has already consumed the body that told them apart by the time the error exists — flagging needs_reconnect on a rate limit would send a user to the consent screen for nothing, which is a worse failure than the silence. (2) “Sync now” reported success when every connection failed. runContactSync never throws (it catches per connection so one dead account cannot stop the others), the action returned only the summed remaining, and the card only toasts on a throw — so the spinner stopped and nothing was said. runContactSyncAction now returns ContactSyncRunOutcome (remaining + failed + needsReconnect) and the card toasts SYNC_NEEDS_RECONNECT. Counts and a flag cross the boundary, never the per-connection error strings: those can carry a provider payload, and a provider payload can quote the user's address book. (3) Nothing told the user a connection broke. The background sweep now writes a contact_sync_disconnected notification naming the account (repo/notifications/contact-sync-notify.ts, modelled on job-notify.ts — its own short withUserDb scope opened after the run's have closed, every failure swallowed so a notification can never cost a tenant their sync; the bell row links to /app/import#accounts). Once per breakage, and it needed no new column: the sweep only ever selects status = 'connected' rows, so a connection flagged in one run is not a candidate on the next tick and cannot re-notify — the notification is pinned to the CONNECTED → NEEDS_RECONNECT transition for free, and reconnecting re-arms it. usableAccessToken/markNeedsReconnect had zero coverage before this; run/failure.test.ts (401 flips it, 403/429/network do not), __tests__/contact-sync-action.test.ts and two new sweep cases now cover all four. Still pending: no live provider has ever returned a real 401 here — every case is a constructed SyncHttpError, and the notification has never been seen rendered in the bell. User guide: the "Google and Outlook, without a phone" section of Syncing with your phone
  • Mobile calendar parity (M4) + contact-sync product rules — built 2026-07-31. Mobile calendar: expo-calendar on the DEVICE calendar rather than OAuth, mirroring the address-book relay (the OS carries changes to iCloud/Google, so no tokens and no second consent screen). Agenda screen lists real device events beside Dhaga follow-ups; write-out reconciles follow-ups into a dedicated "Dhaga" device calendar and removes events on done/dismissed/undated/deleted. Reuses followUpToCalendarEvent + DHAGA_CALENDAR_NAME from @dhaga/core so phone and web cannot drift. Three traps handled: findDhagaCalendar requires title match AND allowsModifications AND isPrimary !== true (a name match is not permission to write someone's main calendar); iOS/EventKit treats an all-day END as inclusive and Android/CalendarContract as exclusive, so unhandled every follow-up renders as a two-day event on iOS; and a followUpId → deviceEventId link file is the phone's stand-in for calendar_event_links, without which every run duplicates every follow-up. iOS files the new calendar under the default calendar's source so it reaches other devices; Android gets a local account and says so, because CalendarContract offers no way to add a calendar a Google sync adapter will pick up — the same honesty as ANDROID_ACCOUNT_NOTICE. Needed a new GET /api/follow-ups (packages/core/src/api/follow-ups.ts + route): the web calendar renders server-side so nothing published follow-ups over HTTP, and /api/export/json was rejected as the carrier because it embeds every scanned card as base64. It serves OPEN follow-ups only so the payload stays bounded, which means completion is signalled by ABSENCE — clients reconcile against the whole set. Also declared the undeclared vitest and expo-file-system in apps/mobile/package.json (both were resolving via root hoisting only). Product rules (user-stated): deleting on the phone never deletes in Dhaga ✅ already true; adding on the phone creates in Dhaga ✅ already true; deleting in Dhaga never deletes on the phone ✅ true (no target has a delete method) but it silently undid itself — contact_links.contact_id is ON DELETE CASCADE, so the link died with the contact and the next sync re-imported the person. Fixed with a contact_sync_tombstones table written as the FIRST statement of cascadeForget (after the delete there is nothing left to read external ids from), consulted only on the create path after the dedup ladder misses. Tombstones store no identity — provider, external id, timestamp only, because keeping the name would make "forget this person" a lie — and never expire; they clear when the pair re-links. mergeContacts deliberately does not tombstone (a merged-away duplicate should be re-adopted by the survivor). Fourth rule, creating in Dhaga creates on the phone: offered, never assumed — every address book defaults OFF, the phone's own included (decided 2026-07-31; it shipped ON for one change and was reversed). Writing into an address book is the user's call to make, so the choice is surfaced rather than made for them: the onboarding tour points from the legacy Contact accounts card to Contacts & sync (SETTINGS_TOUR_STEPS, third of the settings leg's seven since that leg became the tour's opening, overlay left blocking) while the hub names the switch and says it is off — the same convention every email preference already follows. offerUnlinkedCreates also skips source === "import", so turning it on cannot replay every CSV a user ever imported. On a first link only, a real value on one side against a blank on the other is treated as silence rather than disagreement (core/src/sync/merge/blank.ts): the value is taken with no conflict, while two different real values still conflict and still keep Dhaga's copy. That is a volume property, not a nicety — a 700-contact seed whose methods are mostly unlabeled produced 1400 conflict rows before it and 0 after (conflicts are counted per contested field per contact). Mobile, web-sync and core suites are green and the tombstone and sweep guards are mutation-verified; raw test counts are deliberately not quoted here, because the ones this entry used to carry were stale two changes later and a number that rots reads as a fact. Pending: no device or simulator anywhere — every expo-calendar call (device.ts, write.ts) and the link-store file I/O is untested, so a first run should confirm the iOS all-day adjustment renders as one day, createCalendar succeeds under a real iCloud source, and Android's local-account insert is accepted; no live Google/Microsoft credentials, so Graph's /me/contacts/delta (a DIFFERENT endpoint from the one that shipped) has never been exercised against a tenant and has no fallback if one rejects it; the first sync after a user turns the switch on offers their whole back-catalogue of authored contacts, not only the ones created afterwards — there is no "created after I enabled this" marker and inventing one was judged speculative. That is now a caveat rather than an open question: the product call was made on 2026-07-31 (default OFF everywhere, discoverability via the onboarding tour), and the guide states the back-catalogue consequence at the point where someone would enable it. Two further consequences are answered as of 2026-07-31 (same change as the §11 vCard entry). (a) The SYNC_MAX_CREATES 500/run ceiling is no longer silent: SyncPushResponse.remaining reports what a run held back and both platforms render it ("437 contacts still to add — sync again to continue"). It counts only contacts a later run could actually add — eligibility is evaluated before the cap rather than by breaking mid-loop, so the number cannot be padded with stubs, imports or already-linked people and then never drain. Web showed nothing at all before this: the server action returned void and dropped the run's results. (b) A large back-catalogue no longer has to go 500 at a time — ?scope=authored on the vCard export gives a seed file the user imports into the address book in one bulk operation, and the first sync matches and links those contacts (email → phone → name+company, repo/sync/dedup.ts) instead of re-creating them one write at a time. Documented in the "Copying a big back-catalogue across" section of the user guide; packages/core/src/sync/types.ts is 194 lines and deliberately NOT split into a directory because apps/mobile deep-imports that exact path and Metro's directory-index resolution cannot be proven here; calendar-theme.css (187 lines) was split and proven byte-identical through the real PostCSS pipeline but is held back — another session is editing that file, and a modify/delete conflict there would silently drop their work; eslint-config-expo is missing entirely so npx expo lint cannot run at all (pre-existing); typedoc content/docs/api/** remains stale and wants its own PR
  • Voice dictation self-correction ("schedule at 3, no make it 4") — semantic LLM edits + a deterministic number/time pass; deferred pending a dedicated GPU host for the correction model. The in-browser correction LLM is CPU-bound (~48 s/edit) on consumer GPUs, too slow to be "then and there". Prototyped in the browser-voice R&D (llm-experiments repo, feat/voice-browser-jarvis); real-time in-browser STT (Moonshine, WebGPU) is proven there and is the intended whisper-base replacement that ports independently — only the correction layer is parked. (BRD §5.4)
  • Sector enrichment (two tiers, one filter column, opt-in and credit-charged) — built and merged 2026-09-05 (this line said "not merged" when it was written; the branch landed the same day). Merged is not verified: nothing here has had a browser, mobile or integration pass, and no Tier 2 web lookup has been priced against a real bill. One half is deliberately dark, and as of 2026-09-06 correctly so — SectorAutoEnrichSetting ("suggest a sector for every new contact automatically") was imported by app/app/settings/sections/general/suggestions.tsx and omitted from its returned JSX, so it was invisible rather than greyed and the beta convention was not actually followed. It is now rendered, LAST in that section, below every setting that works: a disabled switch with a Coming soon chip, taking no props and reading nothing, so mounting it adds no query to a page whose tenant pool caps at three connections. It stays disabled because the create path fans across ~30 call sites and an insert-time branch in createContactProfile would silently start spending a user's money on all of them; the setting key and copy are already the final ones, so wiring it later is a one-file swap. The manual loop — the bulk dialog, the sector chip and the unplaced-companies page — does work today. Every contact and every company now carries a sector picked from a closed taxonomy the user can extend (DEFAULT_SECTORS + the per-tenant sectors table, merged by lib/repo/sectors.ts), and the People table has shown and filtered a Sector column since 2026-09-06 — before that the query layer, the option producer and the /api/table-filter-options whitelist all supported sector fully while PeopleTable declared no such column and /app/people decoded no such param, so a user could spend credits filling the column and then not browse by it. The column is declared exactly like Company and Tags (an options list is what makes it a picker rather than a typed box) and sits beside Company, because a sector is a fact about the employer. TABLE_FILTER_SEARCH_COLUMN_BY_ID already mapped the id, so the picker searches server-side past the 200-option first page with no new plumbing. /app/saved decodes ?sector= too — it renders the same PeopleTable and therefore inherits the column, and a decode missing there would have left the chip on screen while the list never narrowed. Multi-value, so two sectors are an OR, and the ampersand inside a taxonomy name (Healthcare & Life Sciences) survives the URL round trip as one value rather than splitting into two. The whole feature is off by default and never runs unasked. Tier 1 is the nightly Anthropic Batch pass (lib/jobs/enrich-sectors/, structurally a copy of classify-people/, wired into /api/jobs/daily before the network-bound tail): a user clicks Suggest sectors (bulk bar or whole-book header button), sector_enrich_requested_at is stamped on eligible contacts and on nothing else — it stamped their companies too until 2026-09-06, which was harmless only while nothing read the company column and became a live bill the moment something did (see the third tier below) — and that night the batch judges ten per model call — the chunk size IS the price, one batch item = one ai_actions row = one sector_enrichment credit (per record-ai-actions.ts:22-27 — "THE ROW COUNT IS THE CONTRACT"), so 500 contacts cost 50 credits, not 500. custom_id is the chunk id c:N, not a contact id, so the chunk→ids map is persisted alongside the batch id under SECTOR_ENRICHMENT_BATCH_KEY; the due predicate refuses sector_by = 'user' rows (hard lock, mirroring personKindBy) and person_kind = 'service' rows, and the pool gate drops rows with no company and no title, plus rows whose only title is generic (Founder, CEO, Consultant, …) with no employer to anchor them — both skipped, never billed. A verdict returning null or confidence: "low" writes nothing; the user lock is re-checked at UPDATE time, not just at submit. Tier 2 is the paid web-search follow-up: when the model flags a contact's employer with unknownCompany: true, sector_lookup_suggested_at is stamped on the company, which shows up at /app/companies/unplaced — the user pays 4 credits per company (never per contact) to run a real web search that writes companies.sector, a one-line companies.blurb, and sector_source = 'web'; the next nightly Tier-1 run then inherits that sector down to every contact at the company for free (lib/jobs/enrich-sectors/inherit.ts, zero LLM calls). Priced-per-company is the whole economics of the feature — a search is about the company, not the person, and 30 contacts at one unknown startup is one search. Tier 2 rides the existing extraction_jobs machinery (new sector_lookup kind beside enrichment) so it inherits ExtractionStatus live progress, the blocked-on-credits state, and retry for free. The two Tier-2 model calls (Sonnet web-search complete + Haiku extract) fold into a single ai_actions row via withAiAction("sector_web_lookup", …) — the invariant the tests pin, because splitting them would silently double-bill. Priced at 4 credits against an estimated ~$0.02 floor (search fee is a flat $0.01 the Batches API does not discount, plus ~6K input tokens of results). Per-contact override in SectorChip (mirrors PersonKindChip: shows "who decided" and locks with sector_by = 'user'). Bell notification sectors_enriched routes to /app/companies/unplaced. The auto-enrichment toggle is on screen, greyed, and does nothing — an ongoing-spend switch needs its own monthly ceiling conversation before it is allowed to work, so it ships as the visible "Coming soon" card the beta convention asks for rather than as a control. Prices and labels in packages/core/src/metering/credits/table.ts (sector_enrichment: 1, sector_web_lookup: 4) and apps/web/src/utils/constants/ai-credits.ts. Schema: new sectors table (in EE TENANT_TABLES with a matching SPECS entry — same call as node_types), plus contacts.sector/.sectorBy/.sectorEnrichRequestedAt/.sectorEnrichedAt and companies.blurb/.sectorSource/.sectorLookupSuggestedAt/.sectorEnrichRequestedAt/.sectorEnrichedAt (append-only DDL chunk SECTORS_DDL, which belongs last in the chain because mid-string inserts force a full replay on live databases). That chunk was written but never wired in, and this line claimed otherwise — which is how the gap survived review. lib/db/ddl/index.ts never imported SECTORS_DDL, so its nine ADD COLUMN statements had never run on any database while the drizzle schema already declared the columns, and every contact save failed with a message-less error. Reproduced on a clean database 2026-09-05 and repaired by PR #357 the same day; the fix is the one missing import, and the lesson is that "last in the chain" is a claim about index.ts, not about the file the chunk lives in — only the import proves it, and a search for the symbol would have shown it had exactly one reference, its own declaration. sector_by and sector_source are nullable with no default, deliberately: a pre-existing hand-typed company sector reads as "you", and a DEFAULT 'user' would have stamped that claim on every empty row. The feature could not be STARTED by any user, and that is now fixed (2026-09-05). dueForSectorEnrichment opened with sector_enrich_requested_at IS NOT NULL, listSectorEnrichmentPool selected on it, and quoteSectorEnrich derived eligibleIds from that pool in all three scope branches — while the only writer of that stamp is the confirm arm, which runs after a non-zero quote. Nothing stamped → empty pool → eligible: 0, credits: 0 → the dialog offered nothing → the confirm stamped nothing → the pool stayed empty, permanently, on every tenant. A priced button quoting work that could never happen. The lesson: one predicate was answering two different questions. "What COULD the user ask for?" (no sector, not sector_by = 'user', not person_kind = 'service', and anchored by an employer or a non-generic title) is what the QUOTE needs and must not mention the stamp; "what DID the user ask for?" is all of that PLUS the stamp PLUS the idempotence clause, and is what the nightly BATCH needs because it is the only path that spends credits. They are now two named predicates (requestableForSectorEnrichment / dueForSectorEnrichment) behind two named pool functions (listRequestableSectorEnrichmentPool / listDueSectorEnrichmentPool), not one function with a mode flag — the same call PR #359 made for the table argument: a caller that can be handed the wrong question is precisely the bug, and two named functions cannot be. The anchor guard is shared in one anchoredIds helper so a row neither pool would bill for is never offered either. Getting it backwards is a bug in both directions: the quote on the due pool is this deadlock, the batch on the requestable pool bills an entire address book nobody opted in. Tests: build-items.test.ts (chunking round-trip: 23 contacts → 3 chunks, lossless id map, verdict i resolves correctly), pricing.test.ts (credit arithmetic at boundaries, pluralisation of the cost line, both credit-table entries pinned so a silent drop reads red), and enrich-sectors/__tests__/{requestable-pool,due-pool}.test.ts against a real PGlite database — the quote returns a non-zero offer on a book where nothing has ever been stamped, and the batch pool is empty on that same book. Both mutation-proved to go red. Pending: no live provider round trip for Tier 2 — the $0.02 cost estimate is un-measured, and one real call reading back usage.server_tool_use.web_search_requests would settle it; no browser click-through of the dialog or the unplaced-companies page; the auto-enrich toggle is visible and greyed but the automatic path behind it is still unbuilt; mobile parity pending; the taxonomy became user-extendable on 2026-09-06 — createSector, deleteSector, listCustomSectors and sectorUsageCounts had been built with no callers at all, so the sectors table could only ever be empty and listSectorNames() returned DEFAULT_SECTORS forever: the read path's extensibility was a property no user could reach. A Sectors card in Settings → Suggestions now calls them (components/app/settings/SectorManager/, lib/actions/sectors.ts), and a sector added there is choosable by that same night's enrichment run, because the merged list IS the prompt's enum. Two repo gaps had to close first, both of which the entry above had recorded as open: deleteSector returned a bare false for BOTH "no such row" and "still in use", and sectorUsageCounts returned ONE merged contacts+companies total per name — so nothing could say which surface blocked a delete. Delete now returns {ok:false, reason:"in_use", contacts, companies} and the counts are split, so the refusal names every blocking surface in one pass. RENAME IS DELIBERATELY ABSENT: contacts and companies store the sector NAME as free text with no FK, so a rename that did not rewrite every row would strand them — the same reason deleteSector refuses while a name is in use. Built-in sectors render with no delete control at all rather than a disabled one: they are constants with no row id, so a greyed button would imply that clearing contacts could eventually free them. lib/repo/sectors.ts is now the directory lib/repo/sectors/ (index.ts + write.ts + slug.ts); the import path is unchanged. Two of the three decisions the entry above named as blocking a manager are still open and were NOT taken here: whether create also belongs inline in SectorChip's combobox, and whether the company form's free-text sector <Input> should become a picker. Also unchanged and worth knowing: sectorSlug strips & as a separator, so "Legal & Compliance" and "Legal and Compliance" slug differently and can both be added — "duplicate" is narrower than a reader might assume. Companies-table sector column is now filterable too, on a producer of its own rather than the People-side one: the two sector columns are a different value set (company sectors come from the paid web lookup or, since 2026-09-06, the nightly company pass; contact ones from the model or from inheriting an employer's), so reusing the contacts producer would have offered options matching zero company rows. It got exactly what that needed — a companies branch on /api/table-filter-options, searchCompanyFilterOptions behind a COMPANY_FILTER_OPTION_COLUMNS whitelist, and a sector param on listCompaniesPage applied to the COUNT query as well as the page, since a filter on only one returns a correctly narrowed list under a total counting everything. Its repo-level tests live in lib/repo/companies/filter-options.test.ts. Surfacing tests live in app/app/__tests__/table-filter-searchparams.test.ts — an absent ?sector= reaching the query as [] rather than [""], two sectors as an OR with the ampersand intact, and Saved narrowing by sector without losing its own watched: true scope; no browser or mobile pass on the new column yet. Fourth defect — the dialog advertised a free path that does not exist — copy corrected 2026-09-06: the description read "Waiting is free — the batch runs tonight and adds sectors while you sleep" and the cancel button read Wait for tonight, both carried over from GoalRequestDialog. The wording is true THERE — goal matching's nightly pass keys off having an active goal, so declining still gets you matched — and false HERE, because every sector path requires sector_enrich_requested_at: the paid batch via dueForSectorEnrichment, and the zero-credit inherit pass, which carries that column in its own WHERE. The only writer of the stamp is the confirm arm, so declining enriched nothing, tonight or ever, and the "N credits vs free" contrast described a choice the code never offered — there is one path, and it costs ceil(N/10) credits. Now: SECTOR_ENRICH_TIMING_LINE ("Nothing runs unless you confirm — tonight's batch does the work, and the credits are charged when it runs"), cancel reads Not now, and the success toast says "sectors appear over the next few nights" rather than "tonight" because the pass is two-phase and SECTOR_ENRICH_RUN_CAP is 400/night. This is a copy fix, not the missing feature: the free overnight tier that wording described is still unbuilt — the SectorAutoEnrichSetting switch is visible as of #366 but deliberately disabled, so there is still no stamp-free nightly path to make "waiting is free" true. A THIRD tier landed 2026-09-06 — the nightly COMPANY pass (lib/jobs/enrich-company-sectors/), and it is what the Companies page now enrols. Before it, both of that page's entry points enrolled CONTACTS: the header rendered the People folder's SuggestSectorsButton with scope: "all", and the bulk bar unfolded a company selection server-side into "every eligible contact at those companies" — while the Sector column below reads companies.sector, which nothing on the page ever wrote. So a user ticked companies, spent credits, waited nights, and the column they were looking at stayed empty: a button that charged for one thing and displayed another. Both entry points now enrol the companies themselves (SuggestAllCompanySectorsButton in the header, SuggestCompanySectorsButton in the bulk bar, one CompanySectorEnrichDialog behind both), which is the value that column reads. The feature id is company_sector_enrichment, 1 credit per 10 companies (COMPANY_SECTOR_ENRICH_CHUNK_SIZE, the same chunk-is-the-price rule as Tier 1, and cheaper per item than it because the prompt carries a name and a domain rather than a name, a title, an employer and up to four facts). It judges the company NAME — a firmer subject than a job title, and the reason this tier exists beside Tier 2: the model already knows what a hospital, a bank or a construction firm is, for roughly 1/40th of a paid search. A company whose trimmed name is under COMPANY_NAME_MIN_LENGTH (3) is import debris — skipped, never billed, never offered. Escalation is unchanged: a company the model cannot place gets sector_lookup_suggested_at stamped and appears on /app/companies/unplaced for the existing 4-credit web search, so the two paid tiers stack rather than compete. sector_source = 'model' is written here for the first time — the value has been declared in SECTOR_SOURCE since the column shipped and nothing had ever set it, so every reader that branches on it starts seeing rows the night this pass first runs. A billing trap had to be closed before any of it could be wired. sectorEnrichRequestAction — the CONTACT confirm — already stamped companies.sector_enrich_requested_at on every employer of an enrolled contact, and its own docblock warned not to build on that column without wiring a reader first. This pass is that reader, so left in place the write would have made enrolling PEOPLE silently enrol and bill for a whole book of COMPANIES that the contact quote never counted. The write is gone; companies are enrolled only by the company confirm, which re-quotes them server-side first, so each button charges exactly what it quotes. The free inherit step was widened in the same change — inheritSectorsFromWebLookedUpCompanies → inheritSectorsFromPlacedCompanies, with the co.sector_source = 'web' clause deleted. That clause excluded model-judged companies and user-typed ones — a hand-typed company sector is sector_source NULL beside a set sector, the most trustworthy rows in the table — so a user who had labelled their companies by hand got nothing for it while their people were still judged and billed one at a time. The gate is now simply "the company has a sector"; the CONTACT's own sector_enrich_requested_at is still required, because inheriting is free but "never runs unasked" is a promise about rows, not about cost. The economics are the point, and they set the job order. Placing companies first is what makes contacts cheap — one company verdict inherits down to everyone who works there at zero credits, where judging those people individually is one credit per ten PEOPLE — so /api/jobs/daily runs companySectorEnrichment immediately before sectorEnrichment, and the inherit step reads tonight's answers rather than last night's. The two-question predicate split is repeated here deliberately (requestableForCompanySectorEnrichment / dueForCompanySectorEnrichment, two named exports and never one flag) because merging them is exactly what made the contact tier unreachable for every user from #354 until #364: the quote reads the requestable pool because the quote is what CREATES the stamp, the nightly batch reads the due one because it is the only path that spends. Tests: enrich-company-sectors/__tests__/{build-items,pool,inherit-from-companies}.test.ts, the pool pair against a real PGlite database. Pending on this tier: the pass has never run end to end against a live batch — no integration test over submit/process, and the contact pass has none either; the completion bell reuses the existing sectors_enriched kind rather than a company-specific one, a follow-up recorded in process-pending-batch/notify.ts; no browser pass on the new dialog in light and dark at 375px; the Companies sector column is still filter: false, which is the next PR; and the custom-sector manager is still unbuilt Jev gate on the web lookup (2026-09-23, lib/ai/sector-lookup/jev-gate.ts): the search always runs; Jev skips the parse call only at p(found) ≤ 0.02 (SECTOR_LOOKUP_JEV_MAX_FOUND_PROBABILITY, compared directly) — confident the search found nothing about the company, returning no sector and no blurb — what the parse would return.

21. Viral growth loops (2026-07-24)

Three growth features built together on feat/network-wrapped-viral. All are typecheck/lint/build-clean across every workspace and the EE reward logic is unit-tested; not yet manually click-tested in a browser or pushed — same [x] bar as §19, so they stay unchecked until that happens. The public graph sandbox is web-only by design (a marketing surface); Wrapped + referral ship on web and mobile per the parity rule.

  • Network Wrapped — a contact-free, proud-to-post share card computed deterministically from the user's own graph (no LLM, zero metered cost, Rule 5). Scope-selectable: per event, or per week/month/quarter/year/all-time ("47 people this month", "12 at an event"). Server-rendered next/og card in three formats (1200×630 unfurl / 1080×1080 square / 1080×1920 story), with HMAC-signed params so cards can't be forged; public /wrapped/[token] unfurl page; authed /app/wrapped scope-picker + share modal (download / copy / native-share). Stats repo lib/repo/wrapped runs ONE aggregate query (no getDb() fan-out). Privacy: the card carries counts + cluster CATEGORY only; third-party names (top company, most-connected person) are reveal-gated in-app and never enter the token/URL/image. /api/wrapped (x-api-key) + mobile wrapped screen (RN Share).

  • Public interactive graph sandbox (landing, web-only) — a STATIC, anonymized network the visitor drags/zooms/hovers, reusing the /app sigma renderer verbatim (useRenderer/reducers/theme/camera) with positions baked offline. Loads ONLY on demand: the landing ships a light teaser + CTA; the sigma chunk + graph-core.json (~3.9k nodes, ~134 KB gz) fetch on click, and graph-full.json (21k nodes, ~820 KB gz) only on "Explode to full network". Baked by scripts/export-public-graph.mjs from the deterministic seed generator (synthetic names, no PII, no DB, no network).

  • Two-sided referral (hosted/EE) — a free month of Pro for both sides. /r/<code> sets an httpOnly cookie; a valid code admits the referee past the invite allowlist; the reward fires on email-verify. Stripe-safe: a live-Stripe advocate gets a 100%-off-1-month coupon (STRIPE_REFERRAL_COUPON_ID), free users get an additive comp Pro month (null-preserving). A Razorpay subscriber (live or cancelled) is NOT rewarded yet — their row is left untouched and the referral records deferred-processor-sub (2026-10-05; the old comp write corrupted their row), pending a Razorpay mechanism. Anti-abuse: self-ref + duplicate + per-referrer cap. The self-referral email match is canonical (2026-10-06: +tag stripped, Gmail dots stripped, googlemail = gmail), so me+1@ and dotted variants of the code owner are refused; owner decisions still open: cap total referral days, require the referee's first real action before rewarding. packages/ee/src/referrals (control-plane tables, no RLS); web /app/referral + /api/referral; mobile advocate screen. Redemption is web-signup only.

  • Dhaga Classic preserves the original warm paper/charcoal light and dark palettes as an eighth Appearance preset; the refreshed blue-white/midnight Dhaga palette remains the zero-overhead default.

Two features built together because they share one foundation: Dhaga had nowhere to record who you are, and a template needs a signature while a shared page needs a sender. Both are core — nothing here imports packages/ee — and neither makes a single AI call, so there is no new credit price and no new row in BRD §8.3. Web only; no mobile parity (the Expo app has no /app/me equivalent and cannot mint a link).

Test state, as measured rather than assumed (run 2026-08-23, ten files, 64 cases): 63 pass and ONE FAILS. me-profile.test.ts › "updates in place instead of accumulating rows" calls upsertMeProfile({ headline: … }) with the old single-argument signature, while the repo now takes (userId, input) — the signature changed when the singleton fix landed (below) and this caller was not brought with it. The consequence is not cosmetic: the singleton write is the one path in this feature with no passing coverage right now. Everything else — templates, rendering, expiry, revocation, both projections, the share actions, the resolver's tenant scoping, the DDL replay and the analytics-token redaction — is green.

Nothing else has been verified either: no browser click-through, no push, and the live-database RLS isolation run is still owed (see the last item). So every box here stays unchecked — same bar as §19 and §21.

  • The Me page (/app/me, 2026-08-22) — Profile / Company / Templates, with the tab as URL state (?tab=, the /app/saved shape) so a pane is linkable and the back button works; an unrecognised value narrows to Profile rather than 500ing. Profile and Company are two halves of ONE me_profile row (lib/db/ddl/me.ts, db/schema/me.ts, repo lib/repo/me/, actions lib/actions/me/, UI app/app/me/ + components/app/me/): the account holder has one employer at a time for the purpose of a letterhead, so a second table would buy a join and nothing else. Singleton-ness IS the primary key, because id is the owning user's id — the same trick referral_codes uses in packages/ee. A UNIQUE (user_id) cannot be declared in core (the column does not exist there; EE's RLS loop adds it later), so the key the table already has does the work and the write is one INSERT … ON CONFLICT (id) DO UPDATE. This was corrected on 2026-08-22 after a concurrency audit, and the reason is worth keeping: the first implementation was lookup-then-upsert inside one mutation() transaction, and both the code comment and the design doc claimed the transaction prevented a double insert. It does not — at READ COMMITTED neither transaction sees the other's uncommitted row, so with nothing to conflict on both saves observe "no profile", both insert, and every later read limit 1s onto an arbitrary one of them. A transaction cannot serialise two inserts that have no row to collide over; only a key can. getMeProfile() still orders by updated_at DESC, id ASC as belt-and-braces, so a row written by the earlier build (keyed on a random UUID, and therefore able to sit beside the user-keyed row) resolves to the newest write rather than to whichever one the planner reached first. Stale comment flagged, not fixed — lib/db/ddl/me.ts's header still says singleton-ness is "enforced in the REPO (lookup-then-upsert)"; that describes the implementation this audit replaced. Templates are document templates and email drafts in one me_templates table with a kind discriminator, because they differ only by whether a subject line applies; a body is stored verbatim with its {{tokens}} unexpanded, so editing the profile updates every template that references it instead of leaving stale copies behind. Substitution is a pure function and that is the cost story (lib/templates/render.ts): no DB, no LLM, no clock — if code can answer, code answers — so this path adds no AI action, no credit price and no metering, and the same function runs in the editor's live preview (client) and on the server. The vocabulary is closed and enumerated (utils/constants/me.ts, 14 tokens), deliberately not an open object-path syntax, which would be a read primitive aimed at whatever object the renderer was holding; {{ me.name }} with spaces is the same token as {{me.name}}, an unknown token is left VERBATIM so a typo stays visible instead of silently deleting a line from a letter, and a known-but-unfilled one renders empty. The live preview merges the user's REAL profile with invented samples for the recipient — no contact is picked yet and borrowing one from the graph would put a third party's PII on a screen nobody asked for. Field caps (ME_TEXT_MAX 200 / ME_LONG_TEXT_MAX 4000 / TEMPLATE_BODY_MAX 20000) are product limits in the form and the server action, not column constraints, so raising one needs no migration and an over-long legacy row stays readable. Nav: Me sits in the overflow/You sheet (APP_MORE_LINKS, APP_MORE_MENU_LINKS), not the rail — you fill a profile in once. Tests: lib/templates/render.test.ts (every token class, whitespace tolerance, unknown-token verbatim, an unclosed {{ not throwing, "no listed variable is dead", and that the preview never carries a real contact's details), lib/__tests__/me-profile.test.ts (singleton upsert, partial submits, present-and-blank means delete, over-long rejected not truncated), lib/__tests__/me-templates.test.ts (a subject exists only on an email, unknown kind refused, empty title/body refused, CRUD round-trip). NOT pushed, and no browser click-through has happened — this stays unchecked until both. Deliberately not built yet, so nobody reads this as more than it is: nothing renders a template against a CHOSEN contact. {{contact.*}} is implemented and tested and buildTemplateValues accepts a recipient, but the only surface that expands a template today is the editor preview, which always substitutes samples — picking a recipient and producing the finished text is the next step.
  • Share links (/s/[token], 2026-08-22) — one contact or one company published at an unauthenticated URL that always expires. Schema lib/db/ddl/share-links.ts + db/schema/share-links.ts; repo lib/repo/share-links/; pure helpers lib/share/{expiry,token,url}.ts; projection lib/share/projection/; resolver lib/share/resolve/; actions lib/actions/share-links.ts; public route app/s/[token]/; owner UI components/app/share/ mounted from ContactActionsCard and CompanyDetailActions. Expiry is mandatory and has no escape hatch: presets are 24h / 7d / 30d / 90d (SHARE_EXPIRY_PRESETS, default 7d), there is no sentinel meaning forever anywhere in utils/constants/share.ts, the server clamps to SHARE_MAX_EXPIRY_HOURS (365 days) for requests that did not come from the presets, and an expiry the server does not recognise is REJECTED, never quietly defaulted — a request whose intent we cannot read must not mint a working link. Liveness is one read-time check (isLive: revoked_at IS NULL AND expires_at > now), so revocation is instant with no cache to invalidate and no sweeper that must have run; expired rows are kept as the owner's record of what was shared. Unknown, expired, revoked and dangling links all render the SAME neutral page (not-available.tsx) — telling them apart would confirm to a stranger holding a guessed string that the token was once real. Disclosure is opt-in, five toggles, all DEFAULT FALSE (include_contact_details / include_notes / include_people / include_facts / include_follow_ups, the last two added 2026-08-24), and a toggle that cannot apply to the subject is stored false rather than carried inert, since the row is the audit record of what was disclosed — include_contact_details is contact-only and include_people company-only, while facts and follow-ups are clamped by neither, because facts and follow_ups each carry a contact_id or a company_id and listCompanyFacts / listOpenCompanyFollowUps exist for exactly that. Defaults are name/nickname/title/company/positions for a contact and name/domain/sector/aliases for a company; behind a toggle: contact details, owner-written notes (SHARE_MAX_NOTES 50), facts (SHARE_MAX_FACTS 50) and OPEN follow-ups (SHARE_MAX_FOLLOW_UPS 25, lower because a long list of what the owner still owes someone is a portrait of how they manage the relationship); never disclosed at all: tags, starred, keep-in-touch cadence, personKind, source, signals, card photos, relationship edges, a fact's confidence, a follow-up's recurrence schedule and closed follow-ups, and the source_note_id receipts that ride on facts, edges, follow-ups AND positions. Enforced by an allow-list projection built field by field, never a ...spread, so a column added to contacts later cannot silently widen an existing link; card photos are also structurally out of reach (/api/card-image/[id] is session-gated and 401s a visitor). The one correctness constraint is tenancy. A visitor has no session, so getDb() would fall through to the global UNSCOPED connection and getContact(id) carries no owner predicate — calling a repo read straight from the public page would hand any tenant's contact to anyone who guessed an id. So share_links is a cross-tenant routing table with an explicit user_id (in CORE_DECLARED_USER_ID_TABLES, deliberately ABSENT from EE's TENANT_TABLES alongside messaging_identities/messaging_link_tokens — an RLS policy would compare user_id against a session variable that is unset at exactly that moment and match nothing), the owner is read off that row first, and every content read happens inside withUserDb(link.userId, …). No FK to contacts/companies, twice deliberate: an FK check bypasses RLS and would be a cross-tenant existence oracle, and a plain REFERENCES is RESTRICT in this repo, so an outstanding link would veto "forget this person" — a privacy promise cannot have a delete a stale link can block. A dangling subject_id is therefore an expected state that fails closed, and deleting the person is itself a revocation. A visit records a count and nothing else — view_count / last_viewed_at, no visitor identity, IP or user agent; a share link must not become a tracker aimed at a third party — and it is recorded only once the link actually resolves, so probing a guessed token never lands in the owner's receipt. Not indexable: ${SHARE_PATH}/ joins the robots.ts disallow list (trailing slash load-bearing — a bare /s prefix-matches /signup and /sitemap.xml), the page sets robots: { index: false, follow: false }, its title/description are fixed strings so no crawler or chat-app unfurl republishes a private person's name, and it is force-dynamic because a prerendered copy would keep serving seconds after a revoke. Account deletion covers it: share_links, me_templates and me_profile are all in ACCOUNT_OWNED_TABLES — nothing cascades them, so an outstanding link would otherwise outlive the account. A recipient can save the person, not just read them (2026-08-24). The contact body carries a quick-action row — Save contact always, plus Call / Email / WhatsApp only when include_contact_details is on — built as plain <a href>s rather than scripted downloads, because a text/vcard response opens the OS add-contact sheet only when the browser makes the navigation itself; nothing there is instrumented and the wa.me link is digits only with rel="noreferrer", so the person's name leaves the page in nothing. GET /s/[token]/vcard (app/s/[token]/vcard/route.ts) builds the file from lib/share/vcard.ts, a sibling of the export writer rather than a caller of it: contactsToVCards takes an ExportContact extends ContactRow — the raw row this whole page exists to keep away from a visitor — so the shared builder takes SharedContactCard instead, and details being ABSENT unless toggled makes an over-disclosing file impossible by type rather than by review. Escaping is the export writer's own helpers, whose parameter was widened to Pick<ContactMethod, "value" | "label"> rather than duplicated — a second escaper would mean a name that renders correctly on the page and corrupts the file. The route re-resolves the token, so expiry and revocation are re-checked at DOWNLOAD time, and passes countView: false because a sub-resource of an already-counted view must not report the link as opened twice; it is force-dynamic + no-store, and returns the same bare 404 for an unknown/expired/revoked token AND for a company link, which has no vCard — a different status would let a stranger probe whether a token exists. The Content-Disposition filename goes through an allow-list ([A-Za-z0-9 _-], NFKD first) because the person's name is owner-typed data landing in an HTTP header, where a quote ends the parameter early and a CR/LF starts a new one. Tests: lib/__tests__/share-resolve.test.ts (the resolution opens the scope named ON THE ROW and never reads a subject outside one; nothing resolves one second past expires_at with no sweeper having run; revocation is immediate; a revoke from anyone but the owner is refused; unknown token and dangling subject both fail closed), share-expiry.test.ts (each shipped preset resolves and nothing else, no preset means forever or reaches the ceiling, the clamp cannot be routed around, the expiry instant itself counts as over, URL building), share-projection.test.ts + share-company-projection.test.ts (the exact disclosed key set, receipts and the owner's private per-position note stripped, each toggle honoured, a fact stripped of confidence/sourceNoteId and a follow-up of its recurrence/status — the key-set assertions are what a future ...spread would fail on), share-actions.test.ts (an unrecognised expiry rejected rather than defaulted, an unknown subject type rejected, refusal to mint against a subject the caller cannot see, only applicable toggles stored — including facts/follow-ups surviving on BOTH subject types — every toggle opening only on a literal true, revoke reflected in the refreshed list), me-share-ddl-replay.test.ts (the two later columns reach a database that already holds links, closed), share-vcard.test.ts (a card with NO details produces a vCard with no EMAIL/TEL/URL line at all — a downloaded file outlives the link's expiry and nobody re-reads it, so over-disclosure there is worse than on the page — plus CRLF endings, vCard escaping of a name's own separators, and the filename sanitiser against the header injection it exists to stop). NOT pushed, and no browser click-through has happened. Manual QA — including opening a link in a private window with no session — is written up in the testing guide §7ad and has not been walked yet.
  • The share token stopped leaking into analytics (2026-08-22, found by the same audit pass) — app/layout.tsx mounts Vercel Web Analytics and Speed Insights on every route, /s/[token] included, and both store the CONCRETE url of every data point. The only defence, redactAnalyticsUrl (lib/analytics/redact-url.ts), replaced a path segment only when it matched a UUID — and a share token is randomBytes(32).toString("base64url"), which matches that pattern in no character. So the redactor was a complete no-op on the one route where the path segment is not an id but the whole credential: it was reported intact on every view, to a third-party store that retains it past the link's own expiry, where anyone with dashboard access could have used it to read a named person's record. The fix is a second redaction that works by POSITION rather than shape — ANALYTICS_OPAQUE_PATH_ROOTS (utils/constants/analytics.ts) declares that whatever sits directly under /s is secret, anchored to the START of the path so a segment merely named s deeper in another route (/blog/s/…) is untouched, and REFERRAL_PATH was extracted alongside it rather than left as a bare "/r". Position is the property that survives someone re-encoding tokens later; shape is not. /wrapped is deliberately NOT in the list (its token is contact-free by construction and /wrapped/og is worth telling apart in the analytics) and /r/<code> is not either (a route handler that 302s before any analytics script runs). Covered by four cases in lib/__tests__/analytics-url-redaction.test.ts, written against a real generated token rather than a literal, precisely because the defect was that the token's shape is unrecognisable. The two throwaway audit artefacts that documented both findings were deleted once the fixes landed, which is why neither is cited above.
  • New tables + RLS specs — me_profile and me_templates are added to EE's TENANT_TABLES (they hold one identifiable living person's own name/email/phone and their own drafts in their own voice — nothing derived, so no FK could scope them even in principle) with matching entries in the RLS isolation test's SPECS, without which nothing proves their rows are hidden from another tenant. share_links is deliberately in neither list, for the routing-table reason above. lib/__tests__/me-share-ddl-replay.test.ts proves the three tables apply cleanly to a database that predates them, leave existing rows alone, replay idempotently, and that Postgres itself refuses a share link with no expiry. The live-database RLS isolation test was NOT run: it needs a real Postgres with a non-superuser role and skips itself without DATABASE_URL — in CI too — so what is proven today is the database-free tenant-table coverage guard plus the DDL replay, not that the policies behave against a live server.

23. Lists

The second destination for capture — "the things you keep, rather than the things you owe". An item is a follow-up carrying a list_id, not a new table, so ticking, reminders and a single definition of "done" come free; the /plan predicate below is what keeps /app/lists and /app/plan apart at the read layer. Built as phases 1–4 and 7 of the design doc, plus the ask flow — "you don't have a Projects list, did you mean one of these?" — which landed after them. Phases 5–6 are not built and are listed unchecked below, honestly rather than aspirationally. Everything here is core — nothing imports packages/ee — and there is no new AiActionFeature and no credit price: the batch rung folds into the batch_plan the batch already spent, the fast path into message_intent (0 credits), and every write is code rather than a model call. Web only; no mobile parity.

Verification state, measured rather than assumed: tsc --noEmit clean in apps/web, packages/core and packages/ee; eslint 0 errors; next build exit 0; and the unit suites green — apps/web 5401/5401 across 897 files, packages/core 580/580, both re-measured on this branch after the referent work rather than carried forward. Concurrency is proved by testing each INTERLEAVING, not by observing one: claim-then-DONE, DONE-then-claim and the same claim delivered twice are each driven deliberately and asserted, because PGlite runs on a single connection and two truly simultaneous writers have never been observed — the lock that would serialise them on a real server is reasoned from Postgres semantics, not watched. A redelivered chat command that already filed is not a strict no-op either: it writes nothing and captures nothing, but answers "already on your list" a second time. EE tenant isolation for lists is written but has never executed — it is gated on a live Postgres with a non-BYPASSRLS role, and PGlite has no row-level security to stand in, so that claim is reasoned rather than verified. No browser click-through and no live-database pass, so the same bar as §19, §21 and §22 applies to anything only the browser can show — which here means the drag gesture itself. A green test over a mocked double earned distrust twice in this work: a double that had not declared a new writer made every list question look like a plain capture rather than a failure, and an unmocked deep import threw, a throw being indistinguishable from "nothing was close". Both are now modelled rather than stubbed — a reason to read a passing list test for what it exercises, not a claim of coverage anywhere.

  • Phase 1 — lists exist (2026-09-21, lib/db/ddl/lists.ts, lib/db/schema/lists.ts, lib/repo/lists/, lib/actions/lists/, components/app/lists/, app/app/lists/) — a lists table on the template events/groups/tags/goals already use: name, emoji, a palette-token color (never a hex), parent_id and archived_at. Nesting is exactly one level, enforced in the repo — a list with a parent_id may not itself be a parent — because arbitrary depth makes breadcrumbs, move-targets and bot routing expensive at once and nobody nests a shopping list three deep. Archive, never delete, and that is sharper here than for groups: follow_ups.list_id is ON DELETE SET NULL, so a delete would neither abort nor cascade — it would silently un-list every item the list held and leave those rows in the account with nothing pointing at them. parent_id is ON DELETE SET NULL for the same reason plus one more: referential actions bypass RLS, so a cascade would let one account's erasure delete another tenant's sub-list. The table declared no user_id at this phase (EE's RLS loop adds it, nullable, so a core-only self-host can still insert) — the duplicate-name index below reversed that half, because a unique index has to name the column it is unique on; it declares no organization_id (EE's org drift-guard raises at BOOT for a TENANT_TABLES member that carries it and is absent from EE's org tables) and no visibility; "lists" is registered in TENANT_TABLES instead. The screen this phase shipped was a stacked index — real lists beside Unfiled, sub-lists indented under their parent, archived lists in their own group at the bottom — and phase 7 replaced it with the board, so that entry and not this one describes today's /app/lists. What this phase shipped that is still load-bearing is the table, the one-level rule, and that archive is reversible from the UI: ListHeader renders one toggle that says Archive or Unarchive, matching GroupHeader exactly.
  • Phase 2 — items, and the /plan predicate that keeps the two boards apart (2026-09-21, lib/db/schema/notes/follow-ups.ts, lib/repo/tasks/filed-items.ts, lib/repo/tasks/queries/, lib/repo/tasks/mutations/, lib/repo/tasks/associations.ts, lib/filters/follow-up-scope.ts, lib/repo/search/keyword/combined/plan-branches.ts, lib/repo/reminders/, components/app/lists/ListItems/) — one nullable follow_ups.list_id with a partial (list_id, created_at DESC) index, and add / tick / untick / delete on top of it. Sorting (added, alphabetical, done) is done in SQL, not after a limit — filtering a capped read in JS hands Home a short preview and the agent a truncated answer it then describes as complete, and filtering after a COUNT is not possible at all. The predicate: a follow-up carrying a list_id OR a group_id reaches /plan when, and only when, it has a date. Undated, it belongs to its container; adding a reminder is the single gesture that promotes it, which is exactly what the nav copy already claims. The reverse holds too — an item completed in Plan shows ticked in its list, because it is one row and one status. The group_id half fixes a pre-existing bug rather than introducing behaviour: undated group follow-ups have been landing in /plan General all along, because TaskItem never carried group_id for any predicate to read, while the group page showed the same row the whole time. The rule exists in exactly two implementations — SQL (filed-items.ts) and in-memory (lib/filters/follow-up-scope.ts) — and all seven read sites use one of them, because missing one is a shipped bug. listId is deliberately not added to ContentOwnerColumns: the follow-up write path never calls assertSingleOwner, and adding it would start rejecting notes and facts that are correct today. An item's delete is a REAL delete of the row — a list has no All chip and no restore, so a soft dismiss would be both invisible and permanent — while the LIST is only ever archived.
  • Phase 3 — the item detail view, and an item that has a URL (2026-09-21, components/app/lists/ListItemDetailDialog/, lib/db/schema/notes/follow-ups.ts) — a body on a plain nullable follow_ups.detail text column, chosen over reusing notes via sourceNoteId (which buys extraction and search_tsv free but overloads a column that means "the note this task came from", not "this task's body"). The body renders as plain text, never markup: it reaches this table from the capture pipeline as well as from the keyboard, and putting that through a renderer is not a trade worth making for formatting. The http(s) links inside it are extracted and offered beside the body instead, which gets the one thing actually wanted — a link you can press on a phone. javascript:, data: and vbscript: cannot match the pattern, and the URL-parsed protocol is checked anyway rather than trusted from the regex, so the day somebody widens the pattern to catch a bare www. that check is what stops it becoming an injection. The dialog is ?item= URL state, so an item now has a permalink.
  • Phase 4 — capture can reach a list, on all three surfaces, without touching the grammar ceiling (2026-09-21, lib/messaging/process-session/apply/unclear/list.ts, lib/messaging/lists/, lib/messaging/inbound/route.ts, lib/ai/agent/write/list.ts, utils/constants/messaging/list-replies.ts, packages/core/src/schemas/batch-plan/, packages/core/src/schemas/message-intent/, packages/core/src/schemas/agent-turn/) — the reason Lists is in Dhaga at all: "buy milk on the way home" used to become an unfiled note with the bot asking which person it was about. One resolver serves all three surfaces (findListByName in lib/repo/lists, an exact case-insensitive match against the user's own rows, in code and never by the model; it refuses an archived list, refuses a tie, and never creates), so "shopping" cannot mean one thing in chat and another in capture. (a) The batch unclear ladder gains a fourth rung — company → group → list → ask. Lists go LAST so nothing that files correctly today moves, and the rung sits directly above createGeneralTasks, because addToList calls that itself and a rung placed below it would write every reminder twice. (b) A list_add inbound fast path, because the batch is the wrong latency for adding milk — you should not have to type DONE first, and the idle sweep is up to 30 minutes plus a tick. A routed message inherits redelivery dedupe but loses the idle-sweep recovery a captured one has, so the handler falls through to capture on every failure — no such list, a tie, nothing left to add, a write that threw — with exactly one exception: a write that succeeded and a reply that could not be sent, where the row is already on the list and the send failure is logged instead. (c) add_to_list and move_to_list for the in-app agent, both Tier A — not because they are small (move_to_list takes a row out of one container and puts it in another) but because the inverse act is in the same vocabulary, said the same way in the same chat. Neither may create a list, and neither was folded into tag, which would have cost nothing and taught add_tag that a list name is something to write onto a person. The 32-property grammar ceiling was measured before anything was added, not assumed — a 33rd property returns 400 invalid_request_error "The compiled grammar is too large" and breaks capture for every user, which has already caused two outages. batchPlanSchema is untouched at 32/32 (the rung cost one sentence of prose on subjectName, which the grammar does not charge for); messageIntentSchema went 12 → 13 of 32 and gained its own budget.test.ts, which also guards the second ceiling a property count cannot see — every required-but-nullable field is emitted on every verdict and so raises the response floor against maxTokens: 500, where a truncation is a Zod miss and a Zod miss is a silent fall-through to capture; agentTurnSchema went 15 → 16 of 32, the one property being the listName both acts share.
  • Unfiled is a pinned, protected system list (2026-09-21, app/app/lists/page.tsx, components/app/notes/UnfiledNotes/) — /app/lists shipped in PR #467 holding exactly one thing, unfiled notes; now that real lists exist, Unfiled is a system list pinned ahead of them — first on the stacked index this phase shipped, and the board's first column since phase 7. It is deliberately not a row in lists — there is nothing to rename, re-parent, archive or delete, and no account can end up without it. The page's body is also no longer gated on unfiledNotes.length === 0: each section owns its own empty state, so an account with three lists and no unfiled notes sees its lists rather than a full-page "Nothing to work through" card.
  • Phase 7 — /app/lists is a BOARD, a note can be filed onto a list, and a new account starts with three (2026-09-22, lib/repo/lists/, lib/actions/lists/, lib/repo/notes/unfiled/filter.ts, lib/repo/search/keyword/combined/plan-branches.ts, lib/auth/config/signup-hooks/starter-lists.ts, components/app/lists/Board/) — Unfiled is the first column, every live list is a column after it, and a card moves by being dragged. Unfiled stays VIRTUAL — no lists row and no id: you can drag out of it and never into it. A note dragged onto a list becomes an ITEM and the note is KEPT as its receipt: one follow-up row carrying the list, the note's first line as the item and the rest as its body, and a sourceNoteId pointing back at the note. How it leaves the Unfiled column is the part that had to be right: not by gaining an owner — there is no notes.list_id and there is not going to be one — so "unfiled" now means owns nothing and is not already a list item, in both readers of that definition (the list/count/lookup predicate, and the keyword search branch), as an extra condition on top of the ones the partial index covers rather than a change to them. Deleting the item therefore puts the note back in the inbox, which is the honest answer rather than a leak. The board's read is ONE database checkout and THREE queries whatever the account holds — the lists, then every list's items in one indexed query, then the capped unfiled notes: a column-per-list loader would take a connection per list from a pool of three, and a test fails if that comes back. Starter lists (Reading / Shopping / Chores) are written for a new account at signup; existing accounts are not backfilled — a migration cannot tell an account that never saw Lists from one that archived every list, so the empty board offers the same three behind a button, which refuses when the account has any list including an archived one. A sub-list is an ordinary column, sitting immediately after its parent's and labelled with whose it is — a sub-list holds its own items, so it has to stay a bucket a card can land on, and the alternatives are worse: a swimlane or an indent is a second board layout for a hierarchy exactly one level deep, and an indent is unreadable on a row that scrolls sideways at 375px. The column header says whose it is, and "Move to…" qualifies the entry as Parent / Child, because a flat dropdown has neither adjacency nor a header to lean on; a live sub-list whose parent is archived is promoted to top level rather than dropped. Archived lists are reachable again, as a collapsed drawer below the board with an Unarchive on each row, read from the same query the columns come from — no extra round trip. The board draws live lists only, which left an archived list addressable by its own URL and no other way, and in a design whose only retirement is archive that is a delete. The drawer is rendered outside the drag-and-drop context, so "you cannot drop a card on an archived list" is structural rather than a disabled drop target somebody can re-enable. It fixed a side bug on the way: an account holding only archived lists used to see "No lists yet" above an Add starter lists button that silently refused — the seeder counts archived lists too — and now says the board is empty because everything is retired, and points at the drawer.
  • Attachments and photos on an item (phase 5) — not started. attachments currently requires exactly one of contactId | companyId, so this needs a new follow_up_id owner column, a cascade that deletes the bytes with the item (apply/photos.ts documents a real silent-data-loss bug from getting an owner↔bytes pairing wrong), organization_id + visibility with the "BEWARE THE SPREAD" hazard, reachability from eraseAccount, and exclusion from the JSON export by reference rather than bytes — the dump is assembled as one in-memory JSON string and inlining payloads fails outright on a large account.
  • Embeddings over list items (phase 6) — not started. Widening embeddings so an item is semantically retrievable is the same wall group notes stopped at, and it fixes both at once. Retrieval in v1 is keyword only, by design.
  • A list search-hit kind — not built. An item is reachable through the existing task keyword branch, which by the phase-2 predicate does not return an undated one, so an undated item is still not findable by typing its words. The blocker docs/LISTS.md named for this — that a hit had nowhere to land, because an item had no permalink of its own — is gone as of phase 3; widening the branch was simply not part of this change.
  • The "1) Reading 2) Shopping" question for a capture with NO list evidence at all — still not built, and now for a different reason than this entry used to give. The blocker it named is gone: the answer half of the pending-question machinery is no longer hard-wired to contacts — it dispatches on the payload type, and the second payload kind it said would be needed is exactly what shipped in the ask entry below. What remains unbuilt is the question raised with no evidence: the batch asks only on a near miss, because a captured subject name is a PERSON far more often than a container, so a message naming nothing that resembles a list of theirs is the one case where a numbered offer would be a guess wearing a question's clothes — and the offer arrives numbered and people press 1. Routing is still evidence-only, and such a capture falls back to today's behaviour — a visible, recoverable unfiled note or confirmation card. That satisfies the actual safety argument, which is asymmetric: an unplaceable capture you can see is recoverable, while an item silently filed into the wrong list is invisible and the user finds out weeks later.
  • Moving a NOTE into a list — shipped, reversing the v1 recommendation (2026-09-22, lib/repo/lists/file-note.ts, components/app/notes/UnfiledNotes/FileNoteButton/) — a note dragged onto a column, or filed through the destination dialog's fourth answer "A list", becomes an item. The objection that kept it out of v1 still stands and is exactly what shapes the implementation: this is a conversion, not an association — moving an item onto a person is just setting contact_id on the follow-up, whereas a note has no list column to set and deliberately never gains one, so it is its own action rather than a fourth owner kind. What makes the conversion safe is that nothing is destroyed: the note is KEPT as the item's receipt, it leaves the Unfiled column by an anti-join rather than by acquiring an owner, and deleting the item puts the note back in the inbox — the honest answer rather than a leak, since an item's delete is a real delete and nothing else would remember those words were dealt with. "A list" is not offered when there are no lists to offer, and the picker is now an exhaustive switch that stops compiling if a fifth destination forgets it: it was a two-way choice with a catch-all fallback, so the fourth kind compiled, type-checked, and rendered the people picker.
  • You can add an item from the board, and the page stopped scrolling sideways (2026-09-22, components/app/lists/AddItemForm.tsx, components/app/lists/Board/BoardColumn/, utils/constants/lists/board.ts) — the board shipped as the main Lists screen with no add-item control on it at all: the one form lived on a list's own page, so putting "milk" on a shopping list cost a navigation there and back, and the empty column read "Drag a card over, or use a card's Move to menu", which told the user items only ever arrive from Unfiled. Every list column now carries the field, and it is the same component the list page mounts — one form, one add call path, one set of trim/clear/failure rules. It sits above the cards rather than under them: a column here grows with the page instead of scrolling inside itself, so at the foot of a forty-item column the field would be a page-scroll away and every column's would sit at a different height; above, it costs one fixed row whatever the column holds and the fields line up across the board. Unfiled offers none, and that is the same structural refusal as the drop rather than a hidden button: the helper hands back the list id the form would write to, Unfiled is VIRTUAL and has no row to hand one back from, so there is nothing to render a form with (the tests assert both halves, and that the columns accepting a typed line are exactly the columns accepting a drop). The second bug was a horizontal scrollbar across the whole window: the column row bleeds into the page gutter with a negative margin, and it carried the sm: bleed on up into lg, where the page's right gutter narrows — so 1.5rem of bleed pulled into a 1rem gutter put 8px of board outside the page container and gave the document its own scrollbar at every desktop width below the 1600px cap. The bleed is now one named constant that states the rule it must obey — the negative margin equals the page gutter at every breakpoint, never more — and matches all three. Measured in a headless browser at 375 / 768 / 1024 / 1280 / 1440 / 1600 / 1920: document overflow 0px at every width, the row still scrolling and still showing its peek of the next column at 375px.
  • "Add this to my projects list" now ASKS instead of falling silently to an unfiled card (2026-09-22, lib/repo/lists/candidates.ts, lib/messaging/lists/, lib/messaging/pending-question/, lib/repo/confirmations/, packages/core/src/schemas/confirmations/) — a list name that matches nothing used to fall through to capture; the bot now offers up to three near-miss lists the user already keeps, then "Make a 'Projects' list", and creation is ALWAYS confirmed, never silent. The matcher scores token SETS, not spelling — both names lowercased, punctuation stripped, English plurals folded, and the container words a / list / my / the dropped, so "my projects list" and Projects reduce to the same single token — with an offer threshold of 0.4 and a cap of 3. Sets rather than a distance measure because the real misses are a plural, a trailing "list" and word order, and because "Projects" against "Prospects" must score zero, where a character-level measure scores them alarmingly close. One slipped key is the exception: a word within one letter of a list's word (added, dropped, changed or two swapped) counts as it, for words of four letters or more, so "readng" offers Reading; "project" against "prospect" is two letters apart and still scores zero. The cut sits strictly between containment ("projects" against "Project ideas", the case this exists for) and two different lists sharing one generic noun ("work ideas" against "Gift ideas", the case that must never be offered — the options arrive numbered and people press 1). Archived lists are never offered, and ties break A to Z so a re-raised question cannot renumber its options under an answer already being typed. The exact resolver is UNCHANGED and is still the only thing that ROUTES — exact, case-insensitive, single match, live lists only, never creates — and everything here only ever PROPOSES: a wrong candidate costs one unread line in a chat, a wrong route costs a note in a container the user never opens. A new list_destination confirmation type, not a widened payload, so it rides the exhaustive switches and a missed site is a compile error rather than a runtime surprise — and the compiler found a THIRD switch the design review had missed. The two surfaces ask with different evidence: the inbound fast path has a classifier verdict naming the list, so it asks for any name; the batch's ladder rung asks only on a near miss, because a captured subject name is a person far more often than a container. Answering dispatches on the payload type — the contact question, and any payload this build cannot read, take byte-for-byte the branch they always took, and the agent filing step is skipped only for a list question, because it loads a parked NOTE and files it on a person. The list path runs no model at all: a near-miss query, a pure renderer, a pure parser and two writes, so no new AI action and no credit price. Idempotency: a fast-path question has no session item to key on, so its dedupe key is minted deterministically from the provider and the provider's own message id — the same pair the delivery table keys on — and a redelivery, or the re-run of a stale delivery claim, gets the card that already exists; the applier also re-checks the name inside its own scope, so answering twice creates ONE list. Nothing is lost: if the question expires, is ignored, or is answered unparseably, the words are still a visible, recoverable unfiled note or card — the card is raised BEFORE the chat message and holds the item's words. FIFO honesty: the raiser reads the head row and its payload in one database checkout and words the question from the payload, so the bot re-asks whichever question is actually the head rather than describing one it has not asked; a list question raised behind an older contact question waits its turn and is answerable in the app meanwhile. The reply is read above the classifier, by a parser that takes the printed number or the list's own name — plus, for a list question only, one trailing noise word, so "reading list" answers about a list called Reading.
  • The duplicate-name trap, which was a live bug before this feature (2026-09-22, lib/repo/lists/mutations/, lib/db/ddl/list-name-unique.ts) — creating a list rejected no duplicate at any layer and there was no unique index, so a second "Projects" inserted cleanly — and the damage landed on the READ side, silently: the exact resolver returns nothing on a tie because it may not pick, and all three capture surfaces ask that resolver and nothing else, so one duplicate row turned that name off on every capture surface at once, permanently, with no error anywhere. Once chat can create a list on a redeliverable message, the same accident is reachable by pressing send twice. Now create, rename and unarchive all reject a name already taken, with a unique index under them on the owner and the trimmed, lowercased name, partial on live lists only: a LIVE namesake blocks, an ARCHIVED one does not — archiving is a list's only retirement and re-making a list you archived is ordinary, while an archived twin never could cause the tie this exists to prevent. The owner is coalesced to an empty string rather than left null, because Postgres treats nulls in a unique index as distinct, so on a core-only self-host — the install with no row-level security behind it either — the index would otherwise enforce nothing at all. Accounts that already hold duplicates are detected and reported, never repaired: a bare index build would abort the whole schema replay on exactly the databases that have the problem, so the chunk counts the offending groups first and on any hit logs a COUNT and nothing else — a list's name is the user's own words and never goes in a server log — then skips the index; nothing is deleted, archived or renamed to satisfy a constraint, the write-layer rejection is the enforcement that ships on day one, and the index builds on a later replay once the account is clean. The count runs with row-level security bypassed for that transaction, which is load-bearing rather than defensive: the boot connection sets no user, would see zero rows, conclude the table is clean and build the index anyway. It is an appended schema chunk rather than an edit to an existing one, because the whole string is fingerprinted and replayed when its text changes. Consequently core now declares the lists owner column, because a unique index is enforced ABOVE row-level security and so must name the owner, or the first account to keep a "Reading" list would stop every other account on the deployment from having one — which in turn puts lists on the list of core-declared owner columns, whose drift test re-reads the shipped schema, and where presence proves nothing about tenancy.
  • The same question, answerable in the app (2026-09-22, components/app/confirmations/ListDestinationCard/) — the pending question renders in the inbox with picking and creating visually distinct, and the new list's name EDITABLE before it is created: a transcription heard as "projex" must not become a permanent list on one tap. Options are checked against a live snapshot of the user's lists, so one archived, renamed or deleted since the question was asked is dropped or relabelled rather than offered — read only when a list question is actually pending (the tenant connection pool tops at three, and most inboxes never hold this card) and projected to the three fields the card reads, because every field crosses into the client payload and a list's colour, emoji and open count are nothing the question needs; unknown is distinct from none. The chat-window lapse is computed on the SERVER where the inbox renders, since reading the clock in a client render is a hydration mismatch waiting on a boundary. The inbox gained a "Lists to choose" group at the same time — a pending type absent from that list is counted by the badge and rendered by nothing, which is the phantom "1 new" that no refresh can clear.
  • The in-app agent can create a list, at ZERO schema cost, behind a rail that fails closed (2026-09-22, lib/ai/agent/write/list/, lib/ai/agent/write/gate.ts, packages/core/src/schemas/agent-turn/) — the agent can now make a list and the agent-turn schema still measures 16 of 32 properties: a new enum member is free, and the act's only argument is the list name the two existing list acts already share. It is name-only by budget, not as a first cut — an emoji, a colour and a parent would be three properties on a schema whose 33rd is a 400 on every turn of every question, bought to save three taps on a screen built to collect them. The permission is deliberately not a confirmation tier, which was the obvious home and the wrong one: a tiered authorisation is stored against the act the model proposed, so it would confirm a create the model had already decided to make, when the question actually in doubt is the one before it — whether the user asked for a new list at all. A misheard "put it on my projects list" would produce a proposal whose "yes" was meant for the item. So the guard is a code-set flag on the agent's write context, optional, and absent means no, so a caller that never thought about it and one that decided against reach the same refusal; code opens it from the conversation's own state and nothing the model says can. The executor also re-checks the name inside the scope that would do the insert and catches the taken-name rejection for the race that read cannot close, both landing on "they already have one, add to it" — without that a create can brick the very name it was asked for. The add-to-list refusal changed with it: it used to say "Do not create it" flat, which left the model with nothing to do with the user's answer, and now names the create act for a NEW list — naming is not permission, and the rail still refuses it on a filing turn. The other two budgets are unchanged: the batch planner at 32/32 and the inbound classifier at 13 of 32 — the ask rung cost PROSE, which the grammar does not charge for.
  • "Add this to my reading list" filed the literal word this (2026-09-23, lib/ai/list-referent/, packages/core/src/schemas/list-referent/, lib/messaging/lists/, utils/constants/lists/) — a user forwarded a link, sent "Add this to my reading list", then DONE. Reading held a row reading "this", the link became an unfiled note, and the bot asked which person a code repository was about — three wrong outcomes from one missing step. The item text is cut from the message anchored on the list name we matched, so when the words left over are a pointer there is no item to write. A reference resolver now answers which forwarded message the pointer means. It is its own schema and its own model call, deliberately separate from the classifier — a property there would be paid on every inbound message, and drift in a shared prompt would change verdicts for intents that have nothing to do with lists — and it is metered as its own feature at 0 credits, so the spend is visible without charging the user for a fix to a bug. Its budget test measures it at 5 of 32 properties with an exact equality rather than a ceiling, because a ceiling cannot tell a deliberate spend from an accidental one. It is asked only when the item POINTS — a bare pointer, a pointer with a descriptor ("the one about sourdough"), or nothing at all with unfiled messages waiting — so "add milk to my shopping list" names its own item and goes onto the list with no model call at all, whatever is sitting in the batch: a resolver outage cannot stop a plain add, and no verdict, wrong or injected, can consume "milk" (which it used to, filing it nowhere and capturing it nowhere). The model never sees a database id: the sender's recent unprocessed messages are labelled A/B/C, and a reference it was not shown is discarded — a stray one turns the verdict ambiguous rather than filing the rest, because a half-understood answer is not a licence to file half of it. A title must be GROUNDED: every word of three or more letters, minus a short stopword list, has to appear as a whole word of the item it titles, split by a Unicode-aware segmenter and then on punctuation, so Devanagari and kana ground correctly and "scam" is not found inside "scammers"; a URL in a title must equal one in the item, host included. The detail is always the user's VERBATIM text — the title is presentation, the words are the record, and a link in the detail is pressable in the item view. Ambiguity, nothing to point at, an unavailable model, a throw, or a pick with no text (a photo) all mean no guess and no write: the bot says it will sort it out on DONE and the command falls into the batch beside the forwards, so the planner reads them together. A bare pointer with nothing to point at waits, because the forward and the command arrive on separate webhooks — the batch is re-read every 1.5s for up to 6s, settling on a count that stopped growing, and a plural pointer ("add these") waits the same way even when the batch was not empty, so it cannot file the first of three forwards. When the wait still finds nothing, the bot says why instead of "forward it": already on that list, or taken by a DONE, or still open in a batch. Hostile forwards are the assumption, not the edge case: anyone can craft a message for a user to forward and it goes into the prompt, so item text is escaped rather than stripped — a one-pass strip let a nested tag reassemble into a live one — with look-alike brackets normalised first, and a pick's attached commentary is kept only as the unbroken run immediately after that pick, so an injected forward cannot reach back and pull an older private note onto a list. A successful injection could still choose among the items the user is already asking about; it cannot invent one, reach an unshown one, choose the list, or title an item with words that item does not contain — and every filed row is named in the reply. Jev gate (2026-09-23, lib/ai/list-referent/jev.ts): Jev answers a confident nothing or ambiguous (≥ 0.9) with no Claude call; refers and own_text stay on Claude. Jev gets a 1s share (LIST_REFERENT_JEV_SHARE_MS), then Claude gets exactly its pre-Jev 10s — worst case 1s slower, identical with no key.
  • The forwarded message is CLAIMED, and DONE counts what is actually left (2026-09-23, lib/repo/messaging/sessions/write/claim-items.ts, lib/messaging/inbound/done/) — filing a forwarded link from chat has to take it out of the open batch, or DONE captures the same words again as the unfiled note this whole fix exists to stop. The claim stamps exactly the messages it took as processed, in one statement with two guards: still-unprocessed in the WHERE, so a row another writer has already stamped is skipped rather than re-stamped, and the session row read under a share lock and required to still be open, which conflicts with the batch's own claim of that row so a DONE and a chat claim serialise instead of both winning. It returns the ids it actually stamped, possibly none — and an empty claim writes no list item at all, because "not yours" and "yours" must not produce the same row. DONE's count moved to after the claim, and counts UNPROCESSED messages: the lookup's total still counts a row a chat add has taken, so DONE used to say "Processing 1 item…" and the batch then answered "I couldn't read anything in that batch" — an emptied batch now closes with "Nothing left to save". Counting before the claim is not enough (an add can land between the lookup and the claim), and once the batch is processing no add can claim from it, so the recount is final. The total still gates the "nothing forwarded yet" reply, deliberately: a batch that held messages must not wait out the late-arrival grace for an upload that is not coming. Concurrency here is proved by INTERLEAVING, not by observation: each order — claim then DONE, DONE then claim, the same claim delivered twice — is driven deliberately and asserted, because PGlite has a single connection and two truly simultaneous writers have never been observed. The orderings are tested; the lock that would serialise them on a real server is reasoned from Postgres semantics. A known limit, recorded rather than softened: a provider redelivery of a command that already filed is not a strict no-op — it writes nothing and captures nothing, but answers "already on your list" a second time. Silence would need the command's message id recorded on the claimed rows, which it is not.
  • The claim and the write are atomic on a SELF-HOST too (2026-09-23, lib/db/request-scope/with-user-db.ts) — the per-user database scope is transactional only when the enterprise package supplies a scoped connection; on a core-only install it runs on the global connection with no transaction and every statement autocommits. So the claim committed, the list write then threw, and the forwarded link was left stamped as filed, on no list and in no batch — lost, on exactly the deployment with the least operational visibility. A new withUserTransaction opts one unit of work in: under the enterprise path a nested transaction on the same checked-out client, which is a savepoint and still one tenant slot; on core a real begin/commit. The existing scope helper is unchanged, and a test pins that — the cases run on PGlite under both gates, and the core case exists precisely to prove the global behaviour did not move, because every core caller was written against autocommit and making it transactional everywhere would silently change all of them at once. The checkout is held for the whole unit, so the rule is a hard one: database statements only, never a model call or a wait inside it — the tenant pool has three slots.
  • No bare pointer is ever written as an item — one guarded write, FOUR paths, four tests (2026-09-23, lib/messaging/lists/write.ts, lib/messaging/process-session/apply/unclear/, lib/ai/agent/write/list/, utils/constants/lists/reference.ts) — resolving the referent correctly is not enough: the guard belongs at the write, so that no regression upstream — a classifier that starts copying the pronoun, a verdict read the wrong way, a branch added next year — can put "this" on a list again. Every chat list write goes through one helper, which throws rather than returning a failure, because every caller already treats a throw as "nothing landed" and falls through to capture, which is exactly the right outcome — the batch then sees the pointer beside the thing it points at. The four are the fast path; the batch rung, which used to write directly and was a real back door, since an unsure resolver sends the command into the batch on purpose and the planner's note body for it is then just "this" (its refusal keeps the sender's uncut words as an unfiled note and says so in the closing report); the card raiser, which refuses to raise a question whose item is a pointer, on either surface, because answering a card is a write too; and the in-app agent, whose add act answers that the note's text is only a pointer, so the loop asks what to add instead of filing it. Each layer has its own test, and each fails if that layer alone is removed — one test at the bottom would stay green while three callers quietly lost their guard. The board is deliberately NOT guarded: somebody typing "It" into a column meant it, and there is no other message it could mean. A position is a pointer too — "the second one", "the other links", "the previous message": the plus a position plus the thing pointed at, with no word of content. They used to classify as a pointer with words, which skips the 6s race wait and writes the phrase verbatim, so forwarding two links and typing "add the second one to reading" fast enough put "the second one" on Reading. Matching is equality, so "the second book", "this book" and a title that merely begins with "the" are untouched, and a counted plural ("the last two links") is a pointer as well, which is what makes the plural wait apply to it.
  • A link forwarded with NO list named gets offered your lists, not "who was that about?" (2026-09-23, lib/messaging/process-session/apply/unclear/link-offer.ts, lib/repo/lists/queries/recent.ts, lib/links.ts, lib/messaging/pending-question/link-answer.ts) — a forwarded article or repository reached the last rung of the batch ladder and was asked "🤔 Who was that last note about?", which is absurd for a link about nobody. A new rung offers up to three live lists instead, most recently used first — the later of the newest item's and the list's own creation time — with no model call, because the sender named nothing to score against and recency is the honest proxy for where the next link goes. Detection is code and narrow: no subject and no candidate person, and at least one http(s) link by the shared extractor (now one implementation for the bot and the item detail view), and a first line that is not itself a bare pointer, and at least one live list. Anything else — including any note that mentions a person — reaches the last rung exactly as before, and the offer sits below the reminder rung, so a link that asked for a reminder still becomes a general task with no question. It reuses the list-destination confirmation with a null spoken name (the field became nullable for this) rather than a new type, so it is answerable in the inbox and rides every existing exhaustive switch; it carries no "Make a … list" option, and a null or blank name routes to the offer's own wording, so no path can print You don't have a "" list. The answer rule is the part that was quietly eating messages: only a number in range, an offered name, or the exact case-insensitive name of any other live list answers the offer — everything else is not an answer and flows to normal capture, with the card left pending in the inbox holding the link. Before this, any reply of three words or fewer was read as a list name and raised "make a … list?" for it, which meant "call mom", "dentist friday", "remind me friday", "thanks" and even "milk shopping list" were never saved at all. A new list is created from the in-app card, never from a bare chat reply; filing on a person is still the unfiled note's "File this note", and the chat copy says so. Nothing is lost on any path: the words are an unfiled note before the card exists, and expiry, silence or a non-answer leave both exactly as the last rung would have — with zero lists, or on any read or write failure, the ladder is unchanged. Budget: no plan or intent property and no model call — one extra sequential checkout, never two held at once.
  • "Notes to place" can put a note on a list (2026-09-23, components/app/confirmations/NoteSubjectCard/PersonlessAnswers/, lib/repo/confirmations/apply/note-subject-list.ts, components/app/confirmations/ListDestinationCard/CreateListAnswer.tsx, design doc §6.7) — the inbox's "who is this note about?" card offered a person, a company, a group or Dismiss, so a forwarded repository link with two lines of commentary had nowhere honest to go. It now has Add to a list: a Select of the user's live lists with an explicit Add, and the list card's dashed create panel below it, which asks the user to confirm the new list's name — a list is created only by that button. The answer reuses the list card's choice shape ({ listDestination }) and its resolveDestination, and files through fileNoteToList, so the item is exactly the one the chat link offer makes: first line as the item, the rest as its detail, source_note_id back to the note, which is kept and leaves Unfiled (a card with no parked note gets one written first). Reminders stay general tasks; no extraction, no model call; the action is the existing resolveConfirmationAction. With no lists the answer offers only creating one. Offered where the lists are known — the inbox loads them when a note_subject or list_destination card is pending; quick-add's inline card does not offer it. Not on mobile — the Expo app has no confirmations inbox. Tests: lib/__tests__/confirmations/note-subject-list.test.ts (8, real list repo on PGlite; proved to bite by filing a bare item instead of the note — 3 fail). Cross-tenant list isolation is EE RLS, which PGlite cannot run; the test pins the equivalent refusal (unreadable list ⇒ PreconditionError, card stays pending, note stays unfiled).
  • A "Notes to place" or "Which list?" card no longer outlives its note (2026-09-24, lib/repo/confirmations/queue/visibility.ts, lib/actions/confirmations/index.ts, components/app/confirmations/ConfirmationButton.tsx, design doc §6.8). A note that was put on a list from the board or the Unfiled dialog, or filed on a person, company or group there, left its card pending. Pressing any answer then failed with "That note is no longer unfiled.", and production showed that as "Minified React error #441". Two fixes. VISIBLE_IN_INBOX now hides a note_subject/list_destination card whose apply.noteId note fails unfiledOnly(). The card is hidden, not resolved, which is the same approach the predicate already takes for a stale follow_up_date. One predicate covers every filing path and the badge, the row stays pending, and deleting the list item brings the question back. A card with no noteId is unaffected. resolveConfirmationAction now returns its MutationResult instead of throwing it, so the user-safe sentence reaches the card: it is toasted by ConfirmationButton and shown inline on the list answers. Tests: lib/__tests__/confirmations/stale-note-card.test.ts (6, PGlite, real list repo). Removing the new clause fails 5; putting the throw back fails the return test.
  • One name per page in the navigation (2026-10-06, utils/constants/app/nav/{links,mobile}.ts, components/app/AppNav/, components/app/EmptyState.tsx) — Home (was Today), Needs review (was Confirmations), Places & topics (was Entities), Shared names (was Name clusters); the "Recent people" row that duplicated People is gone; the phone "You" tab is "More". Page titles and headings match the nav. EmptyState is single-accent (amber) with no tone prop. nav.test.ts fails on a repeated label, one page with two names, or a row with no route. Routes unchanged; the verb unification for adding a person is NOT done (deferred).

24. Pages & Journal (branch feat/pages, 2026-10-17)

Spec: docs/PAGES.md. Test map: docs/PAGES_TEST_MATRIX.md. Built on the branch and not yet merged; every row below was checked against the code by grep, and the E2E pass is still pending (Rule 12). Web only: there is no mobile Pages, no offline use, no real-time co-editing and no audio.

  • Editor (BLK, INL, EDT: 52 + 21 + 36 rows) — Tiptap v3 page editor with the Notion block set, Obsidian callouts, inline marks, mentions, slash menu, Markdown shortcuts and drag handles. EDT-30 (Notion-style layouts for a database row's page: pinned properties, named sections, modules incl. a relation module as a linked view, tabs, side panel) is built but not yet browser-verified.
  • Shell, navigation, Journal and templates (NAV, 39 rows) — sidebar tree, peeks, trash, templates, daily Journal, and resizable, collapsible sidebars and side panels (NAV-39: drag the edge, fold away with ⌘\ / ⌘., remembered per sidebar; E2E written, not yet run). NAV-13 (the page-info panel adds and edits properties) is built, not yet browser-verified.
  • Linking, backlinks, graph and tags (LNK, 24 rows) — wikilinks, backlinks, people/company/event/group links, local and global graph.
  • Media and embeds (MED, 38 rows) — files in Postgres (4 MB each, per-plan quota), PDF, drawing, canvas, click-to-load embeds, SSRF-guarded unfurl, GIPHY via a server proxy (greyed without GIPHY_API_KEY). MED-32 (the "also delete the file?" prompt, now on Delete forever too, with a "Download files first" zip) and MED-37 (![[Board]] renders a read-only canvas preview) are built, not yet browser-verified.
  • Search, shortcuts and commands (SRC, 37 rows) — page search with operators and regex (1.5 s clock), quick switcher, command palette, rebindable shortcuts.
  • Databases and Bases (DB, 62 rows) — table, board, gallery, list, calendar, timeline, chart, map and feed views; formulas; relations and rollups; buttons and automations (webhook actions run only if the page owner's plan allows). DB-60 (row access rules in the database menu, materialised as page_members grants) is built, not yet browser-verified.
  • Sharing and collaboration (SHR, 42 rows) — per-page roles, guests with RLS, public links with strict CSP, comments, suggestions, inbox, history. Notifications go to Telegram; WhatsApp for Pages is off (PAGES_WHATSAPP_ENABLED = false, awaiting Meta template approval). An invite is held pending until the invitee accepts it, from Pending invites in the Pages inbox or from the email link (signed, 14 days, that invite only), whether or not the address has an account; members of the page's organization and people already on the page are still added at once. The invite email, the inbox notice and the pending list show no page title, inviter name or note (security review 2026-10-19, M4). Request access reaches the owner and full-access members on Dhaga Cloud too. Until 2026-10-19 the hosted reader check ran as the requester, who cannot read the page, so a request said "sent" and reached nobody. It now runs as each answerer, in app code with no DDL or policy change. Proven on real Postgres (pages-access-request.pg.test.ts); the browser spec e2e/pages/sharing/request-access.spec.ts has not been re-run hosted.
  • Import and export (IMP, 21 rows) — Obsidian vaults, Notion export, Markdown, HTML, CSV, Canvas, Word; vault export is never plan-gated. PDF export is a print view, not a server-generated PDF.
  • AI (AI, 20 rows) — page drafts, database autofill, formula writer, "keep updated", Ask Dhaga citing pages (AI-03: built; a live eval on synthetic data ran, see PAGES_ASK_EVAL.md). All metered; none runs without a user trigger.
  • Clip to page (CLP, 5 rows) — browser-extension clip via Readability and turndown, and the Journal "Your day" block.
  • Product-announcement email — one-click unsubscribe, Settings switch, privacy notice 2026-10-20; nothing has been sent (docs/ANNOUNCEMENT_EMAIL.md).
  • E2E verification — pending (still pending on 2026-10-17; this stays unchecked until the E2E runner reports, and the test matrix lists every spec with no result recorded; the guest specs also need E2E_SECOND_EMAIL and E2E_SECOND_PASSWORD, else they skip): the Playwright runner pass and the real-Postgres RLS specs (*.pg.test.ts, excluded from the default vitest run) have not yet been recorded in the test matrix.
  • Not built, by owner decision — multiplayer, audio, teamspaces, custom domains, mobile Pages; see PAGES.md §2.7.

On this page