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-lineadded 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-caseskept 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 —
/featuresis a tablist over the eight capabilities (constants/landing/capabilities.ts), each opening its own fixture-driven pane inFeatureStory/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 underprefers-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. Belowsmthe 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 embeddedAppWindowwith the eight capabilities demoted to copy cards beside it. That made/featuresa 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./featuresstays 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, andogland@xyflow/reactleft the dependency graph. - npm workspaces (
apps/*,packages/*) sopackages/coreis shareable -
apps/web/.env.exampledocumenting 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/eetests, 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/4matrix withfail-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, andverify-without-eeunchanged. Aconcurrencygroup cancels a superseded run instead of paying for both. Two traps the split had to avoid, both silent. Branch protection requires a check literally namedverify, soverifyis 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 runsif: 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,packagesandverify-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-auth1.7.x extracted the MCP plugin into@better-auth/mcp, somcpandoAuthDiscoveryMetadatano longer exist onbetter-auth/plugins— two call sites plus lockstep moves of the root@better-auth/coreandbetter-calloverrides, which is an import migration through the auth config and not a version number.@electric-sql/pglite0.5.8 stops hoisting to the root and nests underapps/webandpackages/ee, leaving the hoisteddrizzle-ormunable to resolve it, so it is held at 0.5.4. The rest are deliberate one-PR-each migrations —@tanstack/react-table8→9,@fullcalendar/*6→7,motion12→13,react-markdown9→10,openai6→7,stripe19→22 (three majors in one step) — plus all ofapps/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 throughexpressandstripe— was pinned to^6.16.0on 2026-09-03 - Test suite (vitest, in-memory PGlite): heuristic parser, export formats, receipts cascade
- Fix Dependabot alert (postcss
<8.5.10via 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
-
LLMClientinterface (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 routesatisfiesits contract (2026-07-07);auth/[...all],stripe/webhook, andtelegramintentionally 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/importis 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#importbookmarks 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
DataTableis 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 wasFilter → 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 —searchFieldOfpromotes 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 asCOMPANY AcmeandTAGS Acmeunder 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 acolumns=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 owngetDb()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>onJSON.stringify(filters)— destroying any open popup and emptying the search box mid-word on each round trip;DataTablenow re-seedsappliedfrominitialFiltersduring render and no call site passes akey. (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 aTABLE_FILTER_COMMIT_COALESCE_MSwindow (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 realComboboxItem, 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 ofnever reached storage —DataTablehardcodedis-one-ofon commit andcommitDraftonly ever readdraft.values— and it said nothing the chips were not already saying. Single-valued columns are declared instead:singleValueFilterson the four admin tables whosepackages/eequeries (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 requiresdebounced === textas well as a changed value); the Filter count badge was invisible in dark mode, where--brand-trust-inkis defined as--brand-trustitself — 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 takesinput-changeonly, which also covers its empty close-sync write); and the clear ✕ was focusable while carrying thearia-hiddenBase 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 anaria-label: Base UI gives itrole="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 areadonly 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 --noEmitclean acrossapps/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 nocompany:acmesyntax and nois not, because nothing in the repo or the client engine can negate; admin tables still take one value per column until EE widens toIN; and the suggestion list is capped atTABLE_FILTER_SUGGESTION_LIMITper 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
phoneNumberplugin is registered (lib/auth/config/plugins.ts) and itssendOTPis wired to Twilio (lib/sms/send.ts), so a code could be delivered — but nothing in the app ever callssignIn.phoneNumber:LoginFormoffers 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 onsmsEnabled(), and the copy names whichever half is missing —PHONE_SIGN_IN_COMING_SOONwhen SMS is also unconfigured (the deploy's state:TWILIO_ACCOUNT_SIDis unset),PHONE_SIGN_IN_UNBUILT_COMING_SOONwhen Twilio is set and only the sign-in path is absent. Ticking this needs the sign-in entry point, not a Twilio key - Every
/apppage + 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 existingThreadMark+ 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. OneSearchPaletteand oneNavQuickAddDialoginstance 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
/approute (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→ newfeedbackrate-limit bucket, 5 per 5 min → zod →withUserDbinsert → email) writes the corefeedbacktable (lib/db/ddl/core/feedback.ts, mirrored indb/schema/feedback.ts, added to EE'sTENANT_TABLES), then notifiesDHAGA_OWNER_EMAILthrough 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 RLSuser_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 separatecount(*), sequential awaits on the one bypass-RLS admin connection) through the shared TanStack-backedDataTablein server mode — no newuseReactTable, per docs/LIBRARIES.md §2, andFeedbackTableis an export of the already-listedAdminTables.tsxso theverify-without-eeremoval 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.tsxfiles) - 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-passwordand/reset-passwordnow render throughcomponents/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 carriespathLength="1", so onestroke-dashoffsetkeyframe 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-inkfloats 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-inkfor accent text, amber strictly as fill), measured at the glow's peak in both modes: every text token clears 4.5:1 and--brand-lineclears 3:1 on the card.prefers-reduced-motiondrops the draw, the pulses and the card entrance./pendingis 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' existingrole="alert"errors gain only a CSS reveal, reached fromauth-motion.cssvia the shell'sdata-auth-cardhook. 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-tablerow models over a single pseudo-column — the card rows host an inline edit form a<table>cell cannot, so this follows theActivityCardprecedent rather than converting toDataTable. Search matches action text and the attached person/company names (debounced via the shareduseDebouncedValue); 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 fromDataTableinto a sharedTablePagination(components/app/table/DataTable/Pagination.tsx) so the five existingDataTablecall 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 carriesAdded <date>(formatDate, neverformatDueDate—createdAtis 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()'sorderByis untouched, and theduedefault 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 viadistinctOptions, hoisted out of the calendar'sfilter-follow-ups.tsinto the sharedlib/filters/follow-up-scope.tsso 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 bycomponents/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 gainedfont-mediumso rank comes from size/weight, not moretext-fog. The page h1 is 32px atsm(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 atsm, so no h2 outranks the h1;home-type-hierarchy.test.tspins this at both phone andsmsizes. The time column of moment rows widened (5rem phone, 4.75remsm) so a 14px10:30 AMorAll dayno longer wraps. Home sits inside.home-lifecycle-theme, so fog there is#5c646dlight /#a7aab2dark: 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 existingsettingstable (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, sharinglib/messaging/availability.tswith 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#messagingdeep 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'sskipMissingElement:TOUR_PRESENCE_WAIT_FRAMESis 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.threeis MIT,tz-lookupis 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 onRetry-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 onlypendingCountmoved, and pauses while the tab is hidden;copy-maplibre-worker.mjsexits non-zero if the worker files are missing. Unit-tested (map-poller,poll-policy,tile-health). Pending: the Playwright spece2e/map-resilience.spec.tsis written but has not been run green (local dev server ran out of memory), andnext buildwas not run.
- Suspense streaming on Home/profile/settings — Home now streams the lightweight
OnboardingGateseparately from one coherentDashboardSection; 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 TanStackDataTable; a manualstarredfavourite flag (contacts column + partial index) with an optimisticStarButtonon 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/savedremains the canonical surface. Pagination audit: People/Events/Admin/Import already paginate via the sharedDataTable(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.tsnames 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
settingskey/value table (keyui_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.tsdocuments why one-mode presets were rejected). Delivered as a<style>block emitted by the/applayout (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 declaredpreload: false, so a user who never opens the picker downloads no extra bytes. Scoped to/app/**on purpose — the marketing site,/blogand/docsare statically rendered SEO surfaces and per-reader colours would force dynamic rendering; they keep the brand.utils/constants/theme/__tests__/presets.test.tsholds 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.parseUiThemedegrades per field, so an unknown id from a rollback loses one choice, not both. Core: nopackages/eefile and nothing to add to the core-only removal list inci.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 inscripts/capture-docs-screenshots.mjsbehind theONLY=allow-list so they can be regenerated after any UI change, and the run restores the account to the default preset in afinally. Verifying it surfaced one real defect, now fixed:components/ui/button.tsxhardcoded 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-amberinglobals.css. Verifying also turned up, and this branch fixes, a pre-existing crash unrelated to theming:/appHome and the confirmations inbox threw their error boundary on the seeded account becauseconfirmationPayloadSchemareused the strict model-facingrelationshipSchemato 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 throughstoredNoteExtractionSchema, which defaults those six fields to null.listPendingConfirmationsalso moved tosafeParse-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 ofapps/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 SAMEsettingskey (ui_theme) as a newcolorsobject, so no migration;parseUiThemekeeps 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 eightTHEME_PRESETSlost their grid (the palettes are the presets now) but are NOT removed: a stored preset still layers UNDER the colour overrides inbuildUserThemeCss, 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) whilepalette-dark.cssdeclares 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 thesemantic.cssalias 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.tsis 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 (theButtonprimitive'swhitespace-nowrap), and the preview clipped "View all" and both pills off its right edge at 375px (a grid item'smin-width:auto). Known limits, inherited on purpose: surface roles apply to light mode only and dark follows only the accent;--brand-panel-2,--brand-lineand--brand-wellare 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.mjsnow drives the six palette BUTTONS rather than the dead radio grid.app-theme-monochrome.pngandapp-theme-rose.pngkeep 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): addingcolorsINSIDEUiThemetook out the whole/appshell withCannot read properties of undefined (reading 'accent').getCachedAppConfigis a per-user cache with no TTL, so a warm entry written before this deploy came back as{preset, font}— present, so the shell'suiTheme ?? DEFAULT_UI_THEMEguard never fired — andisDefaultUiThemeshort-circuits on a non-default preset before it readscolors, soundefinedreachedresolveOverrides, 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. NewcoerceUiTheme(value: unknown)(utils/constants/theme/ui-theme.ts) rebuilds every field with the same per-field toleranceparseUiThemealready 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 inlib/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:FocusedHomeimportsFooterfromClosing.tsx, andClosing.tsximportsSectionHeadingfor aFinalCtathat 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 bycomponents/landing/useLazyGsap.ts: it loads@/lib/gsapthrough a dynamicimport()from an effect, runs the callback insidegsap.context(fn, scope)so scoping and cleanup are unchanged, and does not issue the import at all underprefers-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 undersrc/components/landingmay import@/lib/gsapor@gsap/reactstatically.@gsap/react'suseGSAPpulls gsap in as well — which is howSpotlightCardwas paying for the whole library while running no tween at all; it is a plainuseEffectnow. 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 whydynamic(..., { 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 ofglobals.cssintoapp/styles/fumadocs.css, imported by the/docsand/bloglayouts only — 66,289 of the 320,243 bytesglobals.csscompiles 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-tour1,475,493 → 1,299,840 and/features1,350,472 → 1,174,647 shed both halves,/trustthe CSS half./docsand/blogpay +28,193 bytes, split across two cacheable files of whichglobals.cssis 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 afterglobals.cssits dark block started beating our brand aliases and/docsrendered on neutral grey (--color-fd-backgroundmeasured 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 ofstyles/palette-light.css/palette-dark.cssand intoapp/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 —/,/docsand/blogat 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 itscss/generated/*.css. If a future Fumadocs stops doing that the docs go unstyled with no build error, soapp/styles/fumadocs.cssrecords 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:OnboardingGatemountsOnboardingTouron every Home render purely so/app?tour=1still 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 staticimport, 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 dynamicimport("./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-runneris eager on that route (page →GraphView→use-graph-data→run-layout), and it statically importedfa2-core— ~110 KB of graphology andgraphology-layout-forceatlas2— to back a main-thread fallback that the COEP fix of 2026-08-25 made near-unreachable. The fallback is a dynamicimport()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:HomeBentoSkeletonhad drifted togap-8with tracks[minmax(0,1fr)_minmax(0,0.92fr)_20rem]againstDashboardSection'sgap-6and[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 inpage.tsxgained thespace-y-6the 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.tsxmatches, so a hard navigation's three stages share one rhythm. Measured as the route's client chunk set out of a real production build:/app1992.4 → 1961.7 KB raw, 570.2 → 561.0 KB gzip;/app/graph1921.7 → 1846.0 KB raw, 537.2 → 521.2 KB gzip, with driver.js, graphology and FA2 absent from both. All three are pinned byapp/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): acachePerUser/invalidatePerUserhelper (lib/cache/per-user.ts) overunstable_cache— cache key + tag both includeuserIdand the read runs insidewithUserDb(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 viarevalidateTag(..,{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 acachePerUserVersionedcandidate 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 viacachePerUserVersioned/getCachedFullGraph— version-keyed on the cheapfetchGraphVersion()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 = acacheHandlerinnext.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 toschedulePrefs/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
cachePerUserVersionedpattern, behind a RediscacheHandlerfor shared multi-instance self-hosting — graph payload already done; seeSCALING.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 insideDataTable(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) -
contactstable -
companiestable -
events+event_contactstables (M2) -
notestable (kind: voice|text|photo|capture_source, body) -
factstable (type, text, confidence,source_note_id,deleted_at) -
edgestable (src/dst typed, predicate,source_note_id) -
embeddingstable (pgvector, 384-dim, receipts via owner_type/owner_id) -
follow_upstable -
ai_actionsmetering 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 areON DELETE CASCADE— a plainREFERENCEShere is RESTRICT and would makeforgetContactthrow on its final delete, rolling the whole erasure back. A derived cache rebuilt from the window/app/planreads, 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 becauseCalendarEvent.attendeesstopped beingdisplayName ?? emailand became{ email, displayName }(packages/corecalendar/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") —
forgetContactcascaded the chain, but deleting a contact that had produced relationship suggestions was blocked byedge_suggestionsRESTRICT 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
deleteNoteitself (2026-07-07, was only in the action layer, so any other caller skipped it);graph-receipts.test.tsnow asserts embeddings are gone, not pushed - Tags carry receipts too (
contact_tag_sources) — extraction used to union tags into thecontacts.tagsarray with no provenance, sodeleteNote/clearNoteDerivationscould never revoke them. One row per (contact, tag, source note);source_note_idNULL = 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 byforgetContact; EE addscontact_tag_sourcestoTENANT_TABLES -
groups+group_members+note_contacts, and agroup_idonnotes/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_memberscarriesjoined_atandleft_atinstead 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_contactsis 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 fromgroup_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. Everycontact_idisON DELETE CASCADEfor the same load-bearing reasoncalendar_event_contactsis: a plainREFERENCESis RESTRICT in this repo andforgetContactends in a bareDELETE FROM contacts, so a RESTRICT reference would abort and roll the whole erasure back.notes.group_idandfacts.group_idare 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 noteassertSingleOwnerforbids, or cascade away what the user wrote about several people at once; the argument is restated inlib/repo/groups/index.tsso the next reader does not "fix" it.follow_ups.group_idisON DELETE SET NULLand is not an owner — a follow-up already permits several nullable associations on top of a bareuser_id. All three tables are in EE'sTENANT_TABLESwith 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, readingpg_stat_statements, putgetGraphStats(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, soevents (created_at),notes (kind),edges (deleted_at)andfollow_ups (status)ship;contacts (source)serves the "promote a mentioned stub" lookup on every contact save (22,300 calls); and a partialcontacts (reach_out_every_days) WHERE … IS NOT NULLserves the two catch-up reads. They live in the DDL, not a one-offpsqlrun, 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 sharedDATABASE_URLand 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 — productionEXPLAIN (ANALYZE, BUFFERS)shows they are picked, and the remaining cost is the RLSORpredicate, 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);listAttachmentsreads 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 with413 FUNCTION_PAYLOAD_TOO_LARGEbefore 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 fromMAX_ATTACHMENT_BYTESrather than restating a number.POST /api/attachment(multipart — a server action body is capped at 1 MB) andGET/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 alwaysapplication/octet-stream+Content-Disposition: attachment+nosniffso a stored file can never execute on Dhaga's origin. UI is ONE component for both pages (components/app/attachments/, owner props union likeEnrichButton/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.tsselects each file's id, owner,fileName,mediaType,byteSizeandcreatedAtand deliberately NOTdata_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/archivecloses 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 holdingdhaga-export.json(byte-identical to/api/export/json) plus every stored file atattachments/<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 throughgetAttachment(id), each inside its own short-livedwithUserDbtenant scope, and the writer is driven from the stream'spullrather thanstart, 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 througharchiveEntryPath— 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) butmaxDuration = 300does 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 ownexport_archiverate-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 escapeattachments/, 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
createContactActionredirected 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.ContactFormnow takeswithNote(default false) and renders one optionalTextareaposting a top-levelnotefield — a sibling of the existingsourceText/eventIdfields, deliberately not folded into the hidden JSONpayload, 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 hasAddNoteFormon 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 verbatimsourceTextcapture 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 bycomponents/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/appscreen; the fuller/app/quick-addpage 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 passedevents={[]}with nodefaultEventId, 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 fromGET /api/eventswhen the dialog opens (AppNav/useCaptureEvents.ts+lib/capture-events.ts), not resolved in/app/layout.tsx: the layout'sPromise.allalready sits at the three-connection tenant-pool ceiling, so threading it through there would have put alistEventsround-trip on every/appnavigation 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.defaultEventIdis 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, becauseEventPicker's<select>is uncontrolled — adefaultEventIdarriving 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 bylib/__tests__/capture-events.test.ts. (thehomeEvents()request memo the fix was expected to reuse is dead —HomeDockno 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 bottomSheet(AppNav/CaptureMethodSheet.tsx) listing Manual · Paste text · Scan visiting card · Upload file · Record voice, and the chosen entry is handed toQuickAddFormasinitialMode/initialSourcekeyed onentry.key— the same remount contract/app/quick-adduses, so a second choice actually applies instead of silently reusing the first surface. The last three rows are spread fromHOME_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 byAppNav/capture-methods.test.ts. Superseded by ONE flat method chooser (2026-08-25) — the phoneSheetabove 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.tsxis deleted: the phone's centre+, the desktop rail's Capture, the Home Capture button and/app/quick-addnow 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 flatCAPTURE_METHODSconstant (utils/constants/capture-methods.ts).MOBILE_CAPTURE_METHODS,CAPTURE_MODES/CaptureModeOption(the pill-strip vocabulary) and the whole ofutils/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 aCaptureSurfaceHeadernaming it and carrying one ← All methods link back; there are no mode pills and no manual sub-tabs left anywhere.QuickAddForm/capture-mode.tsbecame the directorycapture-mode/(index.ts+types.ts+modes.ts+methods.ts) under the 150-line rule, and everyCAPTURE_METHODShref must normalize back tocaptureEntryFromMethod(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.tspins the round trip for all seven. A bare/app/quick-addresolves 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 theCAPTURE_METHODSdoc 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 oldcamerasource shipped with. The in-dialog Voice/Camera/Upload dock row is gone, the chooser owning those destinations;QuickAddDocksurvives 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 inapps/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, soquick-add-manual.pngandnav-quick-add.pngon 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 sharedCaptureSurfaceHeader'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 blankContactFormand 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).QuickAddManualnow 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_parsecall that parses contact fields (no extra AI round-trip;captureExtractionSchemafolds anisNoteAboutPerson/subjectName/noteBodyclassification 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-textcontacts.location(where they ARE), Add to event writesevent_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, passingmode: "overwrite"through the same action. Both writes are ONE statement over the whole selection (setContactsLocationUPDATE,addContactsToEventINSERT … SELECT in a data-modifying CTE), never a loop ofgetDb()calls — the tenant-pool fan-out of PRs #60/#96 — and both select throughcontacts(andevents), 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.locationis free text and the map geocodes lazily fromgeocode_cacheinGET /api/map'safter()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 — thecompaniestable has no location column andevent_contacts.contact_idFKscontacts, so there is nothing to write; its bulk bar stays Merge + Delete. Covered bylib/__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(orattended) affiliation. Built 2026-07-28 (web); mobile parity pending - Company auto-link: extracted company name → find-or-create
companiesrow - Jobs editor — create a new company inline when there's no match (reuses EntityCombobox create +
findOrCreateCompany) - Companies management page —
/app/companieslists 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/duplicatesclusters 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_aliasestable, 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 (findOrCreateCompanyalias 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:CompaniesTablerendered its name as a plain<span>, andRelationshipRowsent 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 acache()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 offpositions.is_current, withrelationseparating employment from studied/interned/board) → Relationships → Follow-ups → Facts → Notes → Timeline. Schema is expand-only:notes.company_idandfacts.company_id(nullable, FK → companies ON DELETE CASCADE, partial indexes mirroring the contact-side ones) plusALTER COLUMN facts.contact_id DROP NOT NULL— the same wideningnotes.contact_idtook for entities andfollow_ups.contact_idtook for company tasks. Nothing dropped, no rewrite. A note/fact belongs to exactly ONE owner, app-enforced byassertSingleOwner(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 onfacts_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 theselected-note/primitives were reused, not forked. Extraction on a company note writes company-scoped facts and follow-ups, each carryingsource_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 existingnote_extractionentry point (a nestedwithAiActionjoins 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):embeddingsgained a nullablecompany_idandcontact_iddropped its NOT NULL (expand-only ALTERs indb/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 sameassertSingleOwnernotes 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/SearchHitcarry akind(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/eeadds an explicitWITH CHECKonembeddingsso 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 withDHAGA_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, becauseembeddingshas 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/ExtractionStatusandlistRecentExtractionJobsare keyed by contact id, so company facts appear on the next revalidation, not as they land — and/api/companies/[id]/factswas therefore not built, since with noonFactstrigger it would be dead code); photo/voice notes are person-only (addCompanyNotetakes akinddefaulting to"text"since company enrichment needed to save anenrichmentnote, but no capture path passes a photo or voice kind); and merge stays on the list/duplicates pages, sinceCompanyMergeDialogresolves fields between ≥2 companies and a detail page has only one id. (web only — mobile parity pending; typechecked, lint-clean and unit-tested vialib/__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 sameenrichmententitlement (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 anenrichmentnote on the COMPANY, then a company extraction pass that writes company facts unverified, each with itssource_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 openwithAiActionscope). The company arm fires its own worker POST from the button (EnrichButtonis now an owner union) because the company page has noFactsPanelstreaming 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-creditenrichmentaction. 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 (connectorstable, AES-256-GCM via the existinglib/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 ordinaryenrichmentnote 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):apolloover REST with a pastedx-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 — andapollo_mcpover Streamable HTTP athttps://mcp.apollo.io/mcp, which is OAuth 2.0 with no API key at all (PKCE + RFC 8414 discovery + RFC 7591 dynamic registration, inpackages/core/src/connectors/mcp/oauth/), with the callable tool set PINNED at configure time plus a name heuristic that refusesemail/send/create/sequenceeven 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):connectorsGateReasonguards STORING a credential and checks theconnectorsplan feature alone, because saving a key costs nothing and greying it on a credit-exhausted account would refuse a free action;connectorEnrichGateReasonguards 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. NewconnectorsPlanFeature (Pro/Power/self-hosted, not free) and job kindconnector_enrichment;extraction_jobs.connector_idmakes 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 reuseprovider_auth, which points at the operator.lib/connectors/providers.tsis 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 inpackages/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/importand 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 bycsv-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 auser_idmerge into one person (union of distinct values); email cells split on,;and whitespace (unparseable text is kept in a custom field, not dropped);phonebecomes "Work" andmobile"Mobile";profile_urlbecomes 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).industryandchapterbecome tags, written through the tag tables (recordTagSourcesForContacts, pinned like a hand-added tag) — no import had ever persisted tags, andcontacts.tagsis a cache so it cannot be written directly. Company description is a custom field + receipt, notcompanies.blurb(that column is documented as web-lookup-only). Sector (2026-09-30):industryalso 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 intogeocode_cacheat the end of a write run (best-effort, offline gazetteer, insert-only; seefeat/geocode-on-write), andapps/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, ordata/contacts/items/results/… wrapper; nested keys flattened toaddress.city),.jsonl/.ndjson, plus.vcfas before; detected by extension AND content. Legacy.xlsis 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 onechoicequestion 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 noJEV_API_KEYsends 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 headedindustryorsectoralso 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, samesectorForIndustries); anything else, and a generictags/categorycolumn, leaves the sector empty. Tests:universal-import-sector.test.ts. Unverified: no live Jev call has been made for this (thechoicequestion, 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/importviaparseContactsVcard, phone-dedup makes re-import safe for email-less device cards; covered byvcard-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
linkSocialgrant +getAccessToken, provider gatewaylib/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, andgetAccessToken({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_atand 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'sencryptOAuthTokens. 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 toneeds_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 toneeds_reconnect, which is exactly the revoked /ACCESS_TOKEN_SCOPE_INSUFFICIENTcase. 403 deliberately still is not, and cannot be from where the error is thrown:isRateLimited403consumes the body that separatesSERVICE_DISABLEDandRESOURCE_EXHAUSTEDfrom a permission failure in order to decide retriability, so by the time aSyncHttpErrorexists 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 byname-clusters.test.ts. Moved from/app/groupsto/app/name-clusters(2026-08-23) — it clusters strings and produces tags and company links, never a group of people, and/app/groupsis 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 inutils/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 owndata-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 nodata-touranchor 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)skipMissingElementwas 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-elementat the viewport centre and the popover floats over blank page with the counter still counting it. Steps are now filtered against the live DOM beforesetSteps, which keeps the progress count honest and reads the gate matrix (no billing tab on core, no credits ledger withouthasLLM(), plan-gated messaging link) off what actually rendered rather than duplicating it as constants;skipMissingElement: trueis set as well, for an anchor that vanishes mid-leg. (2) A missed anchor killed the seen-flag forever — the old wait returned after aconsole.warn, so for the first step no driver was ever constructed,onDestroyednever fired,markOnboardingTourSeenActionwas 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 viafirstVisible, notquerySelector. (4) Home waited for nothing, thoughOnboardingGateis deliberately cheaper than the streamedDashboardSection, so the tour could start before Moments or its navigation anchors existed; both legs now share one machine that waits. Visibility is tested withgetClientRects().length, notoffsetParent !== null, which is null forposition: fixedand would hang on the fixed desktop rail or phone bar. Entry isresolveTourEntry(pure):/appforwards an unseen user to/app/settings?tour=1and continues the Home leg on asessionStoragehand-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.tsdrive the machine headlessly through a fakeTourEnv— the three seen-flag tests were confirmed failing against the old behaviour first — andutils/constants/onboarding/steps.test.tsresolves every step selector against the realdata-tourattributes on disk and every hash againstSETTINGS_HASH_TO_TAB.e2e/auth.setup.tsfollows 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 byFirstRunExamples.test.ts). The capture chooser opens on three methods (Scan, Voice, Paste) with the other four behind More ways, from oneprimaryflag on the single flatCAPTURE_METHODSlist; 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 specsai-gate.spec.tsandcapture-chooser.tsstill 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 (fromapp/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 keyonboarding_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, signednext(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; waslib/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 toContactProfile→POST /api/importwith the per-userx-api-key(v1.1, honors the mobile/web parity rule);apps/mobile/src/{app/import.tsx,components/contact-import/,lib/contacts/map.ts}+ web routeapps/web/src/app/api/import/route.ts; mapper covered bydevice-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/pendingafter it exists — socurrentPlan()answersfreefor 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 insignup-hooks/on-user-created.tsis 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-userwelcome_email_sentsettings key, never throws (logs PII-safe fields instead). The signup hook no longer sends the requester a second "request received" mail —notifyOwnerOfAccessRequeststill tells the admin queue. Covered bywelcome-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 asEntityCombobox/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 Marsub-line via the sharedformatDayMonth, 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-addand behindGET /api/events, so there is no search route. Home's capture dock is the exception: it reuses the dashboard'shomeEvents()memo, which islistEvents(HOME_PREVIEW_LIMIT)— 5 — so typing there searches only those five and anything else falls to "Create event …", whichcreateOrReuseEventthen 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 hiddeneventId/newEventNameinputs (one or the other, never both; neither for None) socreateContactAction's FormData contract is unchanged, and that action now callscreateOrReuseEventso typing the name of an existing event attaches to it instead of minting a duplicate. Mobile parity pending:apps/mobilestill 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/groupslists 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 atMEETING_GROUP_SUGGESTIONS_VISIBLEwith a "show more", each card carrying an editable pre-filled name, untick-ableoccasionalMembers, 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 withjoined_at, add/remove (remove stampsleft_at— the copy says past notes are unchanged), a shared-note composer whose "who was there" tick list becomesnote_contacts, the group's notes spined with the ONEnote-accentsmodule, group-level facts, group tasks and recent meetings. Rename + archive/unarchive only — there is no delete, deliberately (lib/repo/groups/index.tscarries 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 inlib/actions/groups/, all throughmutation(). Saving a shared note queues its extraction and the page DRIVES it:loadGroupPagereads the group’s own jobs andGroupExtractionStatusstreams 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 sameaddNoteActiontheir own page uses. It has to be a per-person note rather than a group-less shared one becausenotes.group_idis a shared note's owner andassertSingleOwnerforbids 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/planalready loaded reduces to a signature — its sorted matched-contact ids; a signature seenMEETING_GROUP_MIN_OCCURRENCES(2) times is recurring, once is one-off, and sizes outsideMEETING_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 asoccasionalMembers, 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, matchingname-clusters.tsverbatim ("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 tosettings(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_contactsis a disposable cache, written from anafter()on a/app/planrender 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 durablemeeting_attendancetable, 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.tsselectsattendeesand 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 withlistBusyand noreadEventssees 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 existingCalendarFullAccessUpgradeinside 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 islib/calendar/limited-connection.ts, which also took ownership ofcanListCalendars(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 NULLsource_note_id— hand-added facts, manual follow-ups, optimistic rows — never highlight and are not clickable at all; that rule is the pureisFromNote(components/app/contact/selected-note/selection.ts, covered byselection.test.ts). Highlight is a ring and a tint from the amber token plusaria-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_idhad been stored since M4 and governed the deletion cascade, but nothing ever showed it:FullGraphEdgedid 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 sharedEdgeReceipt(components/app/relationships/EdgeReceipt.tsx) that mirrorsFactItem'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-joiningnotesinlistNodeRelationshipsand 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: aworks_at/affiliationedge carries its backingpositions.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":nulland +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 theGRAPH_TIER3_EDGE_TRIPWIREof 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. AndGRAPH_PAYLOAD_IDB_KEYwent tofull.v2because 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 noSelectedNoteProvider, 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.addFacttakes aFactOwnerunion ({contactId}|{companyId}) guarded at runtime by the sameassertSingleOwnernotes and embeddings use,addFactActionreads 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, andupsertEmbeddingfiles 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 sameFactListClientfor 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 bylib/__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 throughaddFact,TaskInput/createTaskandcreateRelationshipEdge, so every existing manual caller still writes NULL. Built on a reusableuseTextSelectionhook (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 adata-note-bodyattribute 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 inNoteList, 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 throughcreateTaskAction, the only follow-up write that stampsuser_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.addFactnow takes a{contactId}|{companyId}owner andupsertEmbeddingindexes under either, so a company fact writes and is searchable;FactListClientshows "Add fact" on the company page. What is still contact-shaped is THIS surface:FactFromSelectiontakes acontactId: stringand 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 bylib/hooks/text-selection.test.ts,selected-note/selection.test.tsandlib/__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_extractionjob for the same note; idempotent (worker'sclearNoteDerivationsreplaces 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_idis 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'fromaddFact/addFollowUpandcreateTaskAction, NULL everywhere else, andclearNoteDerivationsspares 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.deleteNoteis deliberately unchanged — deleting a note still retires what was lifted out of it, which is what both dialogs promise. Covered bylib/__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 itssource_note_id— facts, follow-ups, positions, confirmations, edge suggestions and tag receipts (moveTagSourcesToContact) — plusextraction_jobs/card_imagesbynote_id, the contact-typededgesendpoints only (a company or third-person endpoint is never touched, then self-edges and duplicates of this note's edges are dropped), and the denormalisedembeddings.contact_idfor 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_idare recomputed on BOTH people (computePrimaryDenorm);capture_sourcereceipts, entity notes, a missing target and a no-op onto the current owner are refused with a user-safePreconditionError. Surfaced as a third control on the note row (MoveNoteButton, a dialog over the sharedEntityCombobox) viamoveNoteAction. Covered bylib/__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_sourcenotes (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, andNoteListhides the delete button for them (the reprocess button was already gated byREPROCESSABLE_NOTE_KINDS, which droppedcapture_sourcein the same change). They're also excluded from the dashboard's headline Notes count and activity sparkline (USER_AUTHORED_NOTE_KINDS, read ingetGraphStats/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 kindtextwith the channel innotes.source(api/capture/handlers/text.tsandlib/actions/contacts/create/create-contact.ts, which still writes a receipt when the save carries card photos), leavingcapture_sourceto what no human typed: import receipts (lib/repo/import.ts—hasLinkedinImportSincestill 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_KINDinEXTEND_DDLfrees 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 byapi/capture/__tests__/capture-note-kind.test.tsandlib/__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 anabout(a name as written in the note), resolved against only that note'snote_contactsset — a closed list of three to six people, never open-set resolution over the graph. Unambiguous → written to that contact with the ordinarysource_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 existingsubject_resolutiontype 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 plainapplyExtraction— identical receipts and embeddings to a note captured on that person's own page. Noaboutat all → a group-level fact (facts.group_id) or task (follow_ups.group_id), shown only on the group page. Three decisions worth keeping:aboutis.nullable().default(null)and never.optional(), because.nullable()keeps it in the JSON schema'srequiredlist (the strict-mode contract) while.default(null)makes it absent-tolerant on parse, so aconfirmations.payloadwritten before the field existed still reads — the failure that once took out the whole/appinbox; enforcement lives in code after parsing, with nosuperRefineand no required field, because #245 came from enforcing a rule in the prompt and schema instead of in code, and the prompt says outright thataboutnever 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.aboutis omitted from the company-note and unclear-note schemas, which have no room to choose from. The single-contact path is untouched —applyExtractionnever readsabout; this is a superset, not a rewrite. Tests:lib/__tests__/shared-note-attribution/(partition.test.tsasserts every item lands in exactly one of the three piles, never zero) andpackages/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:
createExtractionJobtook only a contact or company owner, soapplyGroupNoteExtractionhad no production caller and the whole attribution path was unreachable code. Givingextraction_jobsa group owner (guarded byassertSingleOwner), agroup-notebranch in the worker's dispatch and acreateExtractionJobcall 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 mountedExtractionStatus, whose stream POSTs/api/jobs/extraction/runper 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 ongroup_id, never on members — one pill on several pages, each able to fire the worker, is not a smaller version of this bug), read byloadGroupPageintoextractionJobs, rendered byGroupExtractionStatuson/app/groups/[id]— mounting it is what runs the jobs — withGET /api/groups/[id]/extraction-statusas the fallback a detached second tab reconciles against.ExtractionStatusmoved tocomponents/app/extraction/and takes anExtractionScope({kind: "contact" | "company" | "group", id}—companyjoined it when the company page gained its own status route and surface, PR #274) instead of acontactId, which is what makes the absence of a driver visible: an owner with no status route cannot be named inextractionStatusUrland does not compile. The status route runs auth first and wraps its read inwithUserDb(userId, …)— the group id comes from the client and may only narrow within the session's own data, and a baregetDb()on anx-api-keyrequest 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 inGroupNoteList), and nowhere else: a member's page withholds it because it cannot drive a group job, and the action re-checks the client'sgroupIdagainst the note's own before filing anything. Failure stays honest — a failed or blocked run never emits thedoneevent that produces "extraction finished — N facts added", and an undriven job still reads "Timed out — retry." rather than as a quiet success. Idempotent becauseclearNoteDerivationsclears bysource_note_idand 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 queryextraction_jobsthemselves — they save a note and drive the REALloadGroupPage, since a helper reading the jobs table directly would pass straight through the bug it exists to catch - A relationship with no
aboutat all is not written — the one relationship outcome still dropped.edgeshas 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.
tagscarriesabout, so a tag the note names a member for lands on that member through their ownapplyExtraction(findOrCreateTagIds→contact_tags+contact_tag_sources+ thecontacts.tagscache), one it names nobody for lands on the meeting in the newgroup_tags/group_tag_sources(lib/repo/groups/tags), and an unplaceable one raises the SAMEsubject_resolutionconfirmation the facts do — never a second mechanism, and never fanned out to the room. Receipts mirrorcontact_tag_sourcesdown to the coalesce'd unique index, so a re-run cannot double-record andclearNoteDerivations/deleteNoterevoke exactly what one note vouched for.unwrittenlost itstagskey entirely rather than reporting zero, so a stale reader fails to compile. Driven end-to-end (not hand-called) inlib/__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_contactsis the closed listaboutresolves 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 sharedPersonPicker— the same control the group's "Add someone" uses, so quick-create reusesquickCreateContactAction, which PROMOTES a matching "mentioned" stub rather than minting a duplicate — and picking somebody links them to that note only, asnote_contactswithsource: '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 ofnote_contactsrather than of this screen. Guest ids ride the same hiddencontactIdsJSON field as the ticked members plus aguestIdsfield 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 becauseaddGroupMembersis ON CONFLICT while a note save is not. A guest is attributable exactly like a member — that is the point — so narrowingprocessGroupNote's room read togroup_memberswould silently reattribute their fact to the meeting. Driven end-to-end inlib/__tests__/group-extraction-driver/guests.test.tsandguest-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.tsmeasures the plan at 32 of the 32-property compiled-grammar ceiling, and a 33rd property is the400 compiled grammar is too largethat has already taken inbound messaging down for every user. So the fix adds zero schema properties:unclearwas widened in PROSE (descriptions are prose to the model and cost the grammar nothing) into the plan's catch-all, andsubjectNamebecame a discriminator resolved in CODE — null ⇒ about nobody, a name matching one of the user's own companies ⇒ that company, anything else ⇒ ask.applyUnclearruns that ladder in order: a matched company getssaveCompanyNote+ its reminders (verdictcompany); a subject-less note that asked for something writes general tasks (contact_id/company_idNULL, so it lands under /app/plan's General scope) and raises no card (verdictgeneral), 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, mirroringfindOrCreateCompanyminus 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:markUnaccountednow parks a dropped seq's own text as anote_subjectcard 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" — thoughnotes.company_idhas always been one of the four owner columns andlistCompanyNoteshas always rendered them; and the capture log printedseq + 1over 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, onejsonb_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, whichdollar-capcannot see at all becausegpt-transcribebills 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 getssubjectName: 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 withoutANTHROPIC_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,duplicatePlanSeqsflagged it,contestedNoteSeqsstripped it from both, and the whole message was stored on nobody with the Saturday 02:00 reminder silently never created.batchPlanSchemagained a third claimant,meetings, whosefollowUpscarryabout— the only place in the plan that does, becauseaboutis what puts B's fact on B — andplannedItemSeqs/duplicatePlanSeqs/withoutContestedSeqsall learned it. Missing any one either stamps a group-owned sequnaccounted(a bug reported to the sender) or writes the message twice, and thewithoutContestedSeqshole is silent: the object spread carriesmeetingsthrough 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 SAMErouteIdentitya planned person does, so the planner PROPOSES the room and code DECIDES it; the group is then matched, never created, byfindGroupByMemberSet— ONE query,GROUP BYwith twoHAVINGclauses, over members withleft_at IS NULLin 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 innotes.source, the messages' own words) withsourceItemIdthreaded throughcreateSharedNoteso the partial uniquenotes_source_item_idxmakes a re-driven batch write one note rather than a second copy, stamps every messagegrouped("Added to a group", distinct fromattachedon purpose) and runsextractAndApplySharedNoteINLINE — nevercreateExtractionJob, whose group-owned rows are only ever drained by a mountedGroupExtractionStatusand would sit pending fifteen minutes inside anafter()before reading "Timed out — retry." No match ⇒ the meeting's follow-ups are written IMMEDIATELY as contact-less general tasks (dated,due_has_timeset, mirrored onto the connected calendar) and only then is ONEnote_subjectcard 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 themeetingsstrip 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, fromlistGroupsContainingAny— ONE query, groups holding at least ONE of the room, most-overlapping first — plus the resolved room as removable chips and the plan’sgroupNameprefilled. 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 —peopleisConfirmationOption[]and they have no id, so their name is dropped and the user retypes it in the composer (apendingNames: string[]besidepeoplewould 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/startor 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-streaminganswerSearchQuery(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.tspins that schema at 32 of the measured 32-property compiled-grammar ceiling, where a 33rd is the400 compiled grammar is too largethat 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 ruleapply/identity/route.tsalready states): the classifier returns two bare strings copied verbatim from the user, andresolveExistingContactdemandspool.length === 1 && isExactNameMatchover the samefindRelationshipCandidatesquery quick-add and the planner already route on.same_personwhere 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.isTouchedByBatchbounds a correction to the contacts one recent batch named (MESSAGING_CORRECTION_WINDOW_MINUTES= 30, measured offprocessed_at— the sender is correcting the summary they were sent); and merge additionally requirescreated, becauserecent.entriesalso holdsattachedverdicts 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.outcomehas always stored{contactId, contactName, noteId}beside acreated/attachedverdict, 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 dormantmessaging_pending_questionstable 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 regressionbatch_planexists 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 callassertAiBudget: 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 60smaxDuration, 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 whenhasLLM()is false, so a core-only self-host pays neither the model call nor the recent-save query. Every failure path returnscapture— 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.tsandpackages/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-lessfollow_upsand stopped: no contact, no note, no card. Thegeneralverdict recorded that reminders were kept and not which, so a correction had nothing to re-point. The unblock was one field:generalTaskIdson the storedoutcomedetail — ids, never actions, per that detail's own contract (ids and names, never note bodies). With ittoEntrymaps ageneralrow to a third entry kind, and the correction re-points exactly those rows onto the resolved contact throughassignTasksToContact—contact_idonly, a sibling ofattachTasksToContactrather than a call to it, because a general task has no note and stamping asource_note_idthat 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 andscheduleTaskWriteOut'srequireUserId()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 overfindRelationshipCandidates, 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" leaveswrongNamenull. 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. Ageneralrow 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): withJEV_API_KEYset, TypeSafe's Jev answers a confidentcaptureorunclear(confidence ≥ 0.9) with no Claude call; alist_addwhen the message names exactly one existing, non-archived list whose name does not end in "list" (code fillslistName,lib/ai/message-intent/list-name.ts); and asnoozethat states exactly one duration code can read ("in 10", "10 mins", "an hour" —lib/ai/message-intent/snooze-minutes.tsdoes the arithmetic, still one Jev call) or, failing that, a confident next-morning — one Jev call carries both questions, soaskJevruns 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 withoutJEV_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_questionswas built for exactly this flow and abandoned, with its table, its(provider, external_id, created_at)index, itsTENANT_TABLESentry 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/startand 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 ismatchPendingAnswer— whole-message equality on the number or the label, for the reasonconfig/done.tsdocuments, 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 explicituserIdinsidewithUserDb, because a provider webhook has no session andresolveConfirmationAction’srequireUserId()would throw — the same trapscheduleTaskWriteOutset for the general-corrections path. Likecorrection/parked.tsit 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 —extractAndApplyNotehad always taken an explicituserId— 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.retireCaptureTasksnow takes the acting user as an argument the wayscheduleCalendarWriteOutdoes, 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 actionnote_extraction_chat, priced 0 and gated on the inference-dollar ceiling ALONE rather thanassertAiBudget, which would spend a burst token a sender mid-batch needs for the capture itself; theai_actionsrow 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, becausemaxDuration = 90belongs to the whole provider DELIVERY: the route loopsawait handleInboundMessageover an arraywhatsapp-client/parse.tsflattens with no cap, andraiseOpenQuestionopens 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 andchatAnswerExtractionBudgetMs()hands out what remains afterMESSAGING_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:meetingsrefuses a single attendee, and theunclearrung’sfollowUpswere 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 sharedfollowUpSchema.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.tsnow asserts the count is UNCHANGED rather than merely under the cap, since<=cannot tell a deliberate spend from an accidental one at zero slack.resolveDatePhraseremains the only thing that turns a phrase into a day anddue_timethe 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, bypackages/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 underpeople(measured:unclearandmeetingsboth empty), and a person in the plan has nofollowUpsfield 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_upsfor a named person, andunclear.followUpsfor a commitment about somebody the plan will not name (the reported message said "her"). Both hold over three phrasings, withdue_hintverbatim ("tomorrow at 3pm", "today", "thursday"),due_timeset 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 ingeneral-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 isMESSAGING_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 allexpires_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 inutils/constants/messaging/question-replies.tsas pure builders and never echoes the note body; the two replies after an answer are imported fromcorrection/repliesrather 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/startstill 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_MS90s minusMESSAGING_INVOCATION_RESERVE_MS25s) — 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_idis never read (TelegramUpdatedoes not declare it), and the per-message idempotency key that already existed —chatId:messageId, built intelegram-client/parse.tsunder the comment "globally unique for idempotency" — had exactly ONE consumer,appendSessionItemon 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 tomessaging_conversations.turnsand 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 oneinsert … on conflict do update … whereagainst a newmessaging_inbound_deliveriestable — a ROUTING table likemessaging_identities(written before any tenant scope exists, so it declares its ownuser_idand stays out of EE'sTENANT_TABLES), appended to the existingMESSAGING_DDLchunk 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 pastMESSAGING_DELIVERY_RETENTION_DAYS= 7 on aclaimed_atindex. 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 onclaimed_atalone whether or not the claim completed, since an incomplete row that old is already re-claimable and a predicate readingcompleted_atwould 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 (anafter()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) andmessaging-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 backgroundafter(), on the same invocation. THE SPLIT IS AT THE CLAIM, NOT PER BRANCH. Only the identity lookup andclaimInboundDeliverystay 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 intoclaimInboundMessageandhandleClaimedMessage, withhandleInboundMessagekept 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 90smaxDurationby 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.handleDonecloses 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 reorderingwaitForLateArrivalsexists to repair ACROSS deliveries. So the whole loop moves, in one piece, in arrival order. The clock still measures the invocation.maxDurationruns from the moment the request arrived and covers theafter()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, becauseinvocationTimeLeftMs()is trusted absolutely by the agent loop and an over-reported clock does not degrade, it overruns.withInvocationBudgetis therefore opened inside the background pass with the ack's own elapsed time deducted fromMESSAGING_INVOCATION_BUDGET_MS, through thebudgetMsoption/api/search/askalready added. Letting the callback inherit an outer clock would also work — Next binds the ALS snapshot at theafter()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 theafter()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), andmessaging-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) —directivehas 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 inplannedNoteSchema.directives,unclearNoteSchema.directives, the planner prompt, and quick-add's ownisInstruction(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 intolib/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 oncreatedbecause 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-inkhalf, which aliases its base in dark, so every class here is correct in both themes and must never grow adark: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, viagroupNamesByIds, a read that filters on neitherleft_atnorarchived_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 overNoteSource, so adding a channel without deciding its colour is a compile error. Search receipts were changed in the same pass: the keyword UNION now projectsnotes.sourceasnote_sourceand it rides to the gateway asSearchIndexResult.matchSources, an optional array index-aligned withmatchesappended only bypushMatchso the two cannot drift; the unaccented rule becamebg-linerather thanbg-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 projectsnotes.sourceand notnotes.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/mobilehas no note list and no endpoint that returns notes, and every note it creates is stampedsource: "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 wherecontroller.enqueuethrowing after the user navigated away marked an already-successful job FAILED. Covered byuseExtractionStream/{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 iOSSFSpeechRecognizer/AndroidSpeechRecognizer, 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 moreStructuredOutputErrors; the re-run failed a NEW way instead. Measured, not theorised: the quarter-hourly backlog drain claims its job insideGET /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, ZEROai_actionsrows — so the model call never returned and nothing was billed — and theextraction_jobsrow leftstatus='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. Bothapi/jobs/extraction/runandapi/jobs/tickcarriedmaxDuration = 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 guardedpending -> runningUPDATE that also incrementsattempts, andclaimExtractionJobonly ever admitspending, so a killed invocation orphans the row with nothing able to reclaim it. The drain now asksinvocationTimeLeftMs()forEXTRACTION_BACKLOG_JOB_BUDGET_MSbefore every job — re-asked per job, because the loop is what spends the clock — and leaves the restpendingfor 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 rulelib/messaging/invocation-budget.tsstates — the outermost caller owns the numbers. A per-run cap counts JOBS and says nothing about how long one takes, which is whyEXTRACTION_BACKLOG_RUN_CAPalone was never a budget. (3) The SDK had no bound at all. The Anthropic client wasnew Anthropic({ apiKey })— notimeout, nomaxRetries— 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, sotimeout 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-streamingmessages.parse()and does not throttlestreamCompletedeltas orgetBatchResultsJSONL.) Fail-loud: the drain reports askippedcount 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: loweringoutput_config.efforton the extraction call (it runs at the API defaulthigh). 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 inutils/constants/agent/effort.tsalready reserves the provider default for exactly that, dropping tomediumonly 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.tsgotNOTE_EXTRACTION_MAX_OUTPUT_TOKENS;group.ts(a meeting group's shared note) andcompany.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_MSwas 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 reads240s < 300s <= stall < reap (15 min). The docstring onEXTRACTION_BACKLOG_MIN_AGE_MSjustified 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_truncatedsays "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;blockedis 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.errorCoderead only the top-level.code, but the Anthropic SDK wraps socket failures asAPIConnectionErrorwith the realECONNRESEThung off.cause— so every genuine transient landed inunknown("Something went wrong on our side") instead ofprovider_busy("Retry in a moment"). It now follows.causeone 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 notimeoutand nomaxRetries, 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.tsstill described the messaging flush route asmaxDuration = 60when it had been 300, and it was not the only one. Stale docs are a bug (Rule 13). Explicitly NOT done:extraction-jobs.tsis 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.effortstays at the provider default for the same write-path reason the entry above records. And the retryability decision keys on the failure label text, becauseextraction_jobshas 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 topendingand a deterministic cause always reproduces — and both attempts loggedStructuredOutputErrorwithcode: 'max_tokens': the generation was cut off AT the ceiling, the constrained-decoding grammar never closed the object,parsed_outputcame back null andrunExtractthrew.extractAndApplyNotepassed nomaxTokens, so a call that asks for four lists at once — every fact, every relationship, every follow-up and every tag — had to fit insidesync.ts's 2048 default, shared with the extract tier's adaptive thinking (claude-sonnet-5, nothinkingfield, so it runs by default and its tokens count as output). The ceiling is nowNOTE_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(), whichmessages.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:StructuredOutputErrorcarries the stop reason ascode,extractionFailureMessageechoes a message only from a bareErrorwith nocode, andextractionFailureKindhad 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 asoutput_truncatedvia the SHAREDisTruncatedOutputpredicate (no second detector) and says something the user can act on. Residual risk, stated rather than hidden (Rule 12):api/jobs/extraction/runhasmaxDuration = 60, so a generation that genuinely ran to 16384 would exceed the invocation and be KILLED, leaving the job rowrunningwith 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 OWNextract()call sites and are still on the bare 2048 — same bug, deliberately not fixed in this pass. Tests: the truncation test injected a hand-rolledErrorwhose shape no longer exists and was green over a dead code path; it now throws the realStructuredOutputErrorand asserts the new label,refusalis 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
AsrEnginecontract lives in@dhaga/core/src/voice/asr/types; an engineer adds a future model by implementing it and wiring auseDictationbranch. Only Dhaga Voice (Moonshine) ships now — the browserSpeechRecognitionengine, on-device Whisper, real-time Whisper, and the Settings engine-picker (VoiceInputSetting/ theSttEnginepreference) 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_SOONthroughComingSoonNotice, 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 (loadTinyalso throws fast when WebGPU is absent).@huggingface/transformersdynamic-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
keywordshint measurably fixes proper nouns — so the user's taught vocabulary now reaches the engine, which Moonshine could never use.packages/core/src/transcription/index.tsself-seeds ONE built-in provider (OpenAI,gpt-transcribe) inside its accessor, the wayllm/registry.tsdoes, sohasTranscription()is true the momentOPENAI_API_KEYis set — noregisterTranscriptionProvider()call and no extra config, and the seeding is inside the accessor deliberately, because a side-effect registration inapps/webis not guaranteed to be bundled into the serverless webhook that needs it.TRANSCRIPTION_PROVIDERis 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_MODELoverrides the model. Two consumers. (1) Inbound messaging —lib/messaging/normalize.tsacceptskind === "audio"when transcription is configured instead of refusing it, andprocess-session/derive.tstranscribes through the meteredlib/ai/transcribe.tsrather 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 toPOST /api/voice/transcribe(GETanswers{ available }), andcomponents/app/contact/dictation-gate.tschecks 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 newvoice_transcriptionaction 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, becausegpt-transcribebills 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_secondscarries the clip's estimated duration andlib/ai/costprices 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 realdurationSeconds, 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 bydictation-gate.test.ts,server-asr.test.ts,lib/voice/openai/wav.test.tsandlib/__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-recognitiondictation (correct()on the transcript, interim + final) + avocabscreen; JSON-fileVocabStore. (pending device build) -
voice_vocabtable (syntheticidPK, mirrorssettings; EETENANT_TABLESadds RLS) +/api/voice/vocab(GET/POST/DELETE) +"use server"actions + export coverage; core path unaffected (noapps/web→@dhaga/eeimport). 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 apositionsrow 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 bysource_note_idalone. 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
positionsrole 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
edgesrow in place, so the edge keeps its id and itssource_note_idreceipt; 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.tagsjsonb array, so it could carry nothing but its own spelling, and there was no read path forcontact_tag_sourcesat all. Nowtags(id, name),contact_tags(composite PK, cascade both ways) andtag_aliasesare the source of truth,contact_tag_sourcesgained atag_idFK, andcontacts.tagssurvives only as a derived cache with exactly ONE writer —refreshContactTagsCache(lib/repo/contacts/tags/cache.ts), the same denormalisation contractcontacts.title/company_idalready have overpositions. 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 onlycache.ts. The migration is EXPAND-ONLY — nothing dropped,contacts.tagsandcontact_tag_sources.tagboth still exist, and the text column is written in lockstep withtag_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, andfindOrCreateTagIdsresolves 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 realapplyExtractionpath, 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_idintact, hand-pinned (NULL) rows included. The RLS wipe hazard and its guard: every new table is under RLS and the boot DDL connection sets noapp.current_user_id, so the DDL-time backfill legitimately sees zero rows on a hosted install — leavingcontacts.tagsfull 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 aTagsBackfilledreceipt whose class value is never exported, so a refresh that skips the backfill does not compile, with a runtimeinstanceof+ 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 addstags/contact_tags/tag_aliasestoTENANT_TABLESwith 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/apprender and threaded to the nav for the badge, so the visibility rule (AppNav/link-state.ts,isNavLinkVisible) costs no extra query — deliberately not folded intogetCachedAppConfig, 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 typedconfirmations(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): anote_subjectcard 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 ontofollow_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_idare 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):dedupeNoteTasksused 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 matchedlower(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.dedupeNoteTasksis 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'sclearNoteDerivations: 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 onapplied && followUpCount > 0, and the reminder the bot already promised survives a bad model response. Soft-dismissed rather than deleted, sorestoreTaskcan 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 bylib/__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:attachTasksToCompanywrites no note and nosource_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, becauseattachTasksToContactgives the SAME rows the note's receipt andopenFollowUpIdsForNotethen hands those same ids to a write-out keyed oncalendar_event_links.follow_up_id— an edit of the existing event, never a second one. Covered bylib/__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 bylib/__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_idis 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 ownentityTypeHint, 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 throughfindOrCreateCompany— alias-aware, so a merged-away name links the surviving company instead of forking the duplicatecreateCompanywould mint. The target union (EdgeSuggestionTarget) gainedcompanyId/newCompany,ConfirmationResult's edgedstTypeis now the endpoint union rather thanstring, and bothresolveTargetand 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 bylib/__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/graphopens a docked panel (a bottom sheet with a sticky "Show N people" bar belowlg) 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",ResetChipcarries "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— viareplaceState, so?focus=is untouched; plain URL params rather than nuqs, whichdocs/LIBRARIES.mdconfines 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/fullis unchanged (payload, ETag, IndexedDB key): the per-contact attributes ship from a separateGET /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'slastTouchSql(never = nothing since capture, as Home'srecentReasonSql), "date added" iscontacts.created_at; connection count and relationship type are derived from the loaded graph, event attended from itsattendededges. 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 everypositionsrole.
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=offbecause the local ONNX embedder is unsupported on Vercel serverless (same constraint §14's goal-curation entry records), soembeddingsEnabled()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 withSEMANTIC_SEARCH_COMING_SOONin that state rather than pretending to weight an empty set —searchActionreturnssemanticEnabled: embeddingsEnabled()andWeightTunerwraps only that one row inComingSoonNotice; it defaults tofalseuntil 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 timests_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)queryWordskept English stopwords thatto_tsquerydiscards, 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' ownenglishstoplist, 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 ontocontacts.title),entities, contactnickname, graphedges(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 onpositions/entitiesplus an expression index fornickname— 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.tssaid 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 JOINscontacts, so a company-only task or a general TODO — both shipped shapes, sincecontact_idandcompany_idare each nullable — was dropped rather than surfaced as itself. Two new branches of the SAMEUNION ALL(keyword/combined/plan-branches.ts, never a second query — see the header onkeyword/combined/index.tsfor the pool exhaustion that rule exists to prevent):local_calendar_eventsmatched on title/location/description (Apple's field set minus invitees, which we don't store) behind a new GENERATED tsvector + GIN index (ddl/search.tsCALENDAR_SEARCH_DDL, concatenated afterCALENDAR_DDLbecause it ALTERs the table that one creates; nothing is needed inpackages/ee— its RLS DDL only addsuser_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 openfollow_ups WHERE contact_id IS NULL, the exact complement of what the contact branch already covers. Both are newSearchKinds —calendarEvent(neverevent, which is the CRM events-attended table) andtask— that carry their own label and day instead of an identity, so they skip identity resolution and are not gated byrestrictTo(a set of contact ids). Both link to/app/plan?date=YYYY-MM-DD:searchHitHrefgained an optionaldateso 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-onlydue_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 concurrentindex.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)
buildTsQueryprefix-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, andmit:*scores the lexememittalexactly as strongly asmit. Becausecontacts.namesits at setweight'A'whilenotes/factsare 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 formit:*were surname false positives, all tied at rank 0.303964, against note evidence at 0.043–0.086.SearchQuery.matchModegains"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 theword_similaritypath atNAME_FUZZY_MIN_TOKEN_LEN— that gate still holds (word_similarity('mit', <surname>)= 0.75, and contributes nothing). (2)positions.search_tsvcovers title/department/note andpositionshas no org-name column — the organisation ispositions.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.positionsBranchnow joins the position's company (GIN probe oncompanies_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) — WithJEV_API_KEYset, 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 2searchcredits 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: areminderwhose one duration code reads (the subject cut by code), alist_addnaming a list the user does not have yet (Jev picks a span code found), and a pure one-clausequestion(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 saiddifferent_personand Claudesame_personin both runs, and that kind decides rename vs move. Bug fixes, on: aquestionthat also carries a note is now captured AND answered (alsoNote; before, Claude routed 8-9 such messagesquestionand 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
aboutis owned by the group (facts.group_id), andupsertEmbeddingasserts a single owner whileembeddingscarries onlycontact_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 itssource_note_idreceipt, but it will not come back from a semantic query untilembeddingsgains 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 isJOIN contacts c ON c.id = n.contact_id … WHERE n.contact_id IS NOT NULLand the facts branch joins the same way. A group-owned note has a NULLcontact_id(its owner isgroup_id, enforced byassertSingleOwner), 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 SAMEUNION ALL(never a second query) carrying its ownSearchKindand 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, whichFREE_TIER_AI_CREDITS_PER_MONTHnow derives from so the free tier has one number; an admin re-sizes it at/app/admin/ai-creditslike any other plan, andDHAGA_AI_MONTHLY_CAPseeds 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, andai-credits-overview.test.tspins it againstaiCreditsUsedThisMonth()— the figure the cap is enforced against); and recent activity, now keyset-paginated (2026-08-01) rather than capped: the firstAI_ACTIVITY_LIMIT(20) rows render server-side, and a Load more button pages through the rest of the account's full history viagetAiCreditActivityPageAction→listAiCreditActivityPage(lib/repo/ai-usage/activity.ts), a(created_at, id)keyset cursor rather thanOFFSETso it stays fast asai_actionsgrows 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), nogetDb()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 owngetDb()) returns a reason ornull, andAiGateNoticerenders 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":assertAiBudgetrefuses onused >= capand 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/gatelazily (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;aiGateReasonitself is unchanged and stays credit-only. 8 tests inlib/__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.tsholds the only copy of provider rates (MODEL_RATES_PER_MTOK,BATCH_PRICE_MULTIPLIER = 0.5) andlib/ai/cost/turns a recordedai_actionsrow 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_actionsgainedbatch boolean NOT NULL DEFAULT false(lib/db/ddl/core/meta.ts, self-healingADD COLUMN IF NOT EXISTS) becausegoal_matchingruns both a nightly Batch pass and a synchronous goal-resolve path, so inferring from the feature would halve a real bill; pre-existing rows readfalse, 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-userai_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 viaSTANDING_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_USDsurvives 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 pinnedINR_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); legacyfounding_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-creditsignal_detectionrow carrying the search turn's tokens, but the $10/1k per-search charge has no column onai_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 reusesai_budget_settings(dollar_cap_enforcementon 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-budgetis unchanged becauseindex.tsre-exports everything, so any doc still namingutils/constants/ai-budget.tspoints at nothing); no new table and no new env var (there is noDHAGA_AI_MONTHLY_DOLLAR_CAP). Enforced inside the existing metering path (assertAiBudget+ thehasMonthlyAiBudgetpre-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 thehasUnlimitedAiCreditsearly 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:AiBudgetErrorwithkind: "dollar_cap"(kinds are now"cap" | "dollar_cap" | "burst"), non-retryable at both sites that branch onkind. 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_actionsgainedcache_write_input_tokens/cache_read_input_tokensandrecordAiActionwrites 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 whatai_actionsactually records. Two real billing bugs in streamed Ask Dhaga, both in the same three lines.recordAiActionran outsidewithAiAction(action, …), and AsyncLocalStorage does not survive ayield— so it fell through to a freshrandomUUID()and wrote a secondai_actionsrow.searchis 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 afteryield {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.tssetswebSearch: trueand 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 hadusage.searchesin hand and threw it away. Transcription was billed by the token, which OpenAI does not do for audio; the minute is the unit, andlib/ai/transcribe.tsnow 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.extractmovedclaude-haiku-4-5→claude-sonnet-5. Theextracttier 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.tshas been pinned toreasonsince 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 isdocs/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 with400 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 recordedapply_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$defsand 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+$refrefused and the identical schema with$refs inlined accepted.outputFormat()now resolves every$refbefore sending, enforced by a source-level guard so nobody callszodOutputFormat()directly;budget.test.tsholds 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.tsfor 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 theextracttier and asserts the call is served.budget.test.tscounts 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.planMessagingBatchclassified 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 storedapply_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 anAiBudgetErrornow wraps asplan_failed, carrying the real error ascause(and, since 2026-09-09, an output the model could not finish writing splits off asplan_truncated); a spent budget still passes through untouched asover_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), becauseerroralone says a batch broke and never what broke it, and the cause went only tologActionError— 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 viaerrorSummary(packages/core/src/logging), shared with the per-itemsave_faileddetail so the two renderings cannot drift: a class name, a driver code, an HTTP status, code locations, and a message only for TypeError/ReferenceError — nevererror.message, whose text quotes the input on exactly the classes that matter here. It followscauseone hop, since without that a wrapped provider failure would persist only the wrapper's name. The failure reply is stored insummaryinstead 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 backgroundafter()),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 leftstatus='error',error='The AI call failed.',attempts=0, noai_actionsrow and no log line anywhere, so the cause was unknowable — and Retry, which only flips the row back topending(same note, same tier, same limits), reproduced it exactly, as a deterministic cause always will.mapExtractionErrorwas a catch-all that kept only anAiBudgetError's message and discarded the error object, which left the classifier (extractionFailureKind, already separatingprovider_auth401 fromprovider_busy429/5xx/network) and the PII-safe logger unreachable inprocessExtractionJob'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, carryingreason,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 addsdetail— the sentence the user is shown — which is the ONLY place a message this app itself threw survives into a log, sinceerrorFieldsredacts a bareError's message and that is exactly the class carrying "Structured output did not match the expected schema (stop_reason: max_tokens)".attemptsis 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. Themax_tokensceiling was NOT raised. Measured againstnoteExtractionSchema, 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 saysstop_reason: max_tokensinstead of "The AI call failed.") Second bug, same commit: re-extraction dated relative phrases from the moment of the RE-RUN.prepareNoteExtractionnow takes the note'screated_ataswrittenAt, which moves BOTH anchors at once —todayLine()in the prompt, andresolveDatePhrase(due_hint, today)insideresolveFollowUpDues, the half that actually writes a reminder on the wrong day. Fixing only the prompt would have left the due date wrong.noteshas no occurrence date (onlycreated_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 realoccurred_atis 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;attemptsreaches 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 noai_actionsrow 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.finishNoteAnswertime-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 inafter()and lived27s, 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$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 billsnote_extractionjob every other note path already creates, on no-outcome and on afailedoutcome.blockedis 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" andnoLlmOutcome()— indistinguishable by flags, and neither fixed by re-reading the same sentence. The app path was fixed too, not just the reported one:!outcomeis unreachable there (the await is unbounded), butfailedis reachable andfinishNoteAnswerreturns 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 wasapi/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:reapStuckExtractionJobsis called only fromapi/jobs/detect-signals, which is in no cron inapps/web/vercel.jsonand in no entry ofapi/jobs/daily/jobs.ts— so on the hosted deployment those rows satpendingforever without even an error.runExtractionBacklognow drains them from the*/15tick, 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 byEXTRACTION_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 onupdated_atatEXTRACTION_BACKLOG_MIN_AGE_MSso it does not race a tab that is already driving a job. Tenant scoping is the sharedlib/jobs/tenant-sweepharness (eight other jobs use it), reads inside the scope and the LLM call outside it.note_extractionONLY, and that is a spending decision: the other three kinds are all started by a person with a tab open, andenrichmentis the dearest action in the product (free(0 credits, ceiling-gated) because answering the bot's own question must not cost a credit; the worker defaulted tocredits, so the price depended on whether the model happened to be fast — and at a monthly capassertAiBudgetrefuses, the outcome is a non-retryableblocked, and the note ends with nothing.extraction_jobs.billing(additiveADD COLUMN IF NOT EXISTSin the existingEXTEND_DDLchunk; 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 claimspending → runningand then spends 20-40s in Anthropic on a route withmaxDuration = 60, andclaimExtractionJobonly ever admits apendingrow — soreapStalledRunningExtractionJobs(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 exposureactions/notes/reprocess.tsalready 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, andmessaging-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 viasettings(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,/contactand 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 wordDELETE) before the destructive button arms — one click must never erase an account, and a nativeconfirm()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 itsuser_idfrom packages/ee's RLS DDL as a baretextcolumn with no foreign key at all, andapikeyhangs offreference_idwith no FK either. A table missing fromACCOUNT_OWNED_TABLES(utils/constants/account-deletion.ts) is therefore a silent breach — add one there whenever one is added to EE'sTENANT_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):scopedwhencontactshas auser_id(Dhaga Cloud) → explicit predicate on every table;soleon a core-only self-host with one account → unqualified deletes, since the graph is unambiguously theirs;sharedon a core-only self-host with several → the graph is left ALONE and returned inskippedTables, 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_idexists in core but is NULL without EE, so a predicate delete cleared none of them and the laternotesdelete 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 (/refundsgoverns 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) andadmin_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_idisON 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/privacyand 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_membersandnote_contactscascade fromcontacts, butgroupshad 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 inACCOUNT_OWNED_TABLES, deleted by the erasure transaction afternotes,factsandcontacts, 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/vcardstays 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 oneofferUnlinkedCreatesuses, shared vialib/repo/sync/authored.tsso the bulk path and the sync path cannot disagree about what may be written into an address book. An unrecognisedscope/provider, or either onformat=json, is a 400 rather than a silent fallback to everything. The card now also carriesNICKNAME,BDAYand item-groupX-ABDATE/X-ABLabel, emits oneADRper entry of the syncedcontacts.addressesarray (labels rideX-ABLabelbecauseresolveLabelmaps only HOME/WORK/OTHER out of a TYPE token and would silently drop a custom one), and omits theTYPEparameter on an unlabeled email/phone instead of defaulting it toWORK— all round-trip correctness rather than completeness (see §20).ADRpreviously came fromcontacts.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 intocontacts.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 — anitem1.ADRand anitem1.X-ABDATEon one card would otherwise both resolve to whicheverX-ABLabelcame 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 asTYPE=NEWSLETTER, whichresolveLabeldiscards, 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 carryX-ABLabel; andimport/vcard/tokenize.tsunescapes a value beforemap.tssplits it on;, so an escaped\;inside a structured component lands in the wrong field (visible in a current fixture:ORG:Stripe\; Incparses to companyStripe, departmentIncwith a leading space) — which now reachesADRtoo, 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) —
/privacynow 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; alogAdminAccess()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 touser: 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-rolledrequireAdmin()copies inlib/actions/admin/ontoassertAdmin()— they were the audit-less duplicates. - Terms accepted at signup, and recorded (2026-08-12) — a required checkbox on
/signupthat 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_versionstamped in better-auth'suser.create.beforehook againstTERMS_VERSION(utils/constants/legal.ts, the same constant/termsprints). 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 becauseinput: falseon the additional fields means the value can never be posted by a client. Hosted-only:/termsis 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. -
/termsrefund 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/pricingalready says).TERMS_VERSION/TERMS_UPDATEDbumped, 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/termsdescribes Razorpay behaviour only.terms/terms-wording.test.tspins 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.tswas 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 (ConsentCheckboxis the shared row;TermsAcceptanceandPrivacyConsentwrap it). Server stampsuser.privacy_consent_at/privacy_notice_version(PRIVACY_NOTICE_VERSIONinutils/constants/legal.ts, which/privacyalso prints) inuser.create.before,input: false, hosted-mode only; the two columns come fromPRIVACY_CONSENT_DDL, appended last in the DDL chain, never backfilled. What is verified server-side, honestly: a hosted/sign-up/emailmust carryx-dhaga-privacy-consentequal 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/privacycopy has since been rewritten (next item) and a third, 18+ box added. Superseded 2026-10-06 (consent-stamp integrity, next-but-one item): thex-dhaga-privacy-consentheader 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.
/privacynow 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 fromSUBPROCESSORSwith 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 fromSTORAGE_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 inLEGAL_ENTITY, 7 days to acknowledge and 30 to resolve, fromutils/constants/privacy-requests.ts).PRIVACY_NOTICE_VERSIONbumped 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 stampsuser.age_confirmed_at(newAGE_CONFIRMATION_DDL, appended last, never backfilled,input: false) and refuses a hosted/sign-up/emailwithoutx-dhaga-age-confirmedequal toMINIMUM_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/termspage says nothing about age (another change owns it); OpenAI (server voice transcription) and Cloudflare (Turnstile) are not inSUBPROCESSORSand 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-confirmedwas 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/:idendpoint, only Google's token call stubbed); not browser-verified, no real provider round trip run. Fixes a review finding:user.create.beforestampedterms_accepted_at,privacy_consent_atandage_confirmed_atfor 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. NowPOST /api/signup/ack(hosted-only, IP rate-limited bysignup_ack_ip, JSON-only, refuses unless all three boxes aretrue) sets a signed (HMAC overBETTER_AUTH_SECRET), HttpOnly, SameSite=Lax, 10-minute cookie bound to the current terms edition, privacy edition andMINIMUM_AGE; the signup form calls it just before email submit and just before each social redirect (SocialButtons.beforeSignIn).beforeUserCreateverifies 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/emailwithout 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.tswas 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 (ConsentCheckboxis the shared row;TermsAcceptanceandPrivacyConsentwrap it). Server stampsuser.privacy_consent_at/privacy_notice_version(PRIVACY_NOTICE_VERSIONinutils/constants/legal.ts, which/privacyalso prints) inuser.create.before,input: false, hosted-mode only; the two columns come fromPRIVACY_CONSENT_DDL, appended last in the DDL chain, never backfilled. What is verified server-side, honestly: a hosted/sign-up/emailmust carryx-dhaga-privacy-consentequal 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/privacycopy 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-emailis 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/pendingaside 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=1was and remains the entire opt-in mechanism inapi/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.getAuthUrlgainedwrite?: boolean, and one-way now asks forcontacts.readonly/Contacts.Readinstead 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 newreadState), 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 setstwo_way_syncfrom that intent in BOTH directions and deliberately never derives it from the granted scope — Google'sinclude_granted_scopes=trueunions 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 contactsexchangeCodeposted a fixedscope(would hand a two-way consent a read-only token) andrefreshfell back to the write constant when the stored grant was unknown (would widen a one-way connection on its first refresh) — a newsync/microsoft-provider/scopes.tsderives 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
apiKeyplugin) for non-browser clients — replacesDHAGA_API_TOKEN; code confirmed fully wired end-to-end (2026-07-07 audit):apiKeyplugin, settings UI,/api/captureauth fallback all connected,DHAGA_API_TOKENno longer referenced anywhere in code. Creating a token is payment-gated since 2026-08-07 — it is the enforcement point for themulti_device_syncplan feature (requireFeatureinlib/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/mcpitself (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/captureaccepts 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-extractoron-device OCR primary + server photo-scan fallback,x-api-keyauth); typecheck green; needs on-device verification on Android + iPhone (seeapps/mobile/README.md), not pushed -
M1 card scan: edit-before-save (2026-09-06) —
POST /api/capturegains 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 againstextractedContactSchema— the postedcontactis never trusted verbatim, same rule as every other write of a user's data about a third party. Omittingstepkeeps 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 intopackages/core/src/api/capture/{request,response}.tsunder the 150-line rule (CaptureParseResponse,CaptureSaveResponseadded). 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'sScanCheckNotice), and Save/Back kept clear of the keyboard (KeyboardAvoidingViewplus a footer outside theScrollView, 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<5stargets are unaffected and unbenchmarked by this change. Not verified: noeslintrun onapps/mobilein the build environment (eslint-config-expounresolved) 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/datagateway 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/mobilehas no note list and no note surface at all, no endpoint it calls returns notes, and every note it creates is stampedsource: "mobile", which the accent map deliberately leaves neutral. The groups half is a real gap — no/app/groupsequivalent, 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 intoCOLORSby 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 — movingNOTE_SOURCE_LABELSintopackages/corefirst 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/captureREST 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 baredhaga.app308-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 inhost_permissions(see Runtime host access below): the Dhaga origin, includinglocalhost:3000for 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 thex-api-keybranchrequireUserIdFromRequestalready 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 inchrome.storage.local, neversync, 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 asimages[]withimageKind: "page". Server side: apage_scanprompt + 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/capturenow checks the page branch BEFORE the card branch socontactIdis honoured — previouslyimages.length > 0returned 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, inapi/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
sidePanelpermission and a service worker whose only job issetPanelBehavior - Runtime host access (2026-08-10) —
host_permissionsis now EMPTY. an earlier build declaredhttps://dhaga.app/*there whileoptional_host_permissionsalso matched it viahttps://*/*; 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 checkschrome.permissions.containson open and shows a Grant access button. Since 2026-09-30 that one click requests the Dhaga origin plushttps://www.linkedin.com/*(not<all_urls>), and Add screenshot asks for<all_urls>separately on first use (optional_host_permissions). Also fixed:picker.tshad atry/finallywith nocatch, 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.zipandSTORE_LISTING.mdwith 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) andPOST /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 withdecisions), 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 fromchrome.storage.sessionfor 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 onlypage; 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 andhttps://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/coreby 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 realANTHROPIC_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-sideweb_searchtool (packages/core/src/search/anthropic-client/) behind the same gateway; the default provider isanthropicwhereverFIRECRAWL_API_KEYis unset, sohasSearch()(packages/core/src/search/index.ts) is true on the hosted instance, which already hasANTHROPIC_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_detectionis 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-sideweb_searchtool by default since 2026-08-08; Firecrawl still wins whereFIRECRAWL_API_KEYis set) → theextracttier classifies hits →signalstable → 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]passeshasSearch()intoWatchToggleassearchConfigured, and a false value wraps the control inComingSoonNoticewithSIGNAL_WATCH_COMING_SOONand swaps the "opt in to get alerted…" line forSIGNAL_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): withanthropicthe default provider,ANTHROPIC_API_KEYalone makeshasSearch()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-creditsignal_detectionrow 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 onai_actionsand 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 withoutJEV_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-freejudgeEachwithin 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 inutils/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 noSUGGESTION_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, nowithAiActionentry 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 indaily-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 bylib/__tests__/daily-suggestions.test.ts.content/docs/guide/home.pngnow 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/callshybridSearch(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 writesgoal_membersrows carrying the frozen 0–100 fit, with anything underGOAL_MIN_FIT(40, added 2026-08-22) storedrejectedrather 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 bySUGGESTION_GOAL_BASE0.7), reserving no slots — all of that was deleted on 2026-08-22: there is noSUGGESTION_WEIGHTS.goal, noSUGGESTION_GOAL_BASEand no goal candidate source (lib/repo/daily-suggestions/candidates/goal.tsis 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 oncelastTouchSqlmoves pastmatched_at, so a note about them or an event scan burns them down exactly like the Reached out button, andmarkReachedOut— 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 nightlygoal_matchingpass 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 vialib/ai/goal-resolve/— its own metered featuregoal_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 andGOAL_SYNC_RESOLVE_CAPwent 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 oldRATE_LIMITS.goal_resolve3-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 wheregoal: 28lost to every term that could realistically fire on the same person (cadence40,followUp30,importantDate30) — though the weight was never the decisive problem (28 sat abovesignal25,quiet15 anddegree10); the caps were. Goal was capped at 3 candidates against every other source's 10, trimmed by meeting count, index-capped out ofMomentsTimeline, and — on the rare render where a member survived — badged Cadence, becausereason.tsnames 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 noSUGGESTION_WEIGHTS.goal. It has two surfaces of its own instead (§3.7)./app/goal— a real route, registered inAPP_MORE_LINKS/APP_MORE_MENU_LINKS— renders four lists off ONE cohort read: Today (up toGOAL_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 sharedEntityCombobox) 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) andstate = '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 bare0/14bar 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_atis stamped from a server action fired by a client effect on/app/goalonly, never on read (a write during a React server render is forbidden) and never from Home (whichrouter.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_LINEsays 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 inheritinghybridSearch'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 storedrejectedand 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'srejectedrows but deliberately keeps the user'sskippedrows — the model may be wrong about a new objective, the user's "not relevant" is not ours to discard.GoalResolveSkipalso stopped collapsing every refusal into oneno_budget: it is nowno_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/(newexamples.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 nightlygoal_matchingprice 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.tsstill provesgoal_membersis joined exactly once per Home render)./app/goalhas 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 nightlygoal_matchingand watchlist figures in BRD §8.3 still are.content/docs/guide/home.pngpredates 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 —hybridSearchreturns at most 20 hits, soGOAL_RECALL_POOL(150) was an upper bound this path could never reach — fixed on 2026-08-22 by passingGOAL_RECALL_LIMITexplicitly (see the rebuild above); the deployment limitation above is unchanged. User guide:apps/web/content/docs/guide/index.mdxJev 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 nogoal_membersrow, so the candidate is re-screened next run; recall pullsGOAL_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 reportsno_candidatesand is not charged (lib/ai/goal-resolve/jev-only.ts); its Jev calls are recorded as 0-creditgoal_matchingrows. A run in which Claude was asked and every judgement failed returnsfailed, 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,getActiveGoalworkspace-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 inpackages/eerls-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 likedetect-signals) classifies each rowperson/service/unknownintocontacts.person_kind. The governing rule: aservicerow is never suggested, but is always findable.lib/repo/contacts/surfaceable.tsis 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:NULLmeans "never judged, batch it" andunknownmeans "the model looked and declined", so a row that says only a first name is left alone rather than coin-flipped; onlyservicesuppresses, andperson_kind_confidenceorders 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 byPERSON_CLASSIFICATION_RUN_CAP(1000 contacts/night), not by billing:person_classificationis 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 inlib/db/ddl/core/extend.ts(four columns + one partial review index), covered bylib/__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.pngis 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_classificationand the four sector passes (2026-09-23,lib/jobs/jev/—judge-each.ts,sector-choice.ts,sector-chunks.ts— andlib/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 inpackages/core/src/judgment/validate.ts(every key present, right type, a choice among the options offered, values in [0, 1], andusage.input_tokensa non-negative integer — a missingoutput_tokensis 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 foldedai_actionsrow 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 aJevBudgetpot (lib/jobs/jev/budget.ts) charged the union of in-flight time and split fairly per tenant, unused time rolling forward, and every pass insideapi/jobs/dailyis still clamped by, and charged to, the sharedDAILY_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 aJevBudgetpot 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) soTYPESAFE_BASE_URLcannot 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 andhasPerson≥ 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 throughjudgeEachand its tokens join the scan's onerecordScanReadswrite, so a successful scan is onecard_scanrow (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 suitelib/__tests__/empty-note-jev-gate.test.tscovers 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.mdand thedhaga-ai-callsskill. -
"The week after Monday" no longer lands on that Monday (2026-09-24,
packages/core/src/dates/follow-up-date.ts) —resolveDatePhrasematched 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. Onecontact_parserow and credit either way; noJEV_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 wheneverJEV_API_KEYis 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 viaisDummyAccount()). Correction (2026-07-30): this job, the reach-out digest and the confirmations digest read their opt-in setting on an unscoped connection, so underpackages/eeRLS the read matched zero rows and all three could never send for a hosted user, whatever their toggles said. All three now use thehostedTenants()+withUserDbfan-out (the shapefollow-up-reminders/linkedin-export-remindersalready had), with per-tenant local-day idempotency vialib/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 (seedocs/LIBRARIES.md§12); follow-ups are all-day single-date items. Shipped onfeat/followups-calendar -
Drag-to-reschedule a follow-up on the calendar (FullCalendar
interactionplugin, 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 nativetitletooltip, 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 onevent.detail === 0rather 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;loadFollowUpsreadslistTasks()(open + done) directly instead oflistAllOpenFollowUps(), andgetNotificationSummary/getDueFollowUpRemindersForUsernarrow back with a namedisOpenFollowUpfirst, 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 intolib/filters/follow-up-scope.tsand consumed by both screens — the two must not be able to disagree about what "General" means.reconcileResolvedFollowUpEventwas replaced by pure list transforms (applyFollowUpOutcome/applyFollowUpDueDate): once events derive from a filtered list, an imperativeevent.remove()is undone by the next keystroke, and an unscheduled item has no grid event to remove.lib/repo/reminders/calendar.tssplit into a directory (150-line rule); import paths unchanged. Pending: no browser pass — the drag-vs-click discriminator, the<button>chip binding as aDraggableitem, and the.fc-donecascade 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/calendarand/app/follow-upspermanentRedirectto it and the nav carries one "Plan" entry. List is Dhaga's own task board, not FullCalendar'slistWeek— 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 viagetApi().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'sall|open|donevs the task board'sactive|completed; resolved as the supersetall|active|completeddefaulting to active, which letcomponents/app/calendar/filter-follow-ups.tsbe deleted rather than kept in sync withcomponents/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 tolib/filters/follow-up-scopeinstead of re-inlining a predicate.CalendarFollowUpgainedcreatedAtat 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 byLocalEventDialog/instants.test.ts). Every follow-up row offers an inlineDatePickerin both views, showing the free-textdueHinta 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(aSheetunderlg) while Home consumes the same due/date facts through Moments rather than duplicate tiles; its upcoming-dates read is hoisted out ofbuildDailySuggestionsand injected back in, soHOME_DB_ROUND_TRIP_BUDGETis unchanged. Known gaps: no browser/screenshot pass at 375px or on the timeGrid views; the#dueanchor is unreachable on mobile because the panel is a closed Sheet there; the notification bell still links/app/calendarand/app/follow-upsand 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_datepinned 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 falsemakes 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 hiddendueAtfield →lib/actions/task-input.ts), because the server does not know which zone "15:00" was typed in; it reusesLocalEventDialog/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 purenextRecurrenceInstant(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 sharedDueFields, 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 inpackages/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.tsand 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_timeis a required-but-nullable 24-hour"HH:MM"wall-clock in the USER's zone (resolveDatePhraseis zone-free by construction and cannot read a clock, so only the model can hand the time over), andlib/repo/graph/follow-up-due.tsis 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 readlib/ai/note-extraction/prep.tsalready makes fortodayand is threaded through asExtractionPrep.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 bylib/__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/plankeep 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/planand 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 inafter(), so the render asked for data whose arrival it had no way to show, andnowwas baked in at render time. The refresh moved toGET /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 callsrouter.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 inafter()(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_MS5 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/planan 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/planrenders 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, seedocs/SCALING.mdlever 2) and the last of them, the connected-calendar read, made outbound Google/Microsoft calls while the render held one of those connections.CalendarPulsemade that worse rather than better: arouter.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 existingcachePerUserVersioned— 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 sFREE_BUSY_SNAPSHOT_STALE_MS. (2) Per-calendarlistEventscalls 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 thegetDb()-under-Promise.allshape that has taken the pool down before. The loop over connections stays serial (usableAccessTokencan refresh a token and queues row writes into a shared array), per-calendar failure handling is unchanged (one dead calendar incrementsfailed, all-failed still marksneeds_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 sameschedule_prefsrow was read 4–5× per render, becausegetCalendarFollowUps,listImportantDateOccurrencesandlistDueReachOutseach re-derived the user's zone on their own.getSuggestionSettings()now runs FIRST andprefs.timezoneis threaded into all three through the injection slotlib/repo/reminders/local-today.tsalready documented; each parameter is optional and defaults to the old self-read, so every other caller is untouched.readCalendarPulsestill reads settings once and is deliberately left alone (Home shares that token). The page's reads moved toapp/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.
PlanListbuiltuseReactTable({ data: items })withoutautoResetPageIndex, which defaults totrue— andcompleteTaskAction→revalidatePath("/app/plan")handsPlanBoarda fresh array, whichmergeFollowUpUpdates(flatMap) →filterFollowUps(.filter) →sortTasks([...].sort) each re-allocate unconditionally, sodatachanged by reference on EVERY render and TanStack queued aresetPageIndex()on a microtask. The hand-rolledresetKeyeffect that existed for exactly this was being silently overridden by it. Worse than a mis-click:CalendarPulse's backgroundrouter.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 — nouseState, and critically noonPaginationChange— which is what actually stops the reset, sinceresetPageIndex()only lands somewhere if the table has a pagination writer to call.autoResetPageIndex: falsestays as the half that keeps the door shut: someone will eventually wanttable.setPageIndexto 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, andtasks/plan-list-pagination.test.tsfails on the reset firing. Third, a clamp: completing the last row of the last page used to leavepageIndexpast 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: truewhere the siblinguse-table-url-stateusesfalse, becauseDataTable'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 andclearOnDefaultis on, so a default visitor still sees a bare/app/plan. A filter or sort change resetspagein the same batched write — one URL update, which is what replaced the deletedresetKeyeffect rather than re-creating it in a new place.?date=is untouched and stays deliberately one-way (docs/CALENDAR_PARITY_PLAN.mdsays why: FullCalendar's own prev/next do not write back either). (3) The default view is width-keyed.PLAN_DEFAULT_VIEWis gone, replaced byPLAN_DEFAULT_VIEW_COMPACT(timeGridDay) andPLAN_DEFAULT_VIEW_WIDE(timeGridWeek) behinduseDefaultPlanView()overPLAN_WIDE_VIEW_QUERY = "(min-width: 1024px)"— a separate named constant from the identically-valuedDESKTOP_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 —useSyncExternalStorewith a mobile-first server snapshot, the mechanismuseIsDesktopalready uses, and the grid sits behinduseCalendarMounted()so FullCalendar never mounts on the compact answer and then swaps;app/app/plan/loading.tsx's skeleton matches in pure CSS atlg, since a server component cannot read a media query. An explicit?view=always beats the width default. e2e updated:plan-visual/views.spec.tsandweek-density.spec.tsasserted Week is pressed at every viewport, which is wrong at 375 now; both are viewport-aware, and a newplan-visual/url-state.spec.tscovers 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.
FollowUpEditFormended 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 wordedSubmitButton"Save" next to the existing Cancel. Nothing on that form ever wrote before the button was pressed; the fix is entirely about saying so.SaveButtonstays as-is forFactEditForm, whose read-mode row has no tick to be confused with. (2)/app/plan's inline due picker wrote on every change.FollowUpDuePickerPOSTedrescheduleFollowUpActionfromonDueDateChangeANDonTimeChange, 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 isnullwhile 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), andSave/Revertappear 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.DueFieldsnow passesfromTodayandquickPicksto the sharedDatePicker, 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 isfutureFloor(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 bycomponents/ui/date-picker/future-days.test.ts. The popover's Clear footer moved fromdate-picker/index.tsxintoPickerCalendarso both files stay under the 150-line rule. Pending: browser pass, mobile parity (there is no follow-up edit UI inapps/mobileat all), and the deadupdateFollowUpActioninlib/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 newnotificationstable (lib/db/schema/notifications.ts, DDLlib/db/ddl/core/notifications.ts, enrolled in EETENANT_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 (statusunread → read → dismissed; rows are never deleted, so a re-run cannot resurrect a dismissed one). Both FKs (contact_id,job_id) areON DELETE CASCADEbecause 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'swithUserDbscope 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-lessMM-DD, or verbatimDecember 9from 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 inpackages/core/src/dates/(Feb 29 clamps to 28 Feb in a non-leap year, local-midnight arithmetic, nevertoISOString()); reads vialistUpcomingImportantDates(leadDays)/listImportantDateOccurrences(range)(lib/repo/reminders/important-dates.ts). Surfaces: read-only all-day entries on the calendar views of/app/plan(/app/calendarwhen 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 bypackages/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, waslib/jobs/important-date-reminders/) +lib/email/important-date-reminder.ts, wired into/api/jobs/dailywith the same per-tenantwithUserDbfan-out. Gated on a newimportant_date_reminders_enabledsetting (default off — imported address books arrive full of dates the user never reviewed) with a configurableimportant_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 theimportant_date_reminders_sentsettings key (a JSON array, mirroringlinkedin_export_reminders_sent; no table, past occurrences pruned). Covered bydaily-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 theimportant_date_reminders_sentkey 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 asSchedulePrefs.timezoneinside the existingschedule_prefsblob, default"UTC"so existing users see no change until they choose; helpers inlib/time/zone.tsareIntl-only (nodate-fns-tz, and it lives inapps/webnotpackages/corebecauseIntl.supportedValuesOfis 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 inapp/app/layout.tsxin normal document flow, immediately afterLapsedPlanBannerand above{children}— so it reaches a user landing on any/approute, not only Settings. In-flow placement is also what keeps it off a collision course with the two onboarding surfaces: the/appcrayon annotation overlay isfixed inset-0 z-50behind 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 samesetTimezoneAction(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 (canonicaliseZonein the newlib/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 listsAsia/Calcuttawhile newer browser ICU saysAsia/Kolkata. The browser-zone read (readBrowserZone,humaniseZone,subscribeNever,readNoZone) moved out ofTimezoneSetting/zone-options.tsinto that new module so both surfaces share one detector;zone-options.tskeeps only the combobox option shaping.app/app/layout.tsxnow callsgetSuggestionSettings()where it calledgetImportantDateLeadDays()— 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 withminutesSinceLocalMidnight— but only where something drives/api/jobs/tickevery ~15 min; the once-a-day/api/jobs/dailydeliberately does not enforce it and stays at"17 6 * * *". The preference stopped being inert on 2026-09-04: the account moved to Vercel Pro andapps/web/vercel.jsongained 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 bytimezone-zone.test.ts+timezone-settings.test.ts+TimezoneMismatchBanner/__tests__/session.test.ts+__tests__/TimezoneMismatchBanner.test.ts(15 cases, all passing; jsdom withcreateElementand realreact-dom/client, since this repo has no @testing-library). What "browser-verified" covers: the picker and the new banner, rendered in Chromium under a PlaywrighttimezoneIdoverride 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
pushandnotificationclickhandlers and deliberately nofetchhandler — 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; waslib/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 viahostedTenants()+withUserDb, contact names HTML-escaped, no email when nothing is due or Resend is unconfigured; pure subject/HTML/send-guard covered bydaily-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 withinFOLLOW_UP_LEAD_DAYS(3), each row taggedOverdue/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.tstenant fan-out,sweep/the one send decision,subject.ts,activation.ts, one file per section undersections/), 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_enabledfor follow-ups and the ambient totals,important_date_reminders_enabled,confirmations_digest_enabled,daily_digest_enabledfor 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_enableddoubles as the daily check-in: with nobody due from the suggestion engine the reach-out section falls back to going-quiet contacts (listQuietContacts, capped atQUIET_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 andmorning_reminder_enabledon gets a short "add your first contact + read the guide" email instead of the brief, at mostACTIVATION_NUDGE_MAX(3) sendsACTIVATION_NUDGE_INTERVAL_DAYS(7) apart, tracked in theactivation_nudges_sentsettings 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 newnotificationEmailShell()(lib/email/send.ts), which always appends "If you'd rather not receive these, turn them off in Settings → Suggestions" pointing at/app/settings#suggestionsvia the sharedemailLinkBase()(moved out oflib/auth/config/emails.tsso 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 plainemailShell(), 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/dailyreturns a singledailyBriefkey ({ 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: thehostedTenants()+withUserDbfan-out, the clean no-op without Resend, and a missing settings row still reading off. (TheEMAIL_JOBS_HOURLYlocal-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 bydaily-brief/{index,activation,follow-ups,important-dates}.test.ts(headline: one email where there used to be three) andlib/__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. NowdigestHour/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 retiredutcOffsetMinutes. The gate isminutesSinceLocalMidnight(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 retiredEMAIL_JOBS_HOURLYgate 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 andisLocalHourcould not have answered this —zonedPartshas always carried minutes andminutesSinceLocalMidnightis 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/dailypassesenforceSendTime: falseand behaves exactly as it always has, and the new/api/jobs/tick(sameCRON_SECRETbearer, fails closed) passestrue. That route was called/api/jobs/digestwhen this entry was written and is/api/jobs/tickas 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/dailyalready 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_HOURandisHourGateEnabled()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.jsonwas deliberately unchanged while this ran on Vercel Hobby, where a sub-daily cron entry breaks the deploy, sodigestHour/digestMinutewere 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*/15instead 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:digestChannelssends 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 atDIGEST_CHAT_MAX_ITEMSwith a+N moreand 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_briefanddaily_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.MessagingClientgains an OPTIONALsendTemplate(the patternverifyWebhookChallenge?already set for a Meta-only capability, so Telegram implements nothing and keeps usingsendText),messaging_identitiesgainslast_inbound_at(appended DDL,ADD COLUMN IF NOT EXISTS), andsendScheduledChatMessagepicks text-or-template per chat. A NULLlast_inbound_atreads 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), plusdaily-brief/index.test.tsfor 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 fromme_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 sameprefs.timezonethe 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 onmorning_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, anddigestChannelsis not one of those — it defaults ON and only decides where a brief goes. (sections/linkedin.tshas 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:syncFollowUpToCalendarscopies 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 viamirroredEventKeys()/mirrorKey()and a test pins it. Silence still stays silent:greetChatBrieftakes astring, not astring | 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/dailyis a single cron at a fixed UTC minute withenforceSendTime: 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 offnowtherefore 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 whygreeting.tsreadsctx.nowand 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 withdigestHour20 was greeted "Good evening" at 07:17 BST under a Today heading; Los Angeles withdigestHour14 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.digestHourcannot answer that question: it is the hour they wish it arrived, and the daily cron delivers at a fixed instant regardless (DIGEST_HOUR_MIN/MAXare 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 —readExternalEventsis handedrunScopedand 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 — anotesrow with all four owner columns NULL and an explicituser_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). Anotesrow, 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 inheritssearch_tsv, the tombstone, the messaging re-drive'ssource_item_ididempotency anddeleteNote's already-owner-agnostic cascade for free). "Exactly one owner" gains a deliberate third state, not a hole:assertSingleOwneris unchanged and still refuses an owner-less row; a siblingassertUnfiledOwneradmits 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_idis 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";notestherefore joinsCORE_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 (thefollow_up_occurrence_overridesbug again). No EE change was needed:noteswas already inTENANT_TABLESwith 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 duplicateretire-capture-tasks.tsexists 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_tsvis a generated column) — what was missing was a branch, since every notes branch of the combined query INNER JOINscontacts. They surface as a record hit, the same shapetasksBranchalready 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 sameuser_id. Fixed on the way:/app/notes/[id]used tonotFound()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 insideconfirmations.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.parkNoteSubjectnow 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 (fileUnfiledNotefor a person, a newfileUnfiledNoteOnCompanyfor a company) and never creates one, keeping the same id, body andsource_item_idso 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 ofapply/unclear/), and itssource_item_idkey changed fromseqs[0]tomin(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:noteIdis optional, and a card raised before this takes the old create-on-resolve path unchanged. Noorganization_idstamp, matchingaddUnfiledNote's existing contract — the row is the capturing user's own, written under the batch's ownwithCaptureDbscope; the tenant predicate isuser_id OR organization_idand 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, whichinsertConfirmationdoes 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 andcountUnfiledNoteshas no production caller. Pending:ParkedTargetnow carries thenoteIdbut 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) —sweepExpiredConversationsshipped 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 asconversationRetention, 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-localapp.bypass_rlsthe nudge claim uses, wrapped in its own named helper): the table carriesFORCE 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 acrosspackages/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 withrowsAffectedrather than.returning()): with no caller until now, the first run on any instance meets the entire accumulated backlog, and one unboundedDELETEover 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.listTasksorders most overdue first andobserveDuetook 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, withAGENT_MIN_RENDERED_OVERDUE_DUEa 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'stitleis 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_duenow takes an optionalterm, matched against the generatedfollow_ups.search_tsvwithplainto_tsquery(AND semantics, deliberately not the recall-first OR thatrepo/search/tokenize.tsbuilds 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 ofread_duerather than a new retrieval field, becausefindCandidatesreturns 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 underlib/ai/agent, and the agent turn was the only volatile prompt in the product that reasons about time and carried notodayLine(). The user's zone is read once per run (agent/clock.ts, degrading toSchedulePrefs.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 as04: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.tsgrew from 3 cases to 13, all driving the whole loop and asserting on the NEXT PROMPT, plusrepo/tasks/queries.term.test.tsagainst 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. Theread_dueaction fetches ONE CAPPED PAGE of the open follow-up list (AGENT_MAX_FETCHED_DUE, five times what it renders), andlistTaskssorts 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 afilter, 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 owncount(*)(countUndatedOpenTasks, same workspace swap and sameopenpredicate as the list it accompanies, run sequentially inside the SAMEwithUserDbscope so it is never a second tenant checkout), andobserveDuetakes 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 newdue.test.tsdrives 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 thetasks.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.turnshad 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 anask) 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.historycarries the stored turns, both chat entry points populate it (the filing path from the read it was already doing; the ask path from theprovider+externalUserIdthatinbound/ask.tshas 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 onecache_controlbreakpoint, 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, andAGENT_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:readConversationalready 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 inagent-conversation.test.tsnow 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 readturns.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 intoaction, 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.parseTurnreads 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.rememberAskTurnsthen appends the ask exchange with noaction, noturnsSpentand nopending, 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_atstill rolls forward on every append andmessaging_conversationsis still inACCOUNT_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 notask), 3 ask-path cases (the second question sees the first, the turns are stampedaskand carry noaction, the palette writes nothing and still answers), and 2 inagent-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 oneextractagainst a nine-property schema, wire-measured against the 32-property compiled-grammar ceiling that has taken inbound messaging down twice (budget.test.tsstarts fromoutputFormat, 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'szodOutputFormatin fact rewrites every enum intotype: "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 newinvocationTimeLeftMson the same store — never a second clock) are the two terminators; the gate iswithinDollarCeilingand neverassertAiBudget, whose first act is to spend a token from the shared burst bucket and which could therefore refuse the very question it protects (themessage_intentprecedent). 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, behindDHAGA_ASK_AGENTand 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 acrossloop.test.ts(every stop condition, each asserting both that it stopped for the right reason and that it still said something) andstrategy.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, bypassingparseAgentTurn, dropping the invocation-clock check, and filtering the zero clause reports out of the observation. Pending: nothing persiststurnsSpentyet (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 thesearchaction 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 sameDHAGA_AGENT_FILINGflag 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.matchPendingAnsweraccepts 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 — sofile_note_on_contactre-points that row: same id, same body, samesource_item_id, and the card is then closed through its own applier, which adopts the same row idempotently (adoptUnfiledNotetreats "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:mayCreateContactis FALSE until we have asked, because on an answer an unfindable name is a spelling to check, not a person to invent.Sundeep/SandeepandSukhwinder/Sukhvinderare as often two people as one, and the existingsawCandidatesgate 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'sbatch_planaction 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, behindDHAGA_AGENT_FILING, off by default. The bug:routeIdentityfilters the names SQL found through the predicates that decide a write, and when every candidate is rejected the pool empties,routeNoteCaptureanswersconfirm_create, and the apply step minted a brand-new contact with no question asked.<First> siragainst 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:attachandconfirm_ambiguousare 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'saction— never by the model, which must not be able to vote itself permission to mint a duplicate), and the exchange is written back withturnsSpent, 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 onebatch_planAI 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 inlib/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 —PlannedPersonhas nofollowUpsfield 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, andgeneralTaskIdsis 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) —guessNamesalso 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.findBatchCandidatesnow 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.findRelationshipCandidatessearches 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 exitedturn_cap:AgentStopReasonhad no member for a provider failure andfinish()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, becausecomposeAgentReplyspecial-cases onlyaskedand 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_spentis the only figure stored, so an outage and a genuine ten-turn burn were the same record, and the stored turnactionsaid"answer"— an outage recorded as having answered.provider_failedis 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 adviceturn_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 fromturn_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 onaction: the stored turn gains astopfield, becauseactionis 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.parseAgentTurnwould 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 newmessaging_conversations.stop_reasoncolumn would have needed an appended DDL chunk wired intoddl/index.ts— the failure mode that 500'd production once — to store what the existing column can already carry, andmessaging_sessions.erroris unreachable from here because the filing path deliberately never throws past its own boundary. Tests: 4 new vitest cases, since thefailedbranch had no coverage at all — a throwing provider reportsprovider_failedrather thanturn_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 — reportedturn_capafter one turn, which is the same lie; it needed a reason of its own, because folding it intoprovider_failedwould 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 withansweroraskand leavereplyempty or null, because the schema permits it (replyis.nullable()), so the provider call SUCCEEDED — what came back is a verdict with no words in it. That exit also returnedturn_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_spentis the only figure the audit stores — and on WhatsApp and Telegram it fell through tocomposeAgentReply'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_replyis now its own stop reason, and deliberately NOT folded intoprovider_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/_PREFIXon the loop path, which still hands over what was found when the hollow turn arrived after real work, andAGENT_EMPTY_REPLY_UNFILED_REPLYon the filing path, leading with the note being saved and unfiled. No schema change and no DDL chunk:AgentTurn.stopis a free string thatparseTurnreads back withasString, so the reason persists exactly asprovider_faileddoes, and the run again names no action — nothing was chosen. Tests: three new vitest cases and one rewritten — the case that used to LOCK INturn_capnow assertsempty_replyand 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 anactionnow 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 — onlyextraction_jobs— butmessaging_sessionsalready IS a durable one: a batch is claimed by a single conditional UPDATE toprocessing, items carryprocessed_atso 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/flushgained a targeted mode (maxDuration = 300) that registers one already-claimed batch inafter()and answers 202 immediately — a genuinely fresh invocation, becauseafter()cannot escapemaxDurationand 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.handOffSessiontherefore has three outcomes shaped entirely around never processing twice — a non-2xx answer (or noCRON_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 toMESSAGING_PROCESSING_STALL_MINUTESof latency, and is only safe because the route answers 2xx after the work is registered — every rejection sits before theafter(), and a malformed body returns 400 rather than letting the caller believe a batch was taken that nothing ran. Origin isVERCEL_URLthenNEXT_PUBLIC_SITE_URL, neverSITE_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 leftopenis 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_MINUTESwas 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 anote_subjectcard over the rowparkForAgentalready wrote and queue the chat's copy, so a bare name reachesanswerWithAgentand 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, whileprovider_failedandrefusedraise 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 becomesask, and that is the load-bearing decision:askis the single key that opensmayCreateContact, 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-healthcounts 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 leftstatus = doneand looked fine — plus anything stuck inprocessingor stillopenpast 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, importingtenant-sweeprather than copying it;openAdminConnection(EE-internal) andnudgeClaimDb(whose contract admits only statements that cannot name a tenant) were both considered and rejected. It emits counts and codes only — even the storederrorstring 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> siragainst a stored<First> <Surname>the loop returnsfile_note_on_contactrather than asking, with the new guidance stripped out as well — so §6.2 ofdocs/AGENTIC_MESSAGING.mdand the model's actual behaviour disagree, and that wants a decision rather than a quiet edit. Also open: theaskedexit 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_actionsshows nine sequentialcard_scancalls spending 46s of the 65s usable invocation (90s budget − 25s reserve), onebatch_plan, and then eight "filing decisions" in 0.7 seconds with noai_actionsrows at all — every planned person hit the pre-first-turn budget check inlib/ai/agent/loop.tsand returnedinvocation_budgetwithturnsSpent0. 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 wasconfirm_create, which beforeDHAGA_AGENT_FILINGfell straight through tocreateContact— needing no model and no clock. NowcreateWhenLoopNeverRanputs that back when the loop provably never ran, under five conditions, and the fifth is the whole safety argument:pool.length === 0is vacuous here, becauserouteNoteCaptureonly saysconfirm_createat a candidate count of zero, so it is true on every call including<First> siragainst 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 intoapply/identity/create.tsso both callers share one writer rather than two that can drift. (2) The clock-out borrowed the wrong sentence.provider_failedandempty_replyalready had honest strings;invocation_budget,turn_timeoutandaction_timeoutfell through to the catch-all and blamed the sender's message for our clock, andrefused(no provider, or over the dollar ceiling) discarded the loop's own accurate reply. They now shareAGENT_OUT_OF_TIME_UNFILED_REPLY— one string because the news and the ADVICE are identical, whileturn_capdeliberately stays on the old wording since there the model really did look with every turn it had — andrefusedUnfiledReplywraps 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 instop. (3)buildBatchSummaryspreadagentRepliesraw — 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 aSetunder the comment "five unreadable photos say it once, not five times".repeatedAgentReplyLinecollapses identical replies onto one line with a count, not a bareSet: 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 stampsturnAction: "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_noterefuses an owner-less note). All four unfiled replies now point at/app/lists#unfiled-notes(/app/plan#unfiled-noteswhen 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 fromUNFILED_NOTES_HREFrather than a hardcoded path. And the cause underneath:deriveBatchwalked items serially, so nine ~5s vision calls were 46s; it now runs a bounded worker-cursor walk atMESSAGING_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). RaisingmaxDurationwas evaluated and rejected: nothing in the tree pins the deployment's real ceiling, andinvocation-budget.tsargues 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 istoBe(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_NAMEand 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) — withDHAGA_AGENT_FILINGon, the loop was handed only the card's name andcreate_contact_and_fileminted the person blank, so a vCard's phone survived only as a fact in the note. The parsed card now rides onAgentWriteContext.card: a create writes it viacreateContactProfile(under the agent's name), a file onto an existing contact merges it add-only viaaugmentContact. Telegram cards share the path. Covered bylib/ai/agent/write-card.test.ts+ two cases inagent-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'ssource_item_idand the capture log'soutcome.noteId— no migration), and a card merged onto the wrong existing contact follows its note onmove_note: the merge's exact additions ride the turn ascardMergesand 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 apositionsrow); 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 plainError, no message, no HTTP status. Noai_actionsrow existed for that plan call, and sinceplanWithAirecords only afterextract()returns, that alone provesextractthrew. The only plainErrorthat fits isrunExtract'sparsed_output == nullthrow: an SDK error would have carriedstatus(whicherrorSummaryprints), a DB error acode, andoutput-format.ts's three throws all fire synchronously before the HTTP call — microseconds, not the ~25s observed.planMessagingBatchpassed nomaxTokens, so the widest structured output in the product had to fit insidesync.ts's 2048 default. Measuredbatch_planrows 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: theextracttier isclaude-sonnet-5and the request sends nothinkingfield, so adaptive thinking runs by default and its tokens count againstmax_tokens— 2048 was being shared between reasoning and the JSON, and the JSON is what got cut off. The ceiling is nowBATCH_PLAN_MAX_OUTPUT_TOKENS= 16384 — Anthropic's own guidance for a non-streaming request, and well under the 128Kclaude-sonnet-5actually allows; materially higher would require.stream(), whichrunExtract'smessages.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 backgroundafter()on amaxDuration = 90webhook (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 inprocessinguntil 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_reasonwas the single fact that identified this failure, and it was being thrown away:bugMessagekeepserror.messageonly forTypeError/ReferenceError, deliberately, for PII. The throw is now a typedStructuredOutputErrorcarrying the stop reason as itscode— a closed provider vocabulary (max_tokens,refusal,end_turn) that is data-free by construction, and exactly whaterrorCodealready documents itself as reading, sologging/index.tsneeded no change at all; the stored detail now reads… <- StructuredOutputError | max_tokens | <frames>.planMessagingBatchclassifies it into its ownplan_truncatedreason, 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_LABELSis aRecord<MessagingBatchFailure, string>, so the compiler demanded the new key, whileBATCH_FAILURE_REPLIESis aRecord<string, string>that would have silently fallen back — which is whyoutcomes.test.tsnow asserts that every code in the vocabulary renders as itself in both surfaces. No migration was needed:messaging_sessions.erroris plaintextwith 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_truncatedis 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 belowMAX_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 surviveserrorSummary— the test that would have saved the database dig — truncation is distinguished fromrefusalandend_turn, an unrelated error that happens to carry acodedoes 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:messageCorrectionSchemacarried onlysame_person|different_person,wrongNameandrightName, so "no, create a new contact" arrived as one of the two existing kinds withrightName: nulland hitmissingRightNameReply()— refused for not repeating a name the sender had just been shown. A thirdCorrectionKind,new_person, and deliberately an ENUM MEMBER rather than acreateNewflag: 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 besidedifferent_personwould be two fields able to disagree. It is NOT inferred fromdifferent_person+ "nobody by that name", which is the tempting one-line version: reading an instruction out of an ABSENCE is the same reasoning errorcorrection/replies/nobody.tswarns 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 typedrightNamewins; otherwisechooseNametakesContactTarget.contactNameon the filed path orParkedTarget.subjectNameon 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 readingpendingParkedNoteBEFORE 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.tsand quick-add hold only IDENTITY predicates, which cannot answer this — plus aCORRECTION_NEW_CONTACT_NAME_MAXof 80 (isNameLikebounds words, not characters) and theUNNAMED_CONTACT_NAMErejectioncreate-fallback.tsalready 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 feedsrecentNames, so the classifier only sees a name as disputable if the batch audit holds it, andresolveTargetbounds itself to the same rows. A correction writes to the graph and never to that audit, sonot <newname>would classify as capture, or at best answerunknownTargetReply. 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 stampedContactSource "messaging"rather thanwhatsapp/telegram, becauseapplyMessageCorrectionis not handed the provider and threading it down is a caller-signature change throughinbound/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 theisNameLikegate, removing the empty-note guard, routingdifferent_person's no-match to create, removing thependingParkedNoteguard, removing the namesake exception, removing the length cap, and blanking the general branch'srightNameeach 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) —noteshas had four owner columns for as long as groups have existed andassertSingleOwnernames 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 WIDENEDfileUnfiledNote.fileUnfiledNoteOnGroupmoves the row — same id, same body, samesource_item_id, so the capture log's link keeps resolving and the messaging re-drive stays idempotent — wherecreateSharedNote, 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_upspermits 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. Nonote_contactsrows, 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 ownfinishGroupNoteAnswer, but no vector (embeddingscarries 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:applyNoteSubjectGroupignoredpayload.apply.noteIdand calledcreateSharedNoteunconditionally, 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 whatunclear.subjectNameasks 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, andresolveGroupSubjectmatches it case-insensitively, exactly, in code overlistGroups— already workspace-scoped and already archive-filtered, so it cannot drift from what/app/groupsshows. Match only, never create (a group is a lens the user built; minting one off a transcribed voice note puts a row in/app/groupsthey 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/planis 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 throughfollow-up-scope.ts,TaskRoworPlanListin 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_HREFis composed, never written out, so the note permalink's redirect (app/app/notes/[id]), the search-hit href forunfiledNote, 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-notesanchor 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:UnfiledNotesSectionreturnsnullfor 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.loadPlanPageloses a read — one fewer serial round-trip on a page whose entire design is having fewer — and theunfilednode prop comes offPlanBoard. Tests:nav.test.tspins 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.tsalready demanded route copy for every href the landing mock's nav can emit, which is why/app/listsgains aLANDING_PREVIEW_ROUTE_COPYentry 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 ofdocs/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 atopicfrom 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 nodelete_contactfor a prompt injection to aim at. It is absent fromAGENT_WRITE_ACTIONS, soisWriteActionis false, the write dispatch is unreachable, and./app-only.tsimports 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 realpage.tsxand 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/settingsand/app/shareall 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 byparseAgentTurnand 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 inlib/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 onanswer; 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 ofdocs/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) andmove_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 —actionis 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) andmergeSourceId. 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 withtoBeagainst the artefactoutputFormatactually 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 calledmerge_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 oneUPDATEmatched 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_notesandread_duerendered no ids at all, sorecordIdhad no reachable argument and neither act was possible — both now renderid=in the shapesearchalready used. And the reply composer had to stop sending Tier-B receipts throughfiledReply, 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 stampedconfirmrather thanask, or the next message's "yes" would have opened the gate on minting a duplicate.move_notereusesmoveNoteToContactwhole — it re-points the note's receipt cascade and then rewrites graph edges and embeddings onto the new person, proved against a real database inlib/__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 inlib/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 rideDHAGA_AGENT_FILING);delete_recordcannot see adoneor 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 ofdocs/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 acalendar_event_linksrow keyed on (connection, provider event id) — nothing is stamped on the event itself, noextendedPropertiesand 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_idisNOT 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 loopmirroredEventKeys()was introduced to close. So the act isput_reminder_on_calendar— it takes a reminder id the loop was already shown and callssyncFollowUpToCalendarsunchanged. 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 withtoBeagainst the artefactoutputFormatactually sends, because its whole argument is therecordIdthatdelete_recordalready carries — awhen, atitleand acalendarIdare 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 sendssummary/description/start/end, and nosendUpdatesorsendNotificationsparameter 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@slugprefix in deterministic code before the model runs, proved with amember-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_rlsdoes 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, leavingmove_out_of_organization— a refusal's subject — as the only organization word in the vocabulary. Tests: 11 vitest cases inlib/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 nofetchis 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.tsfor what may be named,tiers.tsfor 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_upshas no index ondue_date,getDueFollowUpRemindersForUserloads 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_sendsis 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.nudgeLeadMinutesgained 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.itemMinutesis still passednullat 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[] | nullis new, Google'sEVENT_FIELDSmask did not requestremindersat all (so Google trimmed them from every payload), and onlymethod: "popup"counts — anemailoverride 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.nullmeans "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. Soreminderandsnoozejoined the intent classifier from #310 and divert around the batch exactly asquestiondoes. A duration pastQUICK_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.fireAtfor 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_MINUTESis 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/deliverroute makes it honest to the minute.NUDGE_GRACE_MINUTESstays 30 regardless: it is sized against the reader, not the cadence. SNOOZE NEEDS NO CONVERSATIONAL STATE: the sweep already recordssent_at, so the most recent sent row insideSNOOZE_WINDOW_MINUTESIS the referent — the same window-not-state-machine shape corrections use.nextMorningis 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 withinstantFromZonedWallClockoverzonedDayRange'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.dueScheduledSendsmust run un-scoped — it has to find due rows before it knows whose they are — so without aWITH CHECKpolicy one tenant could queue a row naming another tenant'sfollow_upid, 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 againstcreated_at, not the clock alone (2026-09-08): a snooze queues a SECOND row for a task whose due has already passed and deliberately leavesdue_datewhere 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, withsent_atstamped 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/digestwas renamed/api/jobs/tickin 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.jsonwas 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*/15made "remind me in 2 minutes" land up to 13 minutes late: the quarter-hourly tick also runs the O(tenants) digest sweep andmaterialiseNudgeswith 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/tickstill calls delivery too, so an operator who adds only the*/15line 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, andhostedTenantIds()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*/15cron landed on 2026-09-04, and delivery only became minute-accurate when/api/jobs/deliverwas 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.tsis 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 throughreadExternalEvents, 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.tsis O(ROWS DUE) — ONE indexed, deliberately un-scoped claiming read (claimDueScheduledSends) across every tenant at once, grouped byuser_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 stampingsent_atwas 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 newclaimed_atcolumn in the sameUPDATE … RETURNINGthat reads the rows,FOR UPDATE SKIP LOCKEDon the bounded inner select, so a concurrent run sees none of them.claimed_atis the LOCK andsent_atstays 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 afterNUDGE_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 — thenschedule_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 kindnudge(WHATSAPP_TEMPLATE_NUDGE) for chats outside Meta's 24-hour window, and its approved body must carry no{{n}}placeholders at all — the sweep sendsparams: []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 fromapps/web/vercel.jsonbecause 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/tickon*/15 * * * *, and/api/jobs/deliveron* * * * *. 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 (nopackages/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 calldaily-brief/sweep/target.tsalready makes for the chat channel, where a null id skips chat delivery rather than inferring "the instance's only user". Materialise reportsskipped: "self_host"and delivery finds nothing routable. Covered bylib/jobs/nudges/sweep.test.tsandplan.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_sendsis inTENANT_TABLES, sopackages/eegives itALTER 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 noapp.current_user_idset the policy'sUSINGmatched nothing: theUPDATE … RETURNINGstamped no rows, returned none, reporteddue: 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-localapp.bypass_rlsthe 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 sameopenAdminConnectionthe admin panel and the Stripe webhook use), reached throughlib/repo/scheduled-sends/delivery-scope.tsand 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 viawithUserDbfor the follow-up wording and the schedule preferences. The marking had to move with it: a tenant-scopedUPDATEof akind='follow_up'row whose task was completed or deleted fails the table's ownWITH CHECKwith 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.tsreplays 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.tspins the GUC as transaction-local (is_local = true, inside oneBEGIN…COMMIT, no sessionSET, noRESET 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.tspins 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.
sendScheduledChatMessagenow reportsnoChat(no identity, or only identities for a provider this deployment lacks) apart fromblockedand from a failed send. AnoChatnudge 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 asundeliverable(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/capblockedstill emails. Claim,sent_atreceipt 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 newproactive_chatplan feature, checked once insendScheduledChatMessagebefore 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_chatis 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 ismulti_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.tsprices a send by the recipient's E.164 calling code (longest-prefix, the same rulerateForuses for model snapshots), and an unknown market fails towards EXPENSIVE, landing on the dearest rate in the table exactly asMOST_EXPENSIVE_RATEdoes inlib/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 awa_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 newwhatsapp_send_coststable, deliberately NOTai_actions. The shape fits and the meaning does not:aiCreditsUsedThisMonthcounts every row in that table and multiplies by a credit price,costOfAiActionprices 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 reasonai-cost-accounting.tsgives about itsDEFAULT 0columns: 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 enterslib/messaging/costand leaves as a calling code and a price, and nothing downstream is given the chance to log it. TENANCY: per-tenant, the opposite call towhatsapp_templateson 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'sTENANT_TABLES(with a row-isolation spec) and IS inACCOUNT_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 declaresuser_iditself, likescheduled_sends, because the monthly-count index has to name the column. THE FALLBACK IS ONE PATH FOR BOTH REASONS.sendScheduledChatMessagenow returns{ delivered, blocked }, andblockedseparates 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.digestChannelsis{ email, chat }wherechatalready means "whichever provider is linked", so there is ONE control to grey — wrapped in the existingPlanGateNotice(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 inutils/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*/15tick and the per-minute/api/jobs/delivercron 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_TOKENis marked Sensitive in Vercel and sensitive values are write-only —vercel env pullreturns 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 inwhatsapp_templates(unscoped, deliberately absent from EE'sTENANT_TABLES— it describes our Meta account, not a tenant's data), onlyAPPROVEDreachesOutboundTemplate.approvedName, and theWHATSAPP_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 (kindspages_*), and the env-gated Pages submitter that requiredWHATSAPP_BUSINESS_ACCOUNT_IDis 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, orNODE_ENV=productionoff Vercel). Still not run on Dhaga Cloud: itswhatsapp_templatestable is empty because the stored token lackswhatsapp_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 instrength.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 knowncontactIdthere is nothing to plan and nothing to search, solib/ai/contact-ask/reads the bundle directly the waylib/ai/brief.tsdoes — full note bodies instead of four ranked snippets, no lossy filter to leak or drop a record, and one fewer model call (hencecontact_ask= 1 credit againstsearch= 2; derivation inpackages/core/src/metering/credits/table.tsand BRD §8.3). Non-streaminguseActionState+ThreadLoader, matching the page's existing Brief/Draft sections rather than porting the palette's NDJSON stream; the answer renders through the palette's existingAnswerMarkdown(it is Markdown — awhitespace-pre-wrapcard 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): onewithUserDbfor the whole read, a second short one forassertAiBudget, the LLM call with no scope open, a third short one forrecordAiAction. Context is budgeted — notes newest-first toCONTACT_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. NorequireFeaturegate (the global Ask has none; Brief'spre_meeting_briefgate is a decision about briefs, not a house style), reuses the shared"ai"rate-limit bucket viaassertAiBudget. Prompt contract covered bylib/__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 inprompt-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 SAMEmeetingrow 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 fromgetContact(name,lastTouchSql) andlistOpenFollowUps(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.OutboundTemplateKindgained 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.sendScheduledChatMessagegained an optionalrichTextalongsidetext;deliverToIdentity(lib/messaging/outbound/deliver/) uses it ONLY when the channel has nosendTemplatemethod at all (Telegram today), in-window or out, gated or not — a template-capable channel always getstext, 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 INlabel, NEVERrefId.labelwas 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 newmeetingLeadMinutes(default 30) alongside the existingnudgeLeadMinutes(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 sameschedule_prefsblob and the sameNudgeLeadSettingcard, 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 addscalendar.readonly+calendar.app.created(the latter confines the app to calendars it created — a much narrower ask at verification); Microsoft goesCalendars.Read→Calendars.ReadWrite, andCalendars.Readis 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 omitsscopewould 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 inafter()in three separated phases (DB → network → DB) so a tenant connection is never held across an HTTP call.calendar_event_linksmaps follow-up → event because Graph refuses client-chosen event ids; it is in EETENANT_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 mockedfetchonly, and the consent/upgrade flow is entirely unexercised; no browser render or screenshot; thelib/repo/confirmations/apply.ts"supplement" path now propagates follow-ups to the calendar like every other path (it calledapplyExtractionand nothing scheduled the write-out); note thatapplyExtractionwritesdueHintand leavesdueDatenull, and an undated follow-up produces no event — so neither this path nor note-extraction places one until the user dates it; typedoccontent/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.cssis 187 lines, over the 150-line rule (a side-effect-imported CSS theme cannot be split behind anindex.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.tsnow shows the synthetic provider only onDHAGA_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.exampleanddocs/SELF_HOSTING.mddocument the flip; the example file still shipsDHAGA_CALENDAR_DEMO=truebecause it is a dev template -
Imported events are clickable; per-event colour (2026-08-13) — built, not browser-verified.
ExternalEventDialog.tsxopens read-only detail on an imported event: title, when, location if present, andcalendar · 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 nohtmlLinkand the provider URL never crosses the RSC boundary. Attendees are dropped at the repo and forbidden by the props type. Events stayeditable: false, startEditable: false;isFollowUpEventPropsis 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-eventcolorId(mapped through its fixed 1–11 palette) beats the calendar's colour, and the user'suser_coloroverride beats the provider'sbackground_color— a meeting the user tinted in Google keeps that tint here.event-map/colors.tsdiscriminates 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 axiswriteAnyCalendaronCalendarCapabilities, kept a SEPARATE token list fromwriteEventsrather than a widening: Google'scalendar.events(SENSITIVE, pulls the app into verification) is required for it and is deliberately absent fromGOOGLE_WRITE_SCOPE_TOKENS, so a connection that upgraded before this shipped deriveswriteEvents: true, writeAnyCalendar: false— keeps writing to "Dhaga", needs no reconnect, and unlocks the picker only by re-consenting through the same?upgrade=1link. Microsoft derives all three axes fromCalendars.ReadWritebecause Graph has no app-created-only scope — reporting it false would hide a control while changing nothing about what Dhaga could reach — but with nolistCalendarsthere is nothing to pick, so behaviour is unchanged.connectionCapabilitiesgateswriteAnyCalendaronwriteEvents, never on the scope alone.ensureWriteCalendarthrows 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. UICalendarWriteTarget.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_connectionsrow is one per ACCOUNT; until now every read went to Google'sprimary, so anything on "Work" or a shared calendar was invisible.calendar_calendars(lib/db/schema/calendar-calendars.ts, DDLlib/db/ddl/calendar-calendars.ts) now holds one row per calendar, populated by the new optionalCalendarProvider.listCalendarsvialib/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) anduser_color(a palette key overriding the provider hex) —upsertProviderCalendarsupdates 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_reconnectonly when every target fails). Settings UIcomponents/app/settings/CalendarConnectionsSetting/{CalendarList,CalendarRow,CalendarColorPicker}.tsx, optimistic toggles. Microsoft does not implementlistCalendars, 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, DDLlib/db/ddl/calendar.ts, enrolled in EETENANT_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). Repolib/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); actionslib/actions/calendar-events.ts; dialogcomponents/app/calendar/LocalEventDialog/with title (required), all-day, start/end, optional location + notes, and an optional colour from the 8-tokenEVENT_COLORSpalette (token persisted, never the hex). All-day ends are stored exclusive (pinned byLocalEventDialog/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.tsrevalidates/app,/app/calendarand/app/tasks— the last two are now redirect stubs, so the real/app/planpath 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, repolib/repo/calendar/hidden-events/, enrolled in EETENANT_TABLESwith an isolation spec) stores one row per hidden event: a connection FK (ON DELETE CASCADE, so disconnecting an account takes its hidings with it) andevent_key. The key is the PROVIDER's identity, never ours — byte-for-byteExternalCalendarEvent.id, i.e. connection + calendar + provider event id — because Dhaga persists no row for a connected-calendar event:/app/planreads 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-timeevent-{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.SnapshotCalendarEventgained akeyfield 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 inbuildCalendarMomentsbefore 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 —externalEventsstill suppresses the free/busy interval underneath, or hiding "All-hands" would swap it for "Connected calendar · Busy" in the same slot — andbusyis untouched, sodayLoad/findOpenSlotsstill know the user is in that meeting. Reversible and visible, deliberately not anotherfollow_ups.dismissed: the toast carries Undo, and the event stays on/app/plandrawn 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 untillg) and the toggle inExternalEventDialog; both sharelib/hooks/useHiddenCalendarEvent.tsso 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 intoloadDashboardData'sPromise.all. Scope: one row hides one OCCURRENCE, not a series — both providers expand recurrences before we see them (Google reads withsingleEvents=true, Microsoft reads/me/calendarView), so a provider event id names an instance and there is no series id on the normalizedCalendarEventto key on; hiding every Monday standup at once needs that id first, and is additive here (ascopecolumn defaulting tooccurrence). Covered byDashboardSection/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 inDashboardSection/calendar.test.tspinning 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 sharedContactFormoncreateContactAction; existing contact = the sharedEntityComboboxplusaddGuestToContactAction→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) gaineddescription,joinUrlandwebLink, all required-and-nullable so a provider cannot silently omit one. Google mapsdescription/hangoutLink→ first videoconferenceDataentry point (never a dial-in) /htmlLink; Microsoft mapsbody/onlineMeeting.joinUrl→ legacyonlineMeetingUrl/webLink, withPrefer: outlook.body-content-type="text"added tographGetso 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.tsis the one place a body is reduced: markup out (neverdangerouslySetInnerHTMLdownstream — 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.tsxandlib/repo/calendar/events/types.tsboth 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.tsxstill 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_idis 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 consultsisConfigured(). 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 —connectedCalendarRowsandhasCalendarConnection, which had additionally counted any row regardless ofstatus, 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 setDHAGA_CALENDAR_DEMO=true—calendar-demo-linked-meetings.test.tsdid not, and its six failures are what proved the gate bites; covered bycalendar-usable-connection.test.ts -
Setup checklist steps are links (2026-08-16) — every step's label in
SetupPanelnavigates 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 anafterhalo 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) testsend <= now, neverstart— 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 inMomentsTimelinein 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 newuseLiveClockhook (lib/hooks/useLiveClock.ts) is seeded from the server'snowso the first client render matches the server's and nothing hydration-mismatches, then ticks everyHOME_MOMENTS_TICK_MS(one minute — moment rows are shown to the minute) and again onvisibilitychange, because a laptop reopened after lunch has had its interval throttled and must catch up when it is looked at. Covered by four cases inDashboardSection/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 ownnowIndicator, 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
/apprequest inapp/app/layout.tsx;countPendingConfirmationsis now wrapped in Reactcache()so Home's strip and the layout's badge dedupe to ONE query —DashboardSection/load.tsand its three-connection ceiling are untouched.PendingConfirmationsStriprenders in both Home layout branches; badges added to the two collapsed nav triggers. The orphanedHomeDashboard.tsx/ConfirmationsPreview.tsxtree was deliberately left unrevived — seedocs/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.tsxhas zero importers and nothing else mounted the list, so a contact who was due a reach-out showed up in Moments and on/app/planand nowhere on the Home rail that was supposed to carry it. Newcomponents/app/home/HomeRail.tsxcomposesSetupPanel→NextDaysPanel→DueCheckInsListas one mount point rendered at every width and imported byDashboardSection/index.tsx, so the two layout branches cannot drift into showing different tiles.HomeDashboard.tsxand the rest of that orphaned tile tree stay deliberately unrevived — a decision recorded in the internal build checklist and audited indocs/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.tsxwraps the existingMomentActionsso the row reuses the actions Moments already had rather than growing a second set, andReachedOutButton.tsxwas deleted (verified gone) becauseMomentActionsalready carries "Reached out" — two buttons doing one job is how they drift apart./app/plancomputes its open slots with zero extra reads, through a new sharedlib/calendar/open-slots.tsthat both surfaces call, so a time offered on Home and a time offered on the plan cannot disagree. Supporting pure helpercomponents/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) —
getFreeBusyskips a calendar it cannot reach, so a TOTAL provider failure returned an empty array indistinguishable from a genuinely clear week, andrefreshFreeBusySnapshotstored that emptiness as FRESH; Home then told the user their week was clear until the snapshot aged out. There is nowreadFreeBusy()returning{ busy, attempted, failed }(lib/repo/calendar/free-busy.ts, exported fromlib/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.WarmPathPanelhad already solved it locally, which is exactly why it kept recurring elsewhere — the echo comparison now lives once, incomponents/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 forDONE. 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 lonedonestill 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 inpackages/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 owncard_scancredit;batch_planis RETIRED (kept only so old rows keep their label). It replaces the old flow for everyone, with no feature flag; theDHAGA_MESSAGING_IDLE_MINUTES/DHAGA_MESSAGING_MAX_OPEN_ITEMSsettings, theprocess-sessionbatch walk and thebatch-planandlist-referentschemas were deleted./api/jobs/messaging/flushnow 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 viaDHAGA_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 keepsDHAGA_CHAT_AGENT_PROFILEatsonnet_low(74% of the owner's labels with 1 critical error, against 54% and 6 for Haiku with escalation; reportdocs/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 nopackages/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 →transcribePhotoNoteread 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 viasaveCardImages, hung off the note so deleting the note hard-deletes the image, behind the same per-usershouldStoreCardPhotosswitch 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 onhasTranscription(); 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 anote_subjectconfirmation carrying the note body, raised withorigin: "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'sfollowUps, andapplyUnclear(lib/messaging/process-session/apply/unclear.ts) writes them immediately as general tasks —contact_id/company_idNULL,user_idset 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), anddue_hintresolved by the same deterministicresolveDatePhrasethe 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 secondfollow_up_dateconfirmation 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 bylib/__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 abodythe 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. SoplannedNoteSchema(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, andassembleNoteBody(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, sinceDerivedItem.textalready 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 intodirectives, 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, notunaccounted, since the plan had mentioned it — sobareis 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'sdirectives. 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— fromsourceItemSeqstoo, or its photo and its verdict would attribute it by another route) and parked as anote_subjectconfirmation offering the claimants, stampedunclearwithreason: "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 governsunclearnotes, which lostnoteBodyfor the same reason — a parked note is the one the user is least able to check, sitting unread until they open the Inbox. Covered bymessaging-cases/verbatim-notes.test.ts,note-directives.test.ts,contested-notes.test.ts,lib/__tests__/note-body.test.tsandlib/__tests__/merge-notes.test.ts. (Themessaging_pending_questionstable 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-addnote_subjectis 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 (clearingcompany, or the profile shows it employing itself), then toUNNAMED_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 anisInstructionflag folded into the existingcaptureExtractionSchemaoutput — 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 + TelegramMessagingClients behind aregisterMessagingProviderregistry), webhooks/api/messaging/{whatsapp,telegram}/webhook(Meta GET verify viaWHATSAPP_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 toDHAGA_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 itunaccountedand 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 viarunMessagingFlush, plus an optional ~15-minPOST /api/jobs/messaging/flushworker route — kept offvercel.jsonso 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 (pluggableTRANSCRIPTION_PROVIDERgateway 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'scontact— 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'sentryKey, 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), andtitle/company/locationare filled only where empty — a stale card can never overwrite what the user curated. A filled job is a realpositionsrow (2026-08-09), not just the denormalisedtitle/company_idthe shortcut used to write: the detail page showed a job the edit form's job list — which readspositions— could not show, so the user could neither confirm nor correct a stale card's employment. Written throughinsertPositionRowsand only for a contact holding NO position, which keeps it add-only and keeps the denorm recompute agreeing with the fill; itssource_note_idreceipt 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 messagesattached. Covered bylib/__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 — thebatch_plancredit price inpackages/core/src/metering/credits.tsis 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 formulti_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 toself_hostedand are unaffected. Pinned bylib/__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.tsxdetects via BarcodeDetector and routes to/app/people/new?linkedin=(prefills the existing manual-add form, no auto-create); mobile:camera-capture-view.tsxscans live via expo-camera,LinkedInQrPrompthands off to the web form viaLinking.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
organizationplugin 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, withcopied_from_idon 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.deleteandorg.note.read-privateare 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 noorganization_idat 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./featurescarries the capability overview;/product-tourcarries the detailed workflow, sample desktop workspace, Ask examples, graph sandbox, comparison and FAQ. The routes remain discoverable from navigation, the sitemap and generatedllms.txtwithout 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 byawait getCurrentUser()inpage.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-tourand/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-readabledhaga_signed_in=1hint usinggetSessionCookie— 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) flipsdata-signed-inwhile the browser parses the HTML, so the swap lands before the first paint — no flash, no layout shift, no extra request.HeaderAuthActionsre-reads the same cookie in a lazyuseStateso hydration matches the patched DOM and client-side navigations between the marketing routes stay right;MobileNavreads it when the sheet opens. Signed in: Dashboard →/appreplaces "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/appbounces to/login, and the/loginmatcher clears it on the navigation that follows sign-out. Covered bylib/auth/__tests__/session-hint.test.ts: the cookie is matched by whole name (a substring check would letevil_dhaga_signed_in=1show 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 inprerender-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 behindDHAGA_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/pendingand reported the product as broken, so the pending-approval queue stopped being the normal first experience. A password signup is approved in better-auth'safterEmailVerification; an OAuth signup arrives already verified and is approved insignup-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./pendingstill 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.approveUseronly writes whereapproved_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'schangeEmailwould 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, anddocs/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 wasemail_verified = falseand 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 thenamecolumn), 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 setsapproved_atback to null — exactly what a refund and a chargeback have always done — and every consequence is the approval gate already reading that column:requireUserIdForPageredirects to/pending,requireUserIdrefuses every server action. What was missing was an operator control over it, soApprovalGategains 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:assertAdminwritesadmin.user.suspend/admin.user.restoretoadmin_access_logbefore 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 nullableadmin_access_log.reasoncolumn 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.cookieCacheis on with a 60s TTL, but it caches the session, and approval is its own indexed read ofuser.approved_aton 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:isUserApprovedlets an account through onis_adminorDHAGA_ADMIN_EMAILSregardless 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, sinceadmin.user.suspendin the log means an account was locked out. (4)/pendingstill 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 bylib/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 inlib/__tests__/approval-guard.test.tspinning 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 fourRAZORPAY_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. Thesubscriptions.intro_offer_id,intro_price_held_atandintro_price_held_bycolumns and thebilling_settingstable stay in the schema unused (never deleted; DDL unchanged). Read-only production counts on 2026-10-06 showed zero subscriptions with an intro cadence orintro_offer_idand zeropaymentsrows ever, which is why the fourRAZORPAY_PLAN_*_INTRO_*plan ids are no longer recognised (inert in Vercel; they can be removed). The founding plan id and the_LEGACYids 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;/pricingkeeps its INR/USD toggle, defaulted fromx-vercel-ip-countryvia the existingpreferredProcessor()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 wascomingSoon: truewith 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/signupwith no badge, and/pricing's schema.org offers now publish the standing prices, derived fromBILLING_TIERSinstead 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 —parsePlanSelectionalready acceptedpower,BILLING_TIERSwas already["pro","power"], and the in-app plan pickers on/app/settingsand/pendinghad been selling Power the whole time, so the only thing "coming soon" ever gated was the marketing copy. ThecomingSoonflag is deleted fromPricingPlan(types/landing/index.ts) because its one reader — the queue-skip footnote inPricingPlanCard— 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 unrelatedcomingSoonin 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 andPRO_FOUNDING_PRICEare deleted, and no surface sells it. Three things survive on purpose. (1) Thefounding_yearlyBillingCadencestring and itsCADENCE_LABELentry — a Razorpay Plan is immutable, so an existing subscriber's row carries that string forever and the plan status line rendersCADENCE_LABEL[current.cadence]; deleting the union member would crash the settings page for exactly those customers. (2) Thefounding_seatstable 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 inRAZORPAY_PLAN_ENV(packages/ee/src/billing/catalog/plan-env/tables.ts) —isSellableCadencerefuses 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 = 6999is likewise kept inai-budget/plan-revenue.tsrather 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_LEGACYreverse-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_LEGACYenvironment variables (RAZORPAY_LEGACY_PLAN_ENV,packages/ee/src/billing/catalog/plan-env/tables.ts) for renewal reverse-lookup only — the same hazardfounding_yearlyis 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
/pricingopens 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,/refundsand/contactnow exist alongside/privacy, all four sharecomponents/legal/LegalPage, are inMARKETING_SITEMAP_ROUTES, and are linked from a new Legal column in the landing footer (before this,/privacywas in the sitemap and linked from nowhere, and the other three did not exist). Unchecked deliberately: the entity name, address, phone and support email areTODOplaceholders inutils/constants/legal.ts, the copy has not been reviewed by a lawyer, and/refundspromises 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/eeneeded) -
docker compose up— multi-stageDockerfile(node:22-slim, standalone output viaDHAGA_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.mdwritten 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
/docshub 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
/pricingroute (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 aPricingPlanCardshared 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 isSoftwareApplication+offers(derived fromPRICING_PLANS) andFAQPage; Organization/WebSite stay in the root layout. Linked from the landing pricing section and the footer, and added toMARKETING_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.
/pricingand 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 overAI_ACTION_CREDITS(scan/quick-add/note/draft = 1, Ask Dhaga/brief = 2, deep research = 20, watchlist scan = 0), mirrored for the marketing bundle inlanding/pricing/credits.ts. A new plan-cardsuitsline 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 (mapExtractionErrorblocks, 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.freeis 10 (FREE_TIER_AI_CREDITS_PER_MONTHderives 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
guidesblog category, plus reusable on-brand MDX visual components (Figure/NodeGraph/FlowDiagram/Timeline/FeatureMatrix/StatStripinapps/web/src/components/blog/visuals, registered inmdx-components.tsx) — written 2026-07-24, builds clean (SSG); pending in-browser visual QA -
SEO gap pass (2026-10-05) —
/trustadded 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 fromdocsDescriptioninstead 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 inBLOG_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_URLset in Vercel Production, and measured LCP / CLS. OrganizationsameAsis 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_PASSWORDdoesn'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")/signupshows a terms checkbox; the social buttons and the submit button used to bedisableduntil 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.signInWithreturns onblockedbeforeauthClient.signIn.social, anduseSignupForm.handleSubmitreturns ontermsBlockedbefore any network call, so no OAuth redirect and nosignUp.emailever starts without consent.nudgeToTerms()scrolls the checkbox into view, focuses it, and rendersTERMS_GATE_NUDGEin arole="status"region referenced by both buttons'aria-describedby, with a two-pulse amber highlight (static ring underprefers-reduced-motion). Self-hosts see no checkbox and no gate. The gate is a UI guard only —beforeUserCreatestampsterms_accepted_at/terms_versionon 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.tsxstill says "The submit button is disabled until this is ticked" - Per-user personal access tokens (better-auth
apiKeyplugin) replacingDHAGA_API_TOKEN, settings UI to create/revoke — flagged 2026-07-07: duplicate of §12's "Per-user API keys", sameapiKeyplugin/settings UI, not SaaS-specific - Multi-tenant data isolation via Postgres Row-Level Security, entirely in
packages/ee— zero query-logic changes inapps/web/src/lib/repo/* - EE boundary:
packages/eeis 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_MODEonly; 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 unlessTURNSTILE_SECRETis set, which is deliberate: mounting the plugin without a secret throwsMISSING_SECRET_KEYinside itsonRequesthook 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'scaptchaplugin is widened from its 3-endpoint default to 9, because the default covers only/sign-up/email,/sign-in/emailand/request-password-resetwhile four other endpoints create accounts outright./api/access-requestsneeds its own direct siteverify call: the plugin is anonRequesthook on/api/authand 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'sEMAIL_REand better-auth's own check exclude<and>, but it is defence in depth on a message that goes to the owner. A local.envcarryingTURNSTILE_SECRETsilently 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(seeSELF_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; theadmin-granted:<userId>comp sentinel (nullstripeSubscriptionId) 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, andchangePlanreturns a discriminated union rather than a void because of it. STRIPE modifies the subscription in place — upgrades immediately withproration_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): withRAZORPAY_UPGRADE_TOPUPoff (the default) an upgrade is scheduled for the renewal boundary exactly like a downgrade — the change is minted withpending_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().planreports it and a newcompedfield says until when, andlib/ai/metering/dollar-cap.tssizes 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:revertScheduledChangecancels an unapproved pending subscription but refuses once approved (the old mandate is already ending and cannot be resumed), andcancelPlancancels 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.tswent with the retired founding offer (§18)) andreconcilePlanStateplus bothdescribe*Planhelpers 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 refusedPATCHhad 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 itsstart_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 intopup-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, theorder.paidwebhook 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 aspayments.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.tspins 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 indocs/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
subscriptionsrow denormalisescadence,scheduled_plan,scheduled_cadence,scheduled_change_atandsynced_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.getPlanSummaryis consequently DB-only, and that is a contract —entitlement-no-processor-call.test.tsmocks bothdescribe*Planhelpers andgetStripe()to throw and asserts five read paths never reach them. The one deliberate live read isreconcilePlanState, called best-effort by the settings page only. It also write-backs thestatus, which is what stops the plan-lapse notice being a dead end: a dropped terminal webhook leaves the row storedactive, soeffectiveStatuscorrectly withdraws the entitlement and emails "purchase to continue" whileactiveSubscriptionRef— which reads the column raw, deliberately, because it is the double-billing guard — still calls it live andassertNoExistingSubscriptionrefuses 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 inreconcile-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 anadmin_grantedrow is never restatused — a comp over a real subscription keeps that subscription's id, so it does reach the call, and taking the processor'scanceledthere would revoke a grant no processor knows about. No background job reconciles drift: between webhooks the stored target can lag (Stripe'scustomer.subscription.updatedcarries only a schedule id, so it leavesscheduled_*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.tsthe sweep,sweep.tsthe one row's send-then-mark) +lib/email/plan-lapsed.ts, wired into/api/jobs/dailyunder aplanLapseNoticeskey. ONE gate call for the whole instance, deliberately not thehostedTenants()+withUserDbfan-out the daily brief needs:subscriptionsis 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 withoutpackages/eeruns the job and finds nobody). Idempotency is the newsubscriptions.lapse_notified_forcolumn, NOT the per-local-day record inlib/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 latercurrent_period_endmoves past the stored value), so there is no cleanup job and noDAILY_EMAIL_JOB_KEYSentry. Commit only after delivery:markLapseNotifiedruns strictly aftersendEmailresolves ok, so a Resend outage costs a retry tomorrow rather than a permanently swallowed notice; a nullcurrentPeriodEndmarks withnow, 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) andincomplete(a checkout whose first charge never settled — the plan never started, so it cannot have ended). The email is transactional: plainemailShell, no opt-out footer, because no Settings toggle silences a billing state change (pinned bylib/__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 fromPLAN_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 fusePLAN_LAPSE_NOTICE_BATCH_LIMIT(200), oldest-lapse-first. Covered bylib/jobs/plan-lapse/index.test.tsandpackages/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.planis the STORED tier (what they HAD) whilesummary.statusis the EFFECTIVE one (whether it still grants anything), so a storedpro/powerthat is no longeractiveis exactly "was paid, now free" — and it is the only pairing that separates that user from one who was never paid, sincecurrentPlan()collapses both tofree. Nothing in the UI re-derives lapse from a date:effectiveStatusdecides it once, server-side, andgetPlanSummarynow also carries the two row facts the copy needs (periodEndsAt,adminGranted) —current.renewsAtcould not serve, becausecurrentis 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_hostedrenders 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-cachedgetCachedPlanSummarythatcurrentPlanitself 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 amberPlanGateNoticepill, nottext-destructive: a plan boundary is a state, not a failure. Dismissal persists per user in thesettingsk/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 indismissLapsedPlanNoticeAction, 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 storesprowith acurrent_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/unpaidDO 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.incompleteis excluded on both, for the same reason — the plan never started, so it cannot have ended. Covered bylib/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 droppedcustomer.subscription.deleted), the stored status is stillactive, soactiveSubscriptionRefreturns a ref —PlanPickerrenders "Switch to…" change actions andcreateCheckoutUrlwould 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
paymentstable (packages/ee/src/billing/payments/) recording every confirmed charge, refund, partial refund, dispute and failure from either processor: minor-unit integers, the processor's ownoccurred_at, and a UNIQUEprocessor_payment_idthe upsert dedupes on, with asetWhereguard 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 oldsubscriptions.razorpay_payment_idscalar behind a marker row. It exists so a refund or chargeback resolves back to an account without depending on Razorpay copying subscriptionnotesonto 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.tsapplyPaymentReversal) — a refund or chargeback used to clearapproved_at, sending the customer to/pendingwith 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 runningamount_refundedcovers 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 ascanceledso every entitlement read is the free tier, and never touchesapproved_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 arefunded/disputedledger row./refundscopy 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 invoicesubscription_idfield 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):upsertSubscriptionnow refuses a write whosecurrent_endis OLDER than the storedcurrent_period_endfor 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 signedsubscription.chargedcannot 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 — theamount_refundedfield 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 isactiveorpast_duefloors 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 theadmin-granted:<userId>sentinel: comping an existing row PRESERVES its processor ids and setsstatus='active', so a plan comped to unblock a stuckpro/incompletecheckout used to read back as a paying customer and lock the admin out of their own grant permanently.subscriptions.admin_grantedrecords that an admin put the plan there andadmin_granted_over_plan/admin_granted_over_statusrecord 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 inupsertSubscription,updateSubscriptionStatusByStripeIdandpatchSubscriptionForUser), so a settled 3DS charge re-locks the row; a non-paying status leaves the comp alone. Settingfreerevokes the COMP, not necessarily the subscription: a comp over anincompletecheckout 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_grantedbackfilled 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_KEYis 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 toPLAN_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 bybudget-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 theDHAGA_AI_MONTHLY_CAPseed, elseFREE_TIER_AI_CREDITS_PER_MONTH), with active grants added on top (lib/ai/metering/cap/index.ts).DHAGA_AI_MONTHLY_CAPis 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 editsai_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-scopedsettingstable or in EE'sTENANT_TABLES:ai_credit_grantsgets a bespoke RLS policy inpackages/ee(user_id IS NULL OR user_id = <tenant>) because the generic policy would hide instance-wide grants from everyone, andai_budget_settingsgets none (operator config). Grant writes live inpackages/ee/src/admin/ai-grants/(the paginated, name/email-joined ledger read islist-page.ts); a core-only self-host keeps both tables, gets no admin UI to write them, and resolves the cap from theDHAGA_AI_MONTHLY_CAPseed / 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, besideisLapsed, built from the samePLAN_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 (newlapsedoption; a nullcurrent_period_endstill counts active).billing/__tests__/entitlement-sql.test.tsruns the SQL againsteffectiveStatusrow 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-creditsnow 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 (
ceilingForPlanRevenueis shared with the gate so the table cannot drift from what is enforced). Server actions inlib/actions/admin/ai-dollar-cap.ts, components incomponents/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, EEadmin/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_actionsgained nullablejev_input_tokens/jev_output_tokens, added bare and only then givenDEFAULT 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 keepsinput_tokens/output_tokensfor the labelled model only — including folded actions (recordAiActions) and the nightly sector merge, which carries Jev's share inLLMUsage.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 theopenaitranscription 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 samecostOfAiActionByProvider, 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 byrequireAdminForPage("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 anaccount_isolationpolicy +FORCEonly whenDHAGA_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 productionDATABASE_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_ROUTINGmakes every accessor usebypassDb()(packages/ee/src/db/bypass-db.ts, a transaction-localapp.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_BILLINGapplies 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,maxDuration60). Library, not hand-rolled JSON-RPC:mcp-handlerv2 +@modelcontextprotocol/serverv2 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'smcp()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-serverand/.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 thex-api-keypersonal access token that already exists (Settings → API keys — the same key the mobile app and/api/follow-upstake) 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 + theWWW-Authenticatechallenge 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 fromBETTER_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},writesbeing a directory since 2026-10-11; input schemas inlib/mcp/schemas.ts, envelope inresult.ts): reads aredhaga_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 itssourceNoteIdreceipt, ≤ 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 aredhaga_add_note,dhaga_create_contact(onlynamerequired),dhaga_create_contacts(up to 25 people in one call),dhaga_create_follow_up(YYYY-MM-DDdue date optional) anddhaga_close_follow_up(done/dismissed). No delete, merge, export or admin tool, deliberately, and nothing that overwrites (dhaga_create_contactscreates many people in one call but is still additive;surface.test.tsstill forbids any tool name containingbulk) — a confused or prompt-injected client must not be able to trigger the irreversible cascade (contact → notes → facts → edges → embeddings);dhaga_close_follow_upis 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(anddhaga_create_contactwhen given anote) queues the normal backgroundnote_extractionjob at 1 credit, exactly like writing a note in the app, and mirrorsaddNoteActionby checkinghasMonthlyAiBudgetfirst — 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 singlewithUserDbscope, 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 — newmcpbucket inutils/constants/ratelimit/features.ts) after auth so the bucket keys to a real user, answering 429 withretry-after, plus (2026-10-11) a per-user in-flight cap ontools/calland 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 themcpplugin reuses (oauth_application,oauth_access_token,oauth_consent) are appended to the existing auto-applied DDL (lib/db/ddl/oidc.ts, concatenated afterAUTH_DDLbecause all three FK to"user"(id)), so theddl_historyhash gate replays them on next boot — no manual migration step. They are global auth tables, not tenant tables, and are correctly absent from EE'sTENANT_TABLES. EE boundary: pure core. Nothing underlib/mcp/,/api/mcpor/.well-known/*imports frompackages/ee, so nothing is added to theverify-without-eeremoval 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.tspins both credential paths and the rule that a presented-but-invalid bearer token is a hard 401 rather than a silent fall-through tox-api-key— the shape that would otherwise keep a client working after the user revoked its OAuth grant.lib/mcp/tools/surface.test.tssnapshots the exact eleven-tool surface, fails if anydelete/forget/merge/bulk/export/adminverb ever reaches it, and asserts the read/writereadOnlyHintsplit that clients gate auto-approval on.lib/mcp/tools/scoping.test.tspins 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 PKCES256— and an unauthenticatedPOST /api/mcpanswers 401 carrying theWWW-Authenticatechallenge 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, noclaude mcp addrun 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, andnext builddoes 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/mcpis part ofmulti_device_sync, enforced bymcpPlanGateResponse(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 insideverifyMcpToken:withMcpAuthrewrites everything the verifier returns-as-undefined or throws into the same 401invalid_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 answers403 {"error": "plan_required", "error_description": …}. Rate limiting runs first, so a client cannot turn its own refusals into plan-lookup load. Self-hosts resolve toself_hostedand are unaffected. Pinned bylib/mcp/auth-plan-gate.test.ts(both credential branches refused, both let through when paid, self-host exempt, and the code is notinvalid_token). 2026-10-11 pool-starvation fix (built on branchfix/mcp-pool-starvation; unit-tested only, NOT exercised against the real Supabase pooler or a deployed build): a customer bulk-pushed contacts onedhaga_create_contactcall 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 (connectWithRetrystacked on a pool whoseconnect()also retried a localtimeout exceededthat 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) makesPromise.allover one scoped client run in issue order (callback-form and Submittable queries are chained too; the serializer wraps a callback so an erroredpg.Query, which emits no event, still releases the line), fixingCalling client.query() when the client is already executing a query(not applied to the flag-gatedbypass-db.ts)./api/mcpnow has a per-user in-flight cap ontools/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 (withUserDbbudget: transaction-localstatement_timeout/lock_timeout/idle_in_transaction_session_timeoutplus 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 additivedhaga_create_contacts(one scope, a SAVEPOINT per person, per-entrycreated/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 (checkpg_stat_activityon staging after a forced abort), with the server-sidestatement_timeoutas the independent guard. On a core-only install (nopackages/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. Eachdhaga_create_contactsperson with a note queues one 1-creditnote_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, rundhaga_searchfor 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.mdxUpdate 2026-10-11 (surface grew from 11 to 19 tools afterdhaga_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. Addeddhaga_update_contact(PATCH: name/title/company/location,addTags/removeTags,customFieldsupserted by label,removeCustomFields),dhaga_merge_contacts(one target plus 1 to 5 sources, irreversible,destructiveHint),dhaga_delete_contact(HARD delete through the full cascade;confirmNamemust 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 toolsdhaga_list_lists,dhaga_get_list,dhaga_create_list,dhaga_add_list_item,dhaga_update_list_item(code inlib/mcp/tools/{curation,lists}, schemas split intolib/mcp/schemas/).surface.test.tsnow snapshots the 18-tool surface and its destructive-verb check was updated to the new allow-list. Known limits:dhaga_list_contactsreturns tags but not custom fields (usedhaga_get_contact); emails, phones and links cannot be edited throughdhaga_update_contact;dismissedis 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_contactnow edits emails, phones and links throughaddEmails/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 reportschanges);dhaga_list_contactsrows now carrycustomFieldsand bareemails/phones/linksfrom the same single query (opt-inwithDetails, 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 anddhaga_merge_contactsalike): 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/duplicatesclusters 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 reusedcomputeNameClusters(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 ownsfullNameKey(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 — andname-clusters.tsis 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 +computeScalarConflictslive 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 onpositions.company_id, classified by the education predicates) and shared city (contacts.locationtext match), ranked strongest-first (employer > school > city). The shared-event signal was dropped as unsupported —calendar_event_contactsis 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 (predicateknows); Dismiss writes only a veto (connection_dismissals, its own table alongsideduplicate_dismissals). An existing edge is never re-suggested; a dismissed pair stays dismissed. Seedocs/checklist.mdfor the full build note. - "Not a duplicate" on both duplicate finders (2026-08-25) — built, no browser pass recorded.
/app/people/duplicatesand/app/companies/duplicatescould 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 anotherfollow_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 EETENANT_TABLESwith an isolation case) stores the cluster's sorted id set as ONE opaqueentity_idsstring built only bydismissalKey(), plus thereason(email/phone/name) as context, not key — the user never picks a reason.user_idis 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 bylib/__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 incontent/docs/guide/merging-and-bulk-actions.mdx(its own "Not a duplicate" section) andcontent/docs/guide/companies.mdx. Pending: no browser pass;/docs/guide/people-duplicates.pngpredates 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/analyticscomputes 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 bycontacts.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 ownlib/repo/analyticsmodule 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 sharedworkspaceFilterprimitive, 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 indocs/checklist.md§20. - Contact/profile completeness scoring + enrichment nudges (enrichment stays user-triggered) —
lib/repo/completeness.ts'sscoreCompletenessis a pure, read-time function over fields already on the fetchedcontactsrow (same shape aslib/repo/strength.ts'sscoreStrength: 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.ContactInfoCardsurfaces 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 fullMapPayloadfromGET /api/mapand draws a clustered MapLibre point layer: selecting a city point frames it at zoom 11 without pulling back from a closer zoom, and opens aSheetof 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 sharedgeocode_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) untilpendingCountreaches 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 itsCOEP: credentiallessheader even though/appis 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 nowmodal={false}with an opt-inshowOverlay={false}onSheetContent, 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, branchfix/map-payload-geocoding, unpushed) —GET /api/mapused 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 samecurrentWorkspaceFilter, noOR, transaction-localstatement_timeout) and merges those few texts through the ONE JS normalizer — a SQL re-implementation oflower()/\sfollows the database locale and could disagree with the geocode cache key. Each place carries onlycontactCount; the names load on demand from the new authenticated, rate-limitedGET /api/map/place?key=(capped at 200 with the truetotal) 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 tofull.v2. Geocoding: thrown lookups (429/403/timeout/5xx) are still never cached as negatives but are remembered in memory with exponential backoff honouringRetry-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 bymap-payload.test.ts,map-places.test.ts,map-geocode.test.tsand corefailure-cache.test.ts;next buildand the browser sheet are not verified. Postal-code granularity (2026-09-30, branchfeat/map-postal-granularity, unpushed) — a bulk import that wrote only the city (Mumbai) incontacts.locationplus a structured address with a PIN per contact collapsed thousands of people into one marker. A contact whose PRIMARY address (the first entry ofaddresses; 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 oflocation; 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 ofpostalcode,cityand (if present)countryONLY — never the street, building, name, note or any other field; asserted on the outgoing request inmap-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 tofull.v3; poll ceiling raised from 20 to 40 (about 150 postcodes at 1 req/sec take several passes). Covered bymap-postal.test.tsand corepostal-place.test.ts;next buildand the browser are not verified. Offline gazetteer tier (2026-09-30, branchfeat/postal-gazetteer) — the public provider's 1 req/s meant ~1,000 distinct postcode places took ~17 minutes to fill.resolvePendingPlacesnow 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 togeocode_cachewith providergazetteerin batches of 500, cost no request, and are NOT bounded byMAP_GEOCODE_MAX_PER_PASSor 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 anddocs/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 bynode 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 coregazetteer.test.tsandmap-gazetteer.test.ts;next buildand the browser are not verified. Places are prewarmed when locations are WRITTEN (2026-09-30, branchfeat/geocode-on-write, stacked on the gazetteer PR) — a 5-10k-contact workspace should open the map withgeocode_cachealready 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 ownplaceIdentity(postal places also warm their city fallback), skips keys already cached, resolves the rest from the OFFLINE gazetteer and inserts them in oneON CONFLICT DO NOTHINGstatement (providergazetteer; 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 inafter()(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,--writeto insert, insert-only, one workspace at a time,--database-urlexplicit). Covered bymap-prewarm.test.ts(key parity withassemblePlaces, JS/SQLplaceTextOfparity, skips cached, miss writes nothing, one statement for N, never throws, no text in logs) and the script--self-teston 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 buildand 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, branchperf/map-open-timing)** —GET /api/mapnow sends aServer-Timingheader (also on 304) withauth,arm-timeout,location-groups,geocode-cache,unresolvedandetagdurations: phase names and milliseconds only, never a place, workspace or contact (the phase type is a closed set; asserted inmap-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 aset_configinside 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 perfetchMapView, 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'spoint_countis the number of features in a cluster, and since postal grain one pin is a place, so "34" meant 34 places.MapClusterLayertakes an optionalweightPropertyand the map passescontacts, so the label, colour and size follow the summed contact count. Not browser-verified. Postal places poisoned by the network geocoder (2026-09-30, branchfix/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, andgeocode_cachestored 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:NominatimGeocodingClientnow refuses a postal answer that is not positively postcode-grain (type/addresstypepostcode, orplace_rankabove 20) and returnsnull(a definitive "this postcode is unknown"), which is cached as a negative, soplaceIdentityshows 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-urlrequired, dry run unless--write, UPDATE-only, never a delete,--self-teston in-memory PGlite; output is host and counts only). Tell: postal-keyed, providernominatim, 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 becomesgazetteer); 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 bymap-repair-postal.test.ts, corepostal-place.test.tsand the script's--self-test. Not run against Supabase or any remote database (the owner runs the dry run first);next buildand the browser are not verified; theplace_rankcut-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 toMAP_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 withscripts/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, andnext buildand 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 withinGAZETTEER_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;foldPlaceNamealready 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-placesnow 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 smallFilterPanelApiinterface (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 newGET /api/map/contact-places(columnar{keys, ids, place}, workspace-scoped, built with the sameplaceIdentity+ geocode-cache logic asassemblePlacesso keys equalMapPlace.key; unplaced contacts omitted) joins the graph'sGET /api/graph/filtersattributes to the pins client-side (use-map-filters.ts,logic/filter-places.ts): pins with no matching contact disappear,contactCountbecomes 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 byPOST /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 (branchfeat/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 carriesconn/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/fullwithIf-None-Match,buildGraphIndexes; no layout) and adds the columns with the samewithGraphColumnscall 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 onlyGET /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 isPOST /api/map/placewith{key, ids}whereidsis EVERY match at that pin (at mostMAP_PLACE_IDS_MAX= 10,000 UUIDs andMAP_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 atotalcounted 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 bymap-place-ids.test.tsandmap-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) —watchHistoryNavigationnow writes the on-screen filters over it (same page only). Covered byurl-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 belowsm, 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 tocontact_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), andsource === "mentioned"stubs are never pushed out. Link state lives incontact_links(lib/db/ddl/sync.ts+schema/sync.ts; EETENANT_TABLESadds RLS — external ids collide freely across users); a contact missing from the phone only setsstate='unlinked', never deletes a person, note or fact. Server:POST /api/sync/contacts(+/ack), session-or-x-api-keyauth,importrate-limit bucket, whole batch on one scoped connection (db-scope.guard.test.ts); chunked atSYNC_MAX_CONTACTS1000 withobservedExternalIds(capSYNC_MAX_OBSERVED_IDS25000) on the last chunk only. iOS writes into acardDAV/exchangecontainer 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):Containeris iOS-only in expo-contacts and the native insert sets noACCOUNT_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 newobservedEmptyflag carries the positive claim "enumeration ran and found nothing", honoured only when the batch really is empty —full: truewith zero contacts andobservedExternalIds: []are both what a FAILED enumeration looks like, so neither may authorise it on its own (sweep-authorisation.test.tsunchanged and still green);google/microsoftare 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 ingeocoding/nominatim-client, but reachable from a sync payload here because link values are user-controlled), now a linearendsWith/sliceloop; and the name+company dedup key tested for content withreplace("|", ""), which strips only the FIRST separator —normalizeForMatchkeeps 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 byidentity.test.ts). Shipped in PR #135 (merged 2026-07-30). The review queue is now screenshotted (/docs/guide/sync-conflicts.png, seeded byscripts/seed-sync-conflicts.mjs— the page renders only links with a non-emptyconflictsarray, 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/runplays the role the mobile client plays, calling the samereconcileContacts+acknowledgeWrites, with the provider bound behind a plainContactSyncTargetso 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) andmicrosoft-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 separatecontact_connectionstable, notcalendar_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 EETENANT_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(sameCRON_SECRETbearer, fails closed) for a Vercel-Pro or system cron that wants it sub-daily — deliberately not avercel.jsonentry 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 callsrunContactSync(userId, "scheduled"), and the trigger changes exactly one thing — a narrower connection set (run/eligible.ts:auto_sync_enabledon, and not synced within that trigger's floor). Automatic triggers beyond the cron (2026-09-21): the floor is per-trigger —scheduled6h (the cron, a backstop for accounts nobody opens),app_open5 min (the open tab, on open/focus and an interval,components/app/sync/useContactSyncOnOpen),local_edit30s (a contact saved in Dhaga, scheduled fromafter()so no connection is held across the provider call —lib/contact-sync/write-out.ts),manualnone. Both new triggers respectauto_sync_enabled; only the button ignores it. Hooked at the ACTION layer, not the repo writer, becauserepo/sync/write.tsroutes the sync's own PULL throughcreateContactProfile— a repo-layer hook would make every contact pulled from Google schedule another sync. Runs are serialised per connection bycontact_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 unlesstwo_way_syncis separately on.auto_sync_enabledis 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 islast_synced_at— no new table, and deliberately notlib/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 →withUserDbdoesn't scope, so a per-user loop would re-sync every connection once per account); hosted sweeps tenants sequentially vialib/jobs/tenant-sweepwith per-tenant error isolation, and the sweep is deliberately NOT wrapped in a tenant scope becauserunContactSyncopens 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_syncis what says the user asked. Both must hold. The gate lives inapplyWritesat the point of writing, not only in the UI, andReconcileOptions.pushOutwardstops 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 reportsSYNC_NEEDS_WRITE_SCOPEonce 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, solinks/importantDates/addressesare declared unsupported and excluded from the merge via a newReconcileOptions.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 inunsupported-fields.test.ts. OAuth state is shared with the calendar signer but namespacedcontacts:so a calendar-issued state cannot be replayed against the contacts callback. ReusesGOOGLE_CLIENT_ID/SECRETandMICROSOFT_CLIENT_ID/SECRET— no new env vars, but the OAuth app needs the People API /Contacts.ReadWriteenabled 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,tscclean. 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: GooglesyncTokenand Graph/me/contacts/delta, persisted as an opaquecontact_connections.sync_cursor. The sweep is the hazard here — an incremental page is NOT the complete address book, so sending it withfull: truewould unlink nearly every contact. Guarded three ways:modeis a discriminant ("full" | "incremental") that must be spelled at the site that actually enumerated everything, a namedauthorisesSweep(page)predicate replaces the old hardcodedfull: 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 — Googlemetadata.deleted/ Graph@removedare 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/resyncRequireddrops the cursor and re-runs full. Mutation-verified twice; 429/408/5xx and Google's 403rateLimitExceedednow retry with bounded exponential backoff in one sharedcore/src/sync/http.ts(Retry-Afterbeats 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.usableAccessTokensetsneeds_reconnectonly 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 bareErrorintoresult.error, the row stayedstatus = 'connected', and the settings card went on showing a dead account as healthy. A newisAuthFailurepredicate at thepackages/core/src/sync/httpseam narrows the thrownSyncHttpError, andrun/failure.tsreuses the SAMEmarkNeedsReconnectmarker rather than inventing a second state (http.tsbecame 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, andisRateLimited403has already consumed the body that told them apart by the time the error exists — flaggingneeds_reconnecton 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.runContactSyncnever throws (it catches per connection so one dead account cannot stop the others), the action returned only the summedremaining, and the card only toasts on a throw — so the spinner stopped and nothing was said.runContactSyncActionnow returnsContactSyncRunOutcome(remaining+failed+needsReconnect) and the card toastsSYNC_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 acontact_sync_disconnectednotification naming the account (repo/notifications/contact-sync-notify.ts, modelled onjob-notify.ts— its own shortwithUserDbscope 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 selectsstatus = '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/markNeedsReconnecthad zero coverage before this;run/failure.test.ts(401 flips it, 403/429/network do not),__tests__/contact-sync-action.test.tsand two new sweep cases now cover all four. Still pending: no live provider has ever returned a real 401 here — every case is a constructedSyncHttpError, 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-calendaron 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. ReusesfollowUpToCalendarEvent+DHAGA_CALENDAR_NAMEfrom@dhaga/coreso phone and web cannot drift. Three traps handled:findDhagaCalendarrequires title match ANDallowsModificationsANDisPrimary !== 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 afollowUpId → deviceEventIdlink file is the phone's stand-in forcalendar_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 asANDROID_ACCOUNT_NOTICE. Needed a newGET /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/jsonwas 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 undeclaredvitestandexpo-file-systeminapps/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_idisON DELETE CASCADE, so the link died with the contact and the next sync re-imported the person. Fixed with acontact_sync_tombstonestable written as the FIRST statement ofcascadeForget(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.mergeContactsdeliberately 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.offerUnlinkedCreatesalso skipssource === "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 — everyexpo-calendarcall (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,createCalendarsucceeds 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) TheSYNC_MAX_CREATES500/run ceiling is no longer silent:SyncPushResponse.remainingreports 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 bybreaking 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 returnedvoidand dropped the run's results. (b) A large back-catalogue no longer has to go 500 at a time —?scope=authoredon 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.tsis 194 lines and deliberately NOT split into a directory becauseapps/mobiledeep-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-expois missing entirely sonpx expo lintcannot run at all (pre-existing); typedoccontent/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-experimentsrepo,feat/voice-browser-jarvis); real-time in-browser STT (Moonshine, WebGPU) is proven there and is the intendedwhisper-basereplacement 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 byapp/app/settings/sections/general/suggestions.tsxand 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 increateContactProfilewould 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 asectorpicked from a closed taxonomy the user can extend (DEFAULT_SECTORS+ the per-tenantsectorstable, merged bylib/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-optionswhitelist all supportedsectorfully whilePeopleTabledeclared no such column and/app/peopledecoded 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 (anoptionslist 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_IDalready mapped the id, so the picker searches server-side past the 200-option first page with no new plumbing./app/saveddecodes?sector=too — it renders the samePeopleTableand 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 ofclassify-people/, wired into/api/jobs/dailybefore the network-bound tail): a user clicks Suggest sectors (bulk bar or whole-book header button),sector_enrich_requested_atis 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 = oneai_actionsrow = onesector_enrichmentcredit (perrecord-ai-actions.ts:22-27— "THE ROW COUNT IS THE CONTRACT"), so 500 contacts cost 50 credits, not 500.custom_idis the chunk idc:N, not a contact id, so the chunk→ids map is persisted alongside the batch id underSECTOR_ENRICHMENT_BATCH_KEY; theduepredicate refusessector_by = 'user'rows (hard lock, mirroringpersonKindBy) andperson_kind = 'service'rows, and thepoolgate 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 returningnullorconfidence: "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 withunknownCompany: true,sector_lookup_suggested_atis 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 writescompanies.sector, a one-linecompanies.blurb, andsector_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 existingextraction_jobsmachinery (newsector_lookupkind besideenrichment) so it inheritsExtractionStatuslive 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 singleai_actionsrow viawithAiAction("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 inSectorChip(mirrorsPersonKindChip: shows "who decided" and locks withsector_by = 'user'). Bell notificationsectors_enrichedroutes 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 inpackages/core/src/metering/credits/table.ts(sector_enrichment: 1,sector_web_lookup: 4) andapps/web/src/utils/constants/ai-credits.ts. Schema: newsectorstable (in EETENANT_TABLESwith a matching SPECS entry — same call asnode_types), pluscontacts.sector/.sectorBy/.sectorEnrichRequestedAt/.sectorEnrichedAtandcompanies.blurb/.sectorSource/.sectorLookupSuggestedAt/.sectorEnrichRequestedAt/.sectorEnrichedAt(append-only DDL chunkSECTORS_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.tsnever importedSECTORS_DDL, so its nineADD COLUMNstatements 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 aboutindex.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_byandsector_sourceare nullable with no default, deliberately: a pre-existing hand-typed company sector reads as "you", and aDEFAULT '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).dueForSectorEnrichmentopened withsector_enrich_requested_at IS NOT NULL,listSectorEnrichmentPoolselected on it, andquoteSectorEnrichderivedeligibleIdsfrom 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, notsector_by = 'user', notperson_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 oneanchoredIdshelper 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, verdictiresolves correctly),pricing.test.ts(credit arithmetic at boundaries, pluralisation of the cost line, both credit-table entries pinned so a silent drop reads red), andenrich-sectors/__tests__/{requestable-pool,due-pool}.test.tsagainst 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 backusage.server_tool_use.web_search_requestswould 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,listCustomSectorsandsectorUsageCountshad been built with no callers at all, so thesectorstable could only ever be empty andlistSectorNames()returnedDEFAULT_SECTORSforever: 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:deleteSectorreturned a barefalsefor BOTH "no such row" and "still in use", andsectorUsageCountsreturned 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 reasondeleteSectorrefuses 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.tsis now the directorylib/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 inSectorChip's combobox, and whether the company form's free-text sector<Input>should become a picker. Also unchanged and worth knowing:sectorSlugstrips&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 twosectorcolumns 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 — acompaniesbranch on/api/table-filter-options,searchCompanyFilterOptionsbehind aCOMPANY_FILTER_OPTION_COLUMNSwhitelist, and asectorparam onlistCompaniesPageapplied 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 inlib/repo/companies/filter-options.test.ts. Surfacing tests live inapp/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 ownwatched: truescope; 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 readWait for tonight, both carried over fromGoalRequestDialog. 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 requiressector_enrich_requested_at: the paid batch viadueForSectorEnrichment, 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 costsceil(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 readsNot now, and the success toast says "sectors appear over the next few nights" rather than "tonight" because the pass is two-phase andSECTOR_ENRICH_RUN_CAPis 400/night. This is a copy fix, not the missing feature: the free overnight tier that wording described is still unbuilt — theSectorAutoEnrichSettingswitch 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'sSuggestSectorsButtonwithscope: "all", and the bulk bar unfolded a company selection server-side into "every eligible contact at those companies" — while the Sector column below readscompanies.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 (SuggestAllCompanySectorsButtonin the header,SuggestCompanySectorsButtonin the bulk bar, oneCompanySectorEnrichDialogbehind both), which is the value that column reads. The feature id iscompany_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 underCOMPANY_NAME_MIN_LENGTH(3) is import debris — skipped, never billed, never offered. Escalation is unchanged: a company the model cannot place getssector_lookup_suggested_atstamped and appears on/app/companies/unplacedfor 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 inSECTOR_SOURCEsince 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 stampedcompanies.sector_enrich_requested_aton 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 theco.sector_source = 'web'clause deleted. That clause excluded model-judged companies and user-typed ones — a hand-typed company sector issector_sourceNULL beside a setsector, 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 ownsector_enrich_requested_atis 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/dailyrunscompanySectorEnrichmentimmediately beforesectorEnrichment, 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 existingsectors_enrichedkind rather than a company-specific one, a follow-up recorded inprocess-pending-batch/notify.ts; no browser pass on the new dialog in light and dark at 375px; the Companies sector column is stillfilter: 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/ogcard 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/wrappedscope-picker + share modal (download / copy / native-share). Stats repolib/repo/wrappedruns ONE aggregate query (nogetDb()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) + mobilewrappedscreen (RNShare). -
Public interactive graph sandbox (landing, web-only) — a STATIC, anonymized network the visitor drags/zooms/hovers, reusing the
/appsigma 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, andgraph-full.json(21k nodes, ~820 KB gz) only on "Explode to full network". Baked byscripts/export-public-graph.mjsfrom 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 recordsdeferred-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), some+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.
22. Me page & share links (BRD §6.9–6.10)
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/savedshape) 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 ONEme_profilerow (lib/db/ddl/me.ts,db/schema/me.ts, repolib/repo/me/, actionslib/actions/me/, UIapp/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, becauseidis the owning user's id — the same trickreferral_codesuses inpackages/ee. AUNIQUE (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 oneINSERT … 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 onemutation()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 readlimit 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 byupdated_at DESC, id ASCas 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 oneme_templatestable with akinddiscriminator, 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_MAX200 /ME_LONG_TEXT_MAX4000 /TEMPLATE_BODY_MAX20000) 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 andbuildTemplateValuesaccepts 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. Schemalib/db/ddl/share-links.ts+db/schema/share-links.ts; repolib/repo/share-links/; pure helperslib/share/{expiry,token,url}.ts; projectionlib/share/projection/; resolverlib/share/resolve/; actionslib/actions/share-links.ts; public routeapp/s/[token]/; owner UIcomponents/app/share/mounted fromContactActionsCardandCompanyDetailActions. 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 inutils/constants/share.ts, the server clamps toSHARE_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 NULLANDexpires_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 storedfalserather than carried inert, since the row is the audit record of what was disclosed —include_contact_detailsis contact-only andinclude_peoplecompany-only, while facts and follow-ups are clamped by neither, becausefactsandfollow_upseach carry acontact_idor acompany_idandlistCompanyFacts/listOpenCompanyFollowUpsexist 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_NOTES50), facts (SHARE_MAX_FACTS50) and OPEN follow-ups (SHARE_MAX_FOLLOW_UPS25, 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'sconfidence, a follow-up's recurrence schedule and closed follow-ups, and thesource_note_idreceipts 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 tocontactslater 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, sogetDb()would fall through to the global UNSCOPED connection andgetContact(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. Soshare_linksis a cross-tenant routing table with an explicituser_id(inCORE_DECLARED_USER_ID_TABLES, deliberately ABSENT from EE'sTENANT_TABLESalongsidemessaging_identities/messaging_link_tokens— an RLS policy would compareuser_idagainst 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 insidewithUserDb(link.userId, …). No FK tocontacts/companies, twice deliberate: an FK check bypasses RLS and would be a cross-tenant existence oracle, and a plainREFERENCESis 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 danglingsubject_idis 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 therobots.tsdisallow list (trailing slash load-bearing — a bare/sprefix-matches/signupand/sitemap.xml), the page setsrobots: { 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 isforce-dynamicbecause a prerendered copy would keep serving seconds after a revoke. Account deletion covers it:share_links,me_templatesandme_profileare all inACCOUNT_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 wheninclude_contact_detailsis on — built as plain<a href>s rather than scripted downloads, because atext/vcardresponse 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 withrel="noreferrer", so the person's name leaves the page in nothing.GET /s/[token]/vcard(app/s/[token]/vcard/route.ts) builds the file fromlib/share/vcard.ts, a sibling of the export writer rather than a caller of it:contactsToVCardstakes anExportContact extends ContactRow— the raw row this whole page exists to keep away from a visitor — so the shared builder takesSharedContactCardinstead, anddetailsbeing 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 toPick<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 passescountView: falsebecause a sub-resource of an already-counted view must not report the link as opened twice; it isforce-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. TheContent-Dispositionfilename 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 pastexpires_atwith 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 ofconfidence/sourceNoteIdand a follow-up of its recurrence/status — the key-set assertions are what a future...spreadwould 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 literaltrue, 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 NOdetailsproduces a vCard with noEMAIL/TEL/URLline 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.tsxmounts 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 israndomBytes(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/sis secret, anchored to the START of the path so a segment merely namedsdeeper in another route (/blog/s/…) is untouched, andREFERRAL_PATHwas extracted alongside it rather than left as a bare"/r". Position is the property that survives someone re-encoding tokens later; shape is not./wrappedis deliberately NOT in the list (its token is contact-free by construction and/wrapped/ogis 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 inlib/__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_profileandme_templatesare added to EE'sTENANT_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'sSPECS, without which nothing proves their rows are hidden from another tenant.share_linksis deliberately in neither list, for the routing-table reason above.lib/__tests__/me-share-ddl-replay.test.tsproves 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 withoutDATABASE_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/) — aliststable on the templateevents/groups/tags/goalsalready use:name,emoji, a palette-tokencolor(never a hex),parent_idandarchived_at. Nesting is exactly one level, enforced in the repo — a list with aparent_idmay 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_idisON 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_idisON DELETE SET NULLfor 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 nouser_idat 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 noorganization_id(EE's org drift-guard raises at BOOT for aTENANT_TABLESmember that carries it and is absent from EE's org tables) and novisibility;"lists"is registered inTENANT_TABLESinstead. 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:ListHeaderrenders one toggle that says Archive or Unarchive, matchingGroupHeaderexactly. - Phase 2 — items, and the
/planpredicate 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 nullablefollow_ups.list_idwith 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 alimit— 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 aCOUNTis not possible at all. The predicate: a follow-up carrying alist_idOR agroup_idreaches/planwhen, 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 onestatus. Thegroup_idhalf fixes a pre-existing bug rather than introducing behaviour: undated group follow-ups have been landing in/planGeneral all along, becauseTaskItemnever carriedgroup_idfor 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.listIdis deliberately not added toContentOwnerColumns: the follow-up write path never callsassertSingleOwner, 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 nullablefollow_ups.detailtext column, chosen over reusingnotesviasourceNoteId(which buys extraction andsearch_tsvfree 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:andvbscript:cannot match the pattern, and theURL-parsed protocol is checked anyway rather than trusted from the regex, so the day somebody widens the pattern to catch a barewww.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 (findListByNameinlib/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 batchunclearladder gains a fourth rung — company → group → list → ask. Lists go LAST so nothing that files correctly today moves, and the rung sits directly abovecreateGeneralTasks, becauseaddToListcalls that itself and a rung placed below it would write every reminder twice. (b) Alist_addinbound 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_listandmove_to_listfor the in-app agent, both Tier A — not because they are small (move_to_listtakes 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 intotag, which would have cost nothing and taughtadd_tagthat 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 returns400 invalid_request_error "The compiled grammar is too large"and breaks capture for every user, which has already caused two outages.batchPlanSchemais untouched at 32/32 (the rung cost one sentence of prose onsubjectName, which the grammar does not charge for);messageIntentSchemawent 12 → 13 of 32 and gained its ownbudget.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 againstmaxTokens: 500, where a truncation is a Zod miss and a Zod miss is a silent fall-through to capture;agentTurnSchemawent 15 → 16 of 32, the one property being thelistNameboth acts share. - Unfiled is a pinned, protected system list (2026-09-21,
app/app/lists/page.tsx,components/app/notes/UnfiledNotes/) —/app/listsshipped 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 inlists— 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 onunfiledNotes.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/listsis 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 — nolistsrow 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 asourceNoteIdpointing 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 nonotes.list_idand 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 asParent / 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.
attachmentscurrently requires exactly one ofcontactId | companyId, so this needs a newfollow_up_idowner column, a cascade that deletes the bytes with the item (apply/photos.tsdocuments a real silent-data-loss bug from getting an owner↔bytes pairing wrong),organization_id+visibilitywith the "BEWARE THE SPREAD" hazard, reachability fromeraseAccount, 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
embeddingsso 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
listsearch-hit kind — not built. An item is reachable through the existingtaskkeyword 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 blockerdocs/LISTS.mdnamed 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 settingcontact_idon 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 thesm:bleed on up intolg, 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 newlist_destinationconfirmation 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 thelistsowner 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 putslistson 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 a400on 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 confidentnothingorambiguous(≥ 0.9) with no Claude call;refersandown_textstay 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 newwithUserTransactionopts 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 printYou 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: aSelectof 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 itsresolveDestination, and files throughfileNoteToList, so the item is exactly the one the chat link offer makes: first line as the item, the rest as its detail,source_note_idback 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 existingresolveConfirmationAction. With no lists the answer offers only creating one. Offered where the lists are known — the inbox loads them when anote_subjectorlist_destinationcard 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_INBOXnow hides anote_subject/list_destinationcard whoseapply.noteIdnote failsunfiledOnly(). The card is hidden, not resolved, which is the same approach the predicate already takes for a stalefollow_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 nonoteIdis unaffected.resolveConfirmationActionnow returns itsMutationResultinstead of throwing it, so the user-safe sentence reaches the card: it is toasted byConfirmationButtonand 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.EmptyStateis single-accent (amber) with no tone prop.nav.test.tsfails 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 spece2e/pages/sharing/request-access.spec.tshas 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_EMAILandE2E_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.