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 only when the code is written, verified (lint + build + manual run), and pushed — but checkboxes drift, so verify against code before relying on them.
Derived from BRD. Every feature, big and small. Items get checked
([x]) only when the code is written, verified (lint + build + manual run), and
pushed. 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) - Compact landing page for founders and relationship-driven professionals — broad capture story (meetings, notes, messages, introductions, voice, cards, and photos forwarded through the live WhatsApp/Telegram bots), capture → living graph → follow up proof, privacy/data-ownership trust, pricing teaser, short FAQ/access, and full pricing on
/pricing. Its application window now switches through First day / Taking shape / Integrated with compile-time synthetic data, mounts the same production Three.js globe when the preview approaches the viewport, and mounts the real MapLibre city map only after Open map. It imports no auth/repository code and receives no contact data. The first viewport links directly to the shipped WhatsApp, Telegram, web, and MCP capture channels; native mobile apps are labelled coming soon. - 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 is also what 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 instead a single embeddedAppWindow— the same mock the home page ships — with the eight capabilities demoted to copy cards beside it. That made/featuresa second copy of the home page and pulled Three.js, MapLibre, FullCalendar and sigma onto the route; the mock is home-only again. Measured on the dev server:/featuresrequests none of those four in its first 6s (the home page requests nine such resources), and the graph fixturefeature-graph.jsonis fetched only when a graph tab opens./featuresstays short and links to the optional long-form/product-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)
- 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. - 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)
- 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
- 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.
- 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 retired landing WebGL cursor, particle field, and GSAP scroll thread were removed. Rich graph UI remains click- or state-driven, and the production Sigma renderer is dynamically loaded only when its preview is selected. - 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) - 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) - 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
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)
- 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 - 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 - 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 - 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 waiting-list explanation + "subscribing puts you straight in" 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. Admin-invited,DHAGA_ADMIN_EMAILS, already-paid and core-only self-host accounts therefore get docs-only with zero waitlist wording. 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)
- 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 (Haiku, structured output)
- 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 - 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.ts,api/telegram/route.ts, andlib/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 - 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
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) - 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), anddedupeNoteTasksthen collapses each action back to ONE open row, oldest-first, because the extraction that runs on the newly attached note re-derives the very follow-ups the user already has — that is where the duplicate came from. Re-pointing rather than deleting-and-re-extracting is deliberate (the same reasoning asmoveNoteToContact): it keeps an edit the user already made, the row id and its calendar entry, and it cannot lose the reminder on a self-host with no LLM, where re-extraction writes nothing at all. 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.
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 - Stage 1 also plans alternate keywords (2026-08-10) — a question rarely uses the words the records do, so the same Haiku call that extracts filters now returns up to 6 domain synonyms ("investor" → vc, venture capital, angel, funding), OR'd onto the literal question. No extra model call and no third concurrent
index.search(which would want a third connection from a max-of-three tenant pool). The plan's tag filter also matches by prefix in either direction — it planned "investor" against a graph tagged "investor-connections" and matched nothing — still per-element, so it cannot rediscover the "ai" ⊂ "ret-ai-l" false positive - 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 included, 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. 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 do not engage: measured 2026-07-30, zero cached tokens across 33 calls, because every Dhaga system prompt is far below the model minimum (Haiku 4.5 4,096 / Sonnet 5 1,024 tokens). No cost model should assume the discount (BRD §8.2) - 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
- 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 does not erase
groups(2026-08-23) —group_membersandnote_contactsboth cascade fromcontacts, which the erasure deletes, so those go;groupshas no FK to anything the erasure names and is not inACCOUNT_OWNED_TABLES(utils/constants/account-deletion/tables.ts), whose own header says a table missing from that list is a silent breach and must be added whenever one is added to EE'sTENANT_TABLES— which all three tables were. The erasure does not abort (nothing RESTRICTs it), it simply leaves the rows: a group name and description are the user's own words about their own people, so the residue is real content, not a stub. Nothing catches it today because no test asserts parity between the two lists — worth adding with the fix. Ordering when it is added: afternotesandfacts(both RESTRICT togroups) and aftercontacts - 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. - 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, ≥90% accuracy on clean cards, under 5s
-
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)
Built but NOT published (restated 2026-08-09): apps/extension is a working
MV3 extension at manifest version 0.0.1 with a packaged .zip, but there is no
Chrome Web Store listing and no install link anywhere on the site — the only way
to run it is to load it unpacked from the repo. 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): popup 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 popup + contact search API
- Points at Dhaga Cloud by default (2026-08-09) —
https://dhaga.appandhttps://www.dhaga.appare both declaredhost_permissions(both serve; neither redirects), so cloud users get no runtime access prompt.localhost:3000stays declared for self-hosters and local dev - Settings behind a gear icon in the popup, both fields optional: instance
URL and API key. One implementation (
src/settings-form.ts) mounted by both the popup 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 1600px 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. Screenshots are read and discarded;shouldStoreCardPhotosis NOT consulted, since consent to keep a photo of a business card is not consent to keep pictures of web pages. 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. 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
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) → Haiku 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 with the 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 - 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.mdx - 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 Haiku 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 - 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 - 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, unchanged and still true: reminders land on the correct day in the user's zone, but every email still sends at one global time — per-user local-morning delivery was deliberately not built, since it needs an hourly cron (EMAIL_JOBS_HOURLY, Vercel-Pro-only; the Hobby schedule is unchanged at"17 6 * * *"). 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 — not built. Everything above is in-app or email only
- 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.tsthe 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: theEMAIL_JOBS_HOURLYlocal-08:00 gate (it now gates the one brief), thehostedTenants()+withUserDbfan-out, the clean no-op without Resend, and a missing settings row still reading off. 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 - 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)
- Calendar integration → brief pushed 30 min before the meeting
- 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 - 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)
- 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 is vestigial — the old in-chat question mechanism was removed and nothing reads or writes it; this checklist entry described it long after it went dead.) 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 'MNGL – 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 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)
- Org workspace, contact-level sharing controls
- "Who knows whom" across team
- SSO; per-seat billing
18. Monetization & launch
-
Landing detail preserved on focused routes (2026-08-02) — the conversion-focused home page links to
/featuresfor every original step, capture story, Ask demo, comparison, FAQ and the opt-in interactive network demo. Five indexable/use-cases/*pages speak directly to sales, founders, investors, recruiters and community builders. Routes are included in navigation, sitemap and generatedllms.txtdiscovery without restoring the home page's eager graph payload. -
Marketing header knows you are signed in (2026-08-03) — the focused-landing rework dropped the old
<Header isSignedIn>prop (it was fed 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 -
Free tier caps live; public pricing was re-cut on 2026-08-19 and now shows Pro at $5 / ₹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. All four tier × cadence combinations now carry an introductory price (pricing/offers.ts,INTRO_PRICES), charged in INR only: Pro ₹199/mo (~$1.99) and ₹1,999/yr (₹167/mo,$19.99), and — until 2026-08-24, when both became Power's STANDING price and stopped being offers at all (no$8.99) and ₹8,999/yr (₹750/mo, ~$89.99); each is shown struck-through against its standing price, with a per-day line and a stated term, because an unbounded "was ₹899, now ₹199" would be a false comparison price. The calendar deadline is GONE, and this line used to assert one (reversed 2026-08-20): the offer was bounded by "one window for all four, ending 2026-11-30 inclusive (UTC end of day)", enforced byRAZORPAY_OFFER_POWER_*id is configured, so nothing renders a Power discount) — Power ₹899/mo (INTRO_OFFER_ENDS_ATinpackages/ee/src/billing/intro/window.ts. That constant and that whole file are deleted — never quote the date again. What replaced it is two independent mechanisms that used to be one, and the deletion is the point rather than a side effect: keeping a date as well as a toggle would leave two ways to close the offer, free to disagree (a date passed while the toggle said open, or the reverse), with no surface able to say which price a buyer would actually get. (1) THE TERM — what the customer was promised.INTRO_TERM_MONTHS = 12(packages/ee/src/billing/intro/term.ts): a buyer holds their introductory price for their own first twelve months and then steps up to the standing price for that tier and cadence. The clock starts at their own subscription start, so two people who join months apart step up months apart, and closing the offer to new buyers moves nobody's date — there is deliberately no global date left in the package to disagree with that. It is a guarantee, not a lock-in: nothing here holds anyone to a term, the customer may cancel in any month, and a yearly subscriber keeps the year they paid for.introTermEndsAt()clamps the anniversary to the last day of the target month instead of letting JS roll it forward, for exactly one buyer — someone who subscribed on 29 February would otherwise land on 1 March, one day after the cycle Razorpay ends on 28 February, and we would tell them their price rises a month later than it does. That clamp outlived the mechanism it was written for: it was originally there so a nightly sweep could not hand a 29-February buyer a thirteenth introductory month, and now it is there so the notice email and the in-app banner quote the right date. (2) THE AVAILABILITY TOGGLE — whether we are still selling it.introOfferOpen()/setIntroOfferOpen()(intro/availability.ts) read and write theintro_offer_availabilitykey in a new hosted-only control-plane tablebilling_settings(text key PK, text value,updated_at, no RLS; DDLpackages/ee/src/db/tables-ddl/billing-settings.ts, repopackages/ee/src/billing/repo/settings.ts) — the twin of apps/web'sai_budget_settings, deliberately not the tenant-scopedsettingstable, whose primary key is(user_id, key)under RLS: an operator setting has no owner. Default OPEN (INTRO_OFFER_OPEN_BY_DEFAULT, absence of a row = open), so the deploy that introduced the toggle changed nothing — the four offers were live that morning and stayed live that afternoon. Closing it gates attaching an introductory OFFER to a NEW subscription and nothing else (it used to gate minting one against an introductory plan id; the gate is the same, the thing gated changed with the mechanism): it changes nothing for anyone already subscribed, whose twelve months are a promise made at purchase, and no operator action here shortens them. It is reversible, because closing an offer is a revenue decision and not the destruction of one. Still deliberately not an env var, but for a different reason than the date was: the owner closes it at a moment of their choosing ("when ten people have bought it"), from a screen, without a deploy. A failed read reports the default rather than throwing — the public/pricingpage calls this and must not go blank over a settings lookup — and that cannot leak a sale, because minting an introductory subscription needs the same database a moment later, so a database that cannot answer the toggle cannot complete a purchase either. Only the literal"closed"closes it;IntroOfferClosedErroris distinct from a generic checkout failure so the route can answer "the offer has ended", which a buyer can act on by buying the standard plan. THE STEP-UP — the sweep that used to do it is DELETED, and Razorpay now does it by itself (redesigned 2026-08-23; this passage used to describe a nightly sweep, and that sweep is gone). An introductory purchase is the STANDING plan with a Razorpay Offer attached at subscription creation (offer_id), configured in the Dashboard to discount a limited number of cycles — 12 on a monthly plan, 1 on a yearly plan, because a yearly plan bills once a year and twelve cycles would discount twelve years. When the discounted cycles run out Razorpay simply charges the standing amount, so the step-up needs no code, no schedule and no processor call from us. The fact that makes it work is that the e-mandate registers at the STANDING amount, not the discounted one — which is also why a customer's bank shows a cap higher than their first charge. Measured end to end in the sandbox on both Indian rails: standing ₹499 plan + limited-cycle offer → ₹199 invoice paid, mandatemax_amount₹499. Razorpay resolves the payment-rail variant of an offer itself (a UPI offer id posted for a card payment was rewritten to the card offer), so the code passes one offer id per (tier, cadence) and never selects by rail. Offer ids are Dashboard-created — the API refuses to create, list or read them — so they reach the app only asRAZORPAY_OFFER_PRO_MONTHLY/_PRO_YEARLY/_POWER_MONTHLY/_POWER_YEARLY. With none configured every surface shows the standing price and every button sells the standing plan, and that is the CORRECT default rather than a degraded one:getIntroOffers()returns an offer for a (tier, cadence) only when Razorpay is enabled, the availability toggle is open, an offer id is configured for that exact pair, and the standing plan id is configured for it — and every display surface already renders from that list, so an empty list is simply "no introductory price today". Checkout fails closed: an introductory cadence that reaches checkout with no offer id configured is REFUSED, customer-visibly, because silently charging the standing amount at a button that advertised the introductory one is the one outcome that must not be possible. Because the subscription is created on the standing plan, reverse-lookup by plan id cannot tell an introductory purchase from an ordinary one, so the row records the offer it was bought with in a new nullable columnsubscriptions.intro_offer_id(text) — cadence staysmonthly/yearly, honest with what Razorpay holds, andintro_offer_idis what the term-end notice, the banner and the plan-change forfeit warning now key off, replacing the old "cadence is an intro cadence" test. An admin can freeze one account's price:holdIntroPrice(userId, adminUserId)/releaseIntroPrice/isIntroPriceHeld(intro/hold.ts), backed by two new nullable columnssubscriptions.intro_price_held_atandintro_price_held_by(packages/ee/src/db/tables-ddl/subscriptions.ts, schemapackages/ee/src/db/schema/billing.ts) — a timestamp rather than a boolean beside it, and the admin's id rather than nothing, because a decision that gives up revenue indefinitely has to record who made it and when. 🛑 What a hold now DOES is an open question, and this line used to say it unbooked an already-scheduled step-up at Razorpay. It could, because a sweep had booked one and a sweep could unbook it. Under the offer model nothing is booked: Razorpay ends the discount on its own schedule and there is no update call to take back (that is the whole reason the sweep died). The columns, the admin card and the read helpers are not listed among the deletions, so they survive — but until someone decides what freezing a price means when the processor owns the step-up, treat the hold as recording an intention, not as something that stops a price rising. Do not tick this entry on the strength of it. Who is about to be repriced, and when, isfindIntroStepUpsBetween(from, to)+withStandingAmounts()(intro/notice.ts) — the query the 30-day notice email reads, and it survives the sweep's deletion, re-keyed: it selects onintro_offer_idbeing set rather than on an introductory cadence, and dates each row fromintroTermEndsAt()rather than from a change booked at the processor. How many introductory subscriptions have been sold iscountIntroSubscriptionsSold()(packages/ee/src/billing/repo/intro.ts), and it is derived, not a stored counter:subscriptionsUNIONpayments(never UNION ALL) de-duplicated byuser_id, because the subscription row is the only record of a mandate that has not settled while the payment ledger is the only thing that still remembers a customer who has since stepped up or cancelled — so the answer is people, not rows. Covered bypackages/ee/src/billing/__tests__/intro-term.test.ts,intro-availability.test.tsandintro-notice-and-hold.test.ts(intro-window.test.tswent with the deleted date;intro-step-up.test.tswent with the deleted sweep). The admin surface exists (read off disk 2026-08-20):AvailabilityCardon/app/admin/subscriptionsshows the toggle beside that derived sold count,IntroPriceHoldCardon/app/admin/users/[id]holds and releases one account's price, both driven by server actions inapps/web/src/lib/actions/admin/intro-offer.ts(setIntroOfferAvailabilityAction,holdIntroPriceAction,releaseIntroPriceAction; components undercomponents/app/admin/intro-offer/, unit-tested inlib/actions/admin/__tests__/intro-offer.test.ts); manual click-through is pending like the rest of this section. The hosted module reachesapps/webthrough the billing gate (lib/hosted/gate/billing-types/,defaults.ts), whose defaults no-op —findIntroStepUpsBetweenreturns[]— so a self-hosted deployment runs the same code and finds nobody. THE SCHEDULE AND THE NOTICE EMAIL LANDED 2026-08-20 (read off disk, not taken on a report):apps/web/src/lib/jobs/intro-step-up/—notices.ts,send-notice.ts,sent-record.ts— is wired into/api/jobs/dailyunder the keyintroStepUpNotices. There is noapi/cronroute in this repo and never was; the daily jobs route is the schedule, the same one the plan-lapse notices ride. Theindex.tssweep wrapper and theintroStepUpsjob key are DELETED with the sweep (2026-08-23), and so is the sentence this line used to carry about sweep-before-notice being load-bearing. That ordering existed because until a change was booked the date was only a prediction; there is nothing to book now, so the notice quotesintroTermEndsAt()— the deterministic anniversary — and there is no second job whose ordering it depends on. The notice window isINTRO_STEP_UP_NOTICE_LEAD_DAYS(utils/constants/intro-step-up.ts) and the job asks for exactly[now, now + LEAD_DAYS), half-open so a boundary date is never both sent and skipped. "Already told" is stored per user in thesettingsk/v table as the step-up's own date, not a boolean (sent-record.ts;isSameStepUpcompares within a tolerance rather than for equality — the tolerance was there because a booked change moved the date by hours, and it is kept because a deterministic date computed either side of a DST or timezone boundary can still differ by hours) — which is exactly what lets it re-arm, since a customer who buys again years later has a step-up the old record no longer describes. Templateapps/web/src/lib/email/intro-step-up.ts(introStepUpSubject/introStepUpHtml, formatting the date once so subject and body cannot disagree). The notice job IS tested against a stubbed billing gate —lib/jobs/intro-step-up/notices.test.ts(18 cases, counted off disk 2026-08-20;step-ups.test.tswent with the deleted sweep); note it sits beside the source, not in a__tests__/directory, which is why more than one search for it has wrongly concluded there were none — and the cases carrying the most weight are the idempotence pair (silent when the date shifts by a day or two, but a term end a year later is a NEW one), that a failed send records nothing so tomorrow retries, that one customer's failure does not cost the rest of the batch its notice, that the email is transactional with no opt-out footer, that neither the recipient's address nor any account identifier reaches a log, and that the job no-ops on a self-hosted deployment through the real no-op gate.sent-record.ts's header promises "its own tests" and those are the four notice-record cases, exercised through the job rather than in a file of its own; the email template likewise has no test of its own, its subject and body being asserted through the notice job. THE FINAL-MONTH IN-APP BANNER IS BUILT AND MOUNTED (read off disk 2026-08-20; this line asserted the opposite until then, and an earlier sweep that said "nothing underapps/web/src/componentsreferences a step-up" was reading a stale snapshot).apps/web/src/components/app/billing/—IntroStepUpBanner.tsx,notice.tsthe pure decision,resolve.tsthe guarded read,dismissal.ts,actions.ts— mounted inapps/web/src/app/app/layout.tsxinside<main>, mutually exclusive withLapsedPlanBanner(a lapsed plan has no live subscription whose term could still be running). It reads the SAME date the notice email quotes, which is what stops two channels quoting two dates — but that date is nowintroTermEndsAt()offintro_offer_id, not a change booked at the processor, and this line used to say the opposite ("it reads the same booked change", "stated by the processor, not predicted by us"). Under the offer model the processor books nothing: it ends the discount on its own and tells us in advance of nothing, so the deterministic anniversary is the only date either channel can quote, and the fact that both compute it the same way is what keeps them agreeing. "The final month" is now the ~30-day window before that anniversary rather than the existence of a booking. It adds no per-request DB read for anyone not on an introductory price: theintro_offer_idcheck is a pure presence test over a summary the shell already holds for the plan pill and the lapse notice, and only someone genuinely in their final month goes on to read the dismissal row — the guard lives inresolve.tsso the shell cannot forget it, andresolve.test.tspins it. Dismissal stores the step-up's own token, following the lapsed-plan notice key, so it re-arms a year later with nothing having to reset it. Covered bycomponents/app/billing/notice.test.ts(13 cases) andresolve.test.ts(8) — again beside the source, not under__tests__/. Do not conflate it with the settingsPlanCard'sintroTermLine(utils/constants/landing/pricing/intro.ts): that is a pre-purchase disclosure of the OFFER's term to somebody about to buy, this is a countdown for somebody already subscribed. Both exist and they are different surfaces. What stays unticked is that anyone has SEEN either warning: no purchase has ever been made at an introductory price, so no notice has been sent, no term has run out, and the banner has only ever rendered under test. None of the above has run in production — the daily route has never executed the notice job against a real cohort, because there is no cohort. THOSE TWO UNPROVEN THINGS WERE TESTED ON 2026-08-20 AND BOTH WERE CONFIRMED DEFECTS — THE STEP-UP COULD NOT RUN AS BUILT, AND THAT IS WHY THE OFFER MECHANISM ABOVE REPLACED IT ON 2026-08-23. The measurement is kept rather than deleted because it is the evidence the offer model rests on; the 🛑 that stood here is discharged, not forgotten. A human authorised a real subscription against a ₹199 test plan in the Razorpay sandbox, once on card and once on UPI AutoPay, then issued the exact call the (since deleted) sweep made. (1) The e-mandatemax_amountregisters at the PLAN AMOUNT — ₹199 — on both rails, not at a default; a ₹499 step-up debit would be refused at charge time. The ₹99,000 SDK default cited in earlier versions of this line belongs to the registration flow, not to Subscriptions, and applying it here was wrong. (2)PATCH /subscriptions/{id}with{plan_id, schedule_change_at:"cycle_end"}is refused outright with a 400 on both Indian payment methods — card: "Only offers can be updated for subscriptions when payment mode is domestic card."; UPI: "subscriptions cannot be updated when payment mode is upi". A bare{quantity}update was refused on card too ("Can't update subscription immediately when card mandate is applicable"), so this is not specific to the plan id. UPI is stricter than card: card at least accepts an offer-id update, UPI accepts no update at all. Not overstated: this was sandbox, and test-mode mandates never touch real bank rails — but these are structural payment-mode constraints with explicit error messages, agreeing across two independent rails, so they are treated as authoritative unless Razorpay says otherwise. RESOLVED 2026-08-23, and this line used to end "do not sell an introductory price until this is resolved". No customer was ever affected, because no purchase had ever been completed at one. The direction the errors pointed — offers, attached at subscription creation rather than by update — was then measured rather than assumed: a Dashboard offer can be scoped to a limited number of cycles, and standing plan + offer was driven end to end on both rails. Both defects dissolve rather than get fixed, which is the cleaner outcome: the subscription is created on the standing plan, so the mandate registers at ₹499 — the amount the step-up needs — and no update call is made anywhere, so there is nothing left for Razorpay to refuse. Still true, and not a defect: nothing has been sold at an introductory price, and the offer path was proved in the sandbox, not in production. A separate, pre-existing bug fell out of (2) — ordinary tier changes made the same call — and THAT one is FIXED (2026-08-21) by minting a second, future-dated subscription instead. With the intro sweep deleted, that call has no callers left and is deleted too. See the plan-change entry in §19. A PUBLIC PROMISE WAS REVERSED, and it was costless — record why. The old/pricingFAQ said an introductory subscriber "goes on being charged that amount for as long as it stays active", justified by the immutability of a Razorpay plan. That is false under the term, and the copy is gone (apps/web/src/utils/constants/landing/pricing/faq/billing.ts, which now carries a standing comment forbidding both a price-forever claim and a date). Nobody was grandfathered because there is nobody to grandfather: verified against the Razorpay API on 2026-08-20, the forever promise had never applied to a live subscription — no introductory mandate was ever completed. Nobody was ever charged under the old promise, which is why withdrawing it cost nothing. The cadencesintro_monthly/intro_yearlyare deliberately not on the standard ladder — and note that under the offer model they are a checkout SELECTION only and never a stored value: the row that results carries cadencemonthly/yearly(it is on the standing plan) and records the purchase as introductory inintro_offer_id.changePlan()refuses them and they are first-purchase-only, so no existing subscriber can switch onto one; existing subscribers are NOT migrated (Razorpay will not, and we deliberately do not). Credit allowances are untouched: free 10, Pro 300, Power 1,000. The USD offer 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 Power's standing and introductory 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; 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, so an introductory subscription gets an introductory ceiling (₹199/mo → $6.86, ₹167/mo → $5.76), 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. Discharged since this line was last written: all four introductory Razorpay Plans now exist live (INR, verified by read-back) andRAZORPAY_PLAN_PRO_INTRO_MONTHLY/_PRO_INTRO_YEARLY/_POWER_INTRO_MONTHLY/_POWER_INTRO_YEARLYare set on Vercel Production and Preview — kept as RESOLVE-ONLY ids since 2026-08-23, beside the_LEGACYones, and no longer a purchase path: nobody ever bought one, but a Razorpay plan id that could appear on a historical row must still resolve to a tier rather than throw. What you configure to sell an introductory price is now the four OFFER ids (RAZORPAY_OFFER_PRO_MONTHLY/_PRO_YEARLY/_POWER_MONTHLY/_POWER_YEARLY), created by hand in the Razorpay Dashboard because the API will not create, list or read an offer — and setting them up in the Dashboard is itself unchecked: redemption type "limited number of cycles", 12 cycles on monthly and 1 on yearly, "block" on payment failure, a minimum amount below the cheapest plan, and enabled (a disabled offer fails the payment with "Payment method used is not eligible for offer"). 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 step-up — 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: no purchase has been made at any introductory price, and 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. -
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 — 15 "Guides" posts (10 pillars + 5 "Dhaga vs X" comparisons; 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
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) - 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,RAZORPAY_MANDATE_REGISTRATION_INR) — so "nothing is charged today" is FALSE and appears on no surface. 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. The INTRODUCTORY twelve-month step-up is fixed separately and by a different redesign, and this line used to say it was NOT fixed (reversed 2026-08-23): there is no longer a call to fix, because the sweep and the in-place plan update are both deleted. An introductory purchase is now the STANDING plan plus a Razorpay Offer attached at subscription creation, which discounts the first N cycles and steps the price up by itself (§18). 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 - 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), revoked by a refund or dispute, and not revoked by a cancellation — the term was paid for. 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 - 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 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
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 four additive write tools, 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. Ten tools, split read/write across two files so the blast radius of the write half is obvious in review (lib/mcp/tools/{reads,writes}.ts, 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_follow_up(YYYY-MM-DDdue date optional) anddhaga_close_follow_up(done/dismissed). No delete, merge, bulk action, export or admin tool, deliberately — 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.ts) after auth so the bucket keys to a real user, answering 429 withretry-after: 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 ten-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). User guide:apps/web/content/docs/guide/mcp.mdx - 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 — surface likely edges from shared company/school/city/event; suggested, never auto-linked
- Relationship analytics dashboard — network stats (most-connected, longest-known, city clusters, network growth), own-graph only
- Contact/profile completeness scoring + enrichment nudges (enrichment stays user-triggered)
- 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. - 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 a secondvercel.jsonentry, which Hobby's one-cron-a-day limit would break. 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 withinCONTACT_AUTO_SYNC_MIN_INTERVAL_MS= 6h). 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)
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). Anti-abuse: self-ref + duplicate + per-referrer cap.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.